Ui5Dashboard
Introduction
A Ui5Dashboard is a routable composition container: a named artifact that aggregates Groups, each of which composes Tiles, Cards and Charts into a layout.
A dashboard is not a page you render. It has no Blade template and no HTML endpoint. It serves one JSON manifest describing a UI5 control tree, which the <lux:Dashboard> control in @laravelui5/core walks and materialises into native controls on the client. Server-side you author artifacts; the framework turns them into a control tree.
Two artifact types work together and are documented on this one page, because neither means anything alone:
| Type | CoreArtifactType | Routable | Role |
|---|---|---|---|
| Dashboard | Dashboard = 6 | Yes — dashboard/{ns}@{ver}/manifest.json | the root container; holds Groups |
| Dashboard Group | DashboardGroup = 7 | No | the composition node; names the Tiles/Cards/Charts it renders |
Conceptual Overview
Three seats, and who owns which
One rule explains every method on both interfaces — and prevents the mistake that costs the most time:
- You own the properties. Each artifact returns a template DTO for the control it renders as —
getVBox()on the dashboard,getPanel()andgetGridContainer()on the group. Set any property you like on them. - The framework owns the child aggregations. It injects the built children via
withItems()/withContent()on those DTOs. Never setitemsorcontentyourself — anything you put there is silently overwritten. - The registry owns resolution. A group names its children by namespace string, not by instance. The framework resolves each against the registry.
So a group declares which children and how the panel looks; it never holds the children and never builds their controls.
Properties are SAP control properties
A dashboard renders as a fixed nesting — VBox → Panel → GridContainer → children — and those are literally sap.m.VBox, sap.m.Panel and sap.f.GridContainer. The DTOs mirror the real control APIs one-to-one.
That is why the group header is headerText and not title: it is the Panel's own property name. Look properties up in the OpenUI5 API reference, not here — if sap.m.Panel has it, the Panel DTO takes it.
How to Generate
Two commands, one per type:
php artisan ui5:dashboard Sales/Overview
php artisan ui5:group Sales/ThisMonth| Command | Argument | Creates |
|---|---|---|
ui5:dashboard | {AppName}/{DashboardName} | src/Dashboards/{Name}.php |
ui5:group | {ModuleName}/{GroupName} | src/Groups/{Name}Group.php |
The PHP and UI5 namespaces derive from the target module — there are no namespace-prefix options.
Output
Given ui5:dashboard Sales/Overview and three ui5:group runs:
ui5/
└── Sales/
└── src/
├── Dashboards/
│ └── Overview.php
└── Groups/
├── ThisMonthGroup.php
├── PipelineGroup.php
└── PerformanceGroup.phpNo Blade files. A dashboard has no template — if you are looking for one, you are looking for the old mechanism.
Artifact Roles
Ui5Dashboard
Identity is declared as class constants; the getters come from AbstractUi5Dashboard. The only required behaviour is naming the groups:
class Overview extends AbstractUi5Dashboard
{
public const string NAMESPACE = 'com.acme.sales.dashboards.overview';
public const string TITLE = 'Sales Overview';
public const string DESCRIPTION = 'Pipeline, performance and this month at a glance';
/** @return Ui5DashboardGroupInterface[] */
public function getGroups(): array
{
return [
new ThisMonthGroup($this->module),
new PipelineGroup($this->module),
new PerformanceGroup($this->module),
];
}
}Groups are held as instances — a dashboard composes its own groups directly. (Children inside a group are the opposite: namespaces, resolved by the registry. The asymmetry is deliberate; see Why namespaces, not instances.)
Group order is display order. If instantiating is expensive, cache the array in the constructor and return the property instead — the framework calls getGroups() once per request.
To customise the root layout, override getVBox():
public function getVBox(): VBox
{
return new VBox(
renderType: FlexRendertype::Bare,
class: Margin::Responsive,
);
}Set anything except items — the framework injects the built group elements through VBox::withItems().
Ui5DashboardGroup
class PerformanceGroup extends AbstractUi5DashboardGroup
{
public const string NAMESPACE = 'com.acme.sales.groups.performance';
public const string TITLE = 'Performance';
public const string DESCRIPTION = 'Outcomes — quota, revenue mix, and account detail';
public function getChildNamespaces(): array
{
return [
QuotaAttainmentChart::NAMESPACE,
RevenueMixChart::NAMESPACE,
AccountSnapshotCard::NAMESPACE,
];
}
}getChildNamespaces() is the only abstract method on the base — everything else has a working default. The array order is the display order.
The list does not have to be literal. It is resolved per request, so a group may compute it — the LUX Launchpad harvests every tile carrying a placement marker and returns the namespaces sorted by weight. The contract is only that each returned namespace resolves to a registered artifact.
AbstractUi5DashboardGroup gives you a sap.m.Panel and a bare sap.f.GridContainer. The Panel's headerText binds the group's namespace in the app's bundle — {i18n>com.acme.sales.groups.performance} — so TITLE stays the text you read in the code and the bundle translates it:
# webapp/i18n/i18n_de.properties
com.acme.sales.groups.performance=LeistungA missing key shows as the key itself; php artisan ui5:doctor reports it (core.W8, The Doctor). A TITLE written as a binding, {i18n>key}, is emitted as it is.
TITLE as written.Override either to customise:
public function getPanel(): Panel
{
return new Panel(
headerText: I18nBinding::title($this), // LaravelUi5\Core\Ui5\Emit
expandable: true,
backgroundDesign: BackgroundDesign::Transparent,
);
}
public function getGridContainer(): GridContainer
{
return new GridContainer(snapToRow: true);
}Set anything except Panel.content and GridContainer.items — the framework owns that nesting and injects through Panel::withContent() and GridContainer::withItems().
Registration
A dashboard is a module artifact, registered like every other:
class SalesModule extends AbstractUi5Module
{
public const string ARTIFACT_ROOT = SalesApp::class;
public const array ARTIFACTS = [
Dashboards\Overview::class,
Charts\QuotaAttainmentChart::class,
Charts\RevenueMixChart::class,
Cards\AccountSnapshotCard::class,
];
}Every child must also be registered on its module
A namespace listed in getChildNamespaces() is resolved against the registry. If the artifact was never named in its module's ARTIFACTS, composition fails loudly with UnregisteredDashboardChildException — it does not silently 404 later when the client fetches a card manifest. Listing a child and registering it are two separate, deliberate acts.
Groups themselves are not registered on the module. They are reached through the dashboard's getGroups(), and that is their only entry point.
while it was built — drops out of the dashboard too, as a hidden group does: no empty panel and no stray heading. A failed child is still reported.
Why namespaces, not instances
A group could have held its children as objects. It names them instead, for one reason: a namespace is a reference another package can also produce. That enables the cross-module composition below, and it keeps a group's declaration free of use statements pointing into foreign packages.
Cross-module composition
Since Core 1.1.0.
A composer require'd module can drop one of its Tiles/Cards/Charts into another module's group without editing that group — the LUX "compose across modules" promise.
The contributing module registers one line in its service provider:
public function boot(): void
{
app(DashboardGroupCollector::class)->add(
PerformanceGroup::NAMESPACE,
MyForecastChart::NAMESPACE,
);
}- Contributions are artifact namespace strings, same currency as
getChildNamespaces(). add()is idempotent per(group, artifact)pair.- Registration order is render order; own children come first, then contributions, deduped.
- A contributed child resolves through the registry exactly like an own child — so it must be a normally registered artifact on its own module.
A group never lists foreign contributions itself, and never learns that it received any. With an empty collector the walk is a plain 1:1 render — the Core-only default.
Slots
Both types implement SlotProposableInterface: they consume slots (getRequiredSlots()) and propose them to the composition (getSlotProposals()). A dashboard is a composition node, so it does both.
public function getRequiredSlots(): array
{
return [CoreSlots::Period, SalesSlots::Region];
}
public function getSlotProposals(): array
{
return [CoreSlots::Period->value => 'current-quarter'];
}Core ships six canonical slots on CoreSlots (locale, timezone, period, date, date_from, date_to); anything domain-specific is your own backed enum. Proposals are keyed by the slot's backing value — PHP forbids enum instances as array keys.
A proposal is what this node offers downward: a child that requires period and receives no request override resolves it from the composition. The full precedence chain (Request → Actor → Composition → Setting) is in Slots.
Identity never travels through a slot. A provider reads the actor from $context; slots carry wire-shaped values only.
Visibility: the veto chain
To hide a group or a child for some actors, you do not filter getGroups() or getChildNamespaces() — those stay declarative. Register a VetoerInterface by container tag; it answers one question about one artifact:
public function dispose(
ArtifactInterface $artifact,
Ui5ContextInterface $context,
): Disposition;The chain folds all registered vetoers most-restrictive-wins — any Hide wins and short-circuits, else any Lock, else Show. That makes the outcome order-independent, and a vetoer needs to know neither the other vetoers nor the tree.
The chain is consulted before a hidden artifact is built, so it is pruned pre-data — its provider never runs, and it costs nothing. An empty chain returns Show for everything, which is Core's default. Lock is declared and folds above Show, but is not yet enforced.
This is the seat for access control, lifecycle and personalization alike. A vetoed child leaves no trace on the wire — see the errors note under Failure isolation for how to tell the two apart.
After the build: the gate chain
since 3.0 Some questions can only be answered once a child is built — chiefly *where doesits press lead?* A tile's press-intent is the parameters its provider returns, so it does not exist before the provider runs. For those, register an ElementGateInterface by container tag:
public function dispose(
WireElement $element, // the built child
ArtifactInterface $subject, // the artifact it was built from
Ui5ContextInterface $context,
): Disposition;// in a service provider
$this->app->tag([NavigationGate::class], ElementGateInterface::class);A gate reads the intent typed: GenericTile (and ActionTile), AbstractCard (and Lux\Chart) and Button implement CarriesPressIntent, whose pressIntent() returns the parameters payload or null.
final class NavigationGate implements ElementGateInterface
{
public function dispose(WireElement $element, ArtifactInterface $subject, Ui5ContextInterface $context): Disposition
{
$target = $element instanceof CarriesPressIntent ? ($element->pressIntent()['target'] ?? null) : null;
return $target !== null && ! $this->policy->allows($context, $target)
? Disposition::Hide
: Disposition::Show;
}
}The gates fold exactly like vetoers — most-restrictive-wins, Lock reserved and rendered as Show, nothing registered means Show for everything. A child a gate hides is pruned, not failed: absent from the wire and absent from errors.
Two limits to keep in mind. A gate costs the build of an element it may drop, so anything decidable without the element belongs in a vetoer. And it sees a group's direct children only — a press-intent nested deeper, such as a button in a card header's toolbar, is not walked.
Failure isolation
A child whose provider throws does not 500 the endpoint. Its container emitter catches the cause, records it in the EmitErrorSink, omits the node, and the siblings render. The same applies one level up: a group that throws is dropped while its sibling groups survive.
After the walk the controller report()s each cause once (server-side observability — since 3.0 that includes Core's own wiring faults, such as a tile requesting a slot that is not in the catalog) and stamps the sanitised entries into the envelope's errors array. The wire reason is debug-gated — full Class: message in dev, a stable generic line in production. Raw exception text never reaches a customer browser.
The one unguarded seat is the root VBox: if that throws there is nothing to render, so it bubbles as a whole-dashboard failure.
What the endpoint actually returns
Useful when a tile is missing from the rendered dashboard and you want to know why. The manifest response wraps the control tree in an envelope:
{
"schemaVersion": "1.0",
"namespace": "com.acme.sales.dashboards.overview",
"title": "Sales Overview",
"description": "Pipeline, performance and this month at a glance",
"params": ["period", "region"],
"tree": { "sap.m.VBox": { "…": "…" } },
"errors": []
}errors— one entry per isolated child. A hole in the dashboard with an emptyerrorsarray means the child was vetoed or gated, not that it failed.params— the dashboard's owngetRequiredSlots(), by backing value, so the client knows what it may vary.tree— the control tree. Each node is{"<ui5.class.name>": { …properties }}, recursive through child aggregations.
The response carries Cache-Control: no-store.
Consuming the manifest
The client side is the <lux:Dashboard> control from @laravelui5/core, which fetches the manifest, walks the tree and materialises native controls.
It emits one action event for both consumer-handled card CTAs and tile presses. oEvent.getSource() is the <lux:Dashboard> control itself (idiomatic UI5); the originating child arrives as the child parameter, alongside type ("Custom" for a card action, "Press" for a tile) and the verbatim parameters. Consumers switch on parameters.method, never on type — type is provenance, not routing.
Raw Navigation card actions do not surface: the card navigates itself.
See the frontend reference for the control's API.
Best Practices
- One dashboard per audience, not per data source. Groups are the seam for topical structure; a second dashboard is warranted when a different person looks at it.
- Keep groups small. A group is a Panel of a GridContainer — once it needs a scroll of its own, it wants to be two groups.
- Push work to the leaves. A dashboard resolves nothing itself; the data cost lives in each Tile/Card/Chart provider, where failure isolation and caching apply per child.
- Let children fail. Do not defensively try/catch inside a provider to return an empty shape — a recorded, omitted child is more honest than a rendered lie.
- Reach for a vetoer, not a subclass, whenever the question is "should this be visible for this actor".
Related
- Ui5Tile, Ui5Card, Ui5Chart — the children
- Slots — the resolution chain
- Ui5Module — where artifacts are registered
sap.m.VBox·sap.m.Panel·sap.f.GridContainer