Skip to content

LUX Weave ​

Two apps, written by two vendors, shipped a year apart. The second one has something to say about a business object the first one owns. Weave is how it says so — without a class import, without a route, without an edit to the first app.

Three attributes carry it:

AttributeGoes onSays
#[Concept]the module that owns the model"this business object exists, and this is its public name"
#[ConceptEntry]an app"I am where you land when you open one of these"
#[Weave]an app"I have a page about these — put me in the menu"

Everything else — the menu in the hub app, the deep link, the authorization — follows from those three declarations.

A concept is a public name for a business object ​

php
use LaravelUi5\Sdk\Intent\Attributes\Concept;

#[Concept(name: 'acme.customer', model: Customer::class, label: 'Customer')]
class SalesModule extends AbstractUi5Module { /* … */ }
  • name is the wire identity, vendor-namespaced. Other packages reference this string — never your model class — so they weave to you without depending on your code.
  • model is the Eloquent model behind it. Its primary key is the concept's instance key. This is a local binding, not part of the identity.
  • label is a short English display name. Every concept is presentable, so it is required.
  • key overrides the instance key. Set it only for a surrogate or composite key.

The attribute goes on the module, because the owner of the model declares the concept — a UI-less domain library can own one just as well as an app can. A concept name may be declared once in an installation; a second declaration is a build-time error naming the first definer.

The SDK's own concepts are laravelui5.partner (the Partners app) and laravelui5.setting (the Settings app).

The front door ​

One app per concept is where you land when someone opens an instance of it:

php
use LaravelUi5\Sdk\Intent\Attributes\ConceptEntry;

#[ConceptEntry(concept: 'acme.customer', route: 'customer/{0}')]
class SalesApp extends AbstractUi5App { /* … */ }

The route is relative to the app root, and {0} is filled with the instance key at dispatch. There is no label and no icon: a front door is a referential jump, not a menu item.

At most one front door per concept, across the whole installation — a second one is a build-time collision. An app may be the front door of several concepts; the attribute is repeatable for that.

Declaring the front door is also what makes this app the concept's hub: the app that carries the related-menu for everyone weaving to it. That is one declaration, not one per contributor.

One placeholder, two segments

{0} is the only placeholder, but a key containing a slash expands into two path segments. The Settings app uses that: its front door is setting/{0}, and a reference of com.acme.orders/pageSize reaches the two-parameter route setting/{artifactNamespace}/{setting} with no handler change.

The doorways ​

An app that has a page about someone else's concept says so on itself:

php
use LaravelUi5\Sdk\Intent\Attributes\Weave;

#[Weave(
    concept: 'acme.customer',
    route:   'orders/customer/{0}',
    label:   'Orders',
    icon:    'sap-icon://sales-order',
)]
class OrdersApp extends AbstractUi5App { /* … */ }

route is again relative to the app root with {0} for the crossing key. label is the menu entry's text, icon an icon URI.

Translation across an app boundary is not specified yet

label reaches the shell as it was written and is rendered verbatim: today it must be finished text, not a key. The attribute's own docblock still calls it an i18n key — it is not one, and passing weave.orders puts weave.orders in front of the user.

The reason it is open rather than merely wrong: a Weave entry shows the target app's label inside the hub app's shell, and the hub does not have the target's resource bundle. Who resolves the key, and against which bundle, is a decision about the LeanShell's i18n contract as a whole — it covers #[Concept]'s label, the navigation entries and every other human-readable string the chrome carries across an app boundary. Three shapes are on the table: literal text everywhere; server-side resolution, so the contributor puts finished text on the wire (which is what the payload below already shows); or the hub loading foreign bundles. Until that is settled, write plain text and keep it short.

Tracked in the SDK roadmap as the umbrella entry for shell i18n.

The hub declares nothing. It reads every #[Weave] pointed at its concept and renders each as a menu item. Ship a new contributor and the hub gains a doorway with an empty diff.

Crossing, in the app ​

Two intents do the travelling, and both take the concept by name (Intent Dispatch).

weave.open — jump to a concept's own detail. The caller names the concept and the key. It does not know the hub, its route, or whether it is even installed.

js
LaravelUi5.dispatchIntent({
    name: 'weave.open',
    parameters: { concept: 'laravelui5.partner', refKey: partnerId },
});

weave.navigate — follow an outbound arc. This is what the related-menu fires: from the hub's detail page to a contributor's page about the same instance.

weave.openweave.navigate
Directioninbound, to the concept's own detailoutbound, to a related app
Parametersconcept, refKeyconcept, target, refKey
Which app is gatedthe app providing the front doorthe target app
Fails whenthe concept has no front doorthe target is not one of the concept's doorways

Neither intent lets the client name a URL. The client sends a concept, a target namespace and a key; the server re-resolves the route and answers with a redirect. A forged target is denied because it is not in the concept's outbound list.

The menu is one tag, with no attributes and no controller code:

xml
<mvc:View xmlns:lux="com.laravelui5.core.controls" …>
    <uxap:ObjectPageLayout>
        <uxap:headerTitle>
            <uxap:ObjectPageDynamicHeaderTitle>
                <uxap:actions>
                    <lux:Weave/>
                </uxap:actions>
            </uxap:ObjectPageDynamicHeaderTitle>
        </uxap:headerTitle>
    </uxap:ObjectPageLayout>
</mvc:View>

It reads the doorways the server delivered, builds one menu item per entry, and takes the crossing key off the current binding context using the column name the server named — it never guesses id. When there are no doorways it hides itself, so a hub page looks finished either way. Outside the shell — on Core alone — the server sends no doorways and the control renders nothing.

What the server sends ​

The shell's context carries a weave key: the outbound doorways of the concept the current app provides (Context API).

json
"weave": [
  { "concept": "acme.customer", "target": "com.acme.orders", "key": "id",
    "label": "Orders", "icon": "sap-icon://sales-order" }
]
FieldIs
conceptthe concept name both sides agreed on
targetthe contributing app's namespace — what weave.navigate sends back
keythe column the crossing key is read from, resolved from the concept's model
label, iconthe menu entry's text and icon — the text as written, see the note above

There is no route in it. The list is already filtered by the actor's #[Access] on each target app, and the same rule is applied again when the intent is dispatched — the pre-filtered list is a convenience, not the gate.

Inspecting the graph ​

bash
php artisan ui5:concept            # every concept, with its inbound and outbound counts
php artisan ui5:concept acme.customer

It reads the booted registry, so it shows what the host will actually answer with.

What fails at build time ​

Weave has no runtime "link not found" state. Everything is decided when the registry is built:

DeclarationError
two #[Concept] with the same nameConcept […] is already defined by […]
#[Concept] whose model is not an Eloquent modelrefused
a second #[ConceptEntry] for one conceptbuild-time collision
#[ConceptEntry] or #[Weave] with an empty routerefused — a doorway must go somewhere
either one naming an unknown conceptDeclare a #[Concept] with that name first

Nothing to migrate ​

The graph is read from attributes when the registry is built. There is no table, no seeder and no ui5:sync step for it. In production it is baked into bootstrap/cache/ui5.php by ui5:cache, so run that on deployment like you already do. Remove a package and its doorways are simply gone; there are no orphans to clean up.

Worked example — the reciprocal pair the SDK ships ​

Two of the SDK's own apps weave to each other, and neither imports a line of the other's code.

A partner's overridden settings → the setting. The Partners app shows, on a person, the settings that have been overridden for them. Each row links to the setting itself:

js
LaravelUi5.dispatchIntent({
    name: 'weave.open',
    parameters: { concept: 'laravelui5.setting', refKey: `${artifactNamespace}/${setting}` },
});

The Settings app's front door is setting/{0}, and because the refKey carries a slash it expands into the two-segment deep link — one placeholder, a two-parameter route.

A setting's User value → the partner. The Settings app lists every stored value of a setting, including the per-user ones. The owner of a User row links back:

js
LaravelUi5.dispatchIntent({
    name: 'weave.open',
    parameters: { concept: 'laravelui5.partner', refKey: partnerId },
});

Neither call names an app, a route or a URL. Each names a concept and a key. Uninstall the other app and the link stops resolving instead of 404-ing into a missing route — and a partner who may not open the target app is refused before anything is rendered.

The other two facets ​

The same idea — name a stable anchor, contribute to it, no edit on the receiving side — carries two more contributions:

Not there yet ​

  • One key per crossing. {0} is the only placeholder; composite crossings are out of scope.
  • No package index. php artisan ui5:describe writes an extra.laravelui5 block into your composer.json — the concepts your package defines, its catalogue category, its tags — and --check fails a CI run when the block has drifted. Nothing reads those blocks yet. Publish them if you want to be listed later; expect no effect today.
  • The related menu is the younger half. The inbound direction is exercised in both shipped apps (above). The outbound one — #[Weave], context.weave, <lux:Weave/> — ships and is covered by tests, but it has no declaration in a shipped app yet, because the one natural candidate is waiting on a view (a per-partner overrides page in the Settings app). If you build the first related menu in a product, expect to be the one who finds its rough edges.

See also ​