Skip to content

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:

TypeArtifactTypeRoutableRole
DashboardDashboard = 7Yesdashboard/{ns}@{ver}/manifest.jsonthe root container; holds Groups
Dashboard GroupDashboardGroup = 13Nothe 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() and getGridContainer() 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 set items or content yourself — 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:

bash
php artisan ui5:dashboard Sales/Overview
php artisan ui5:group      Sales/ThisMonth
CommandArgumentCreates
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.php

No 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:

php
class Overview extends AbstractUi5Dashboard
{
    public const string NAMESPACE   = 'com.acme.sales.dashboards.overview';
    public const string VERSION     = '1.0.0';
    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():

php
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

php
class PerformanceGroup extends AbstractUi5DashboardGroup
{
    public const string NAMESPACE   = 'com.acme.sales.groups.performance';
    public const string VERSION     = '1.0.0';
    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 with headerText bound to the group's title and a bare sap.f.GridContainer. Override either to customise:

php
public function getPanel(): Panel
{
    return new Panel(
        headerText:       $this->getTitle(),
        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:

php
class SalesModule extends AbstractUi5Module
{
    public function getDashboards(): array
    {
        return [new Dashboards\Overview($this)];
    }

    public function getCharts(): array
    {
        return [
            new Charts\QuotaAttainmentChart($this),
            new Charts\RevenueMixChart($this),
        ];
    }

    public function getCards(): array
    {
        return [new Cards\AccountSnapshotCard($this)];
    }
}

Every child must also be registered on its module

A namespace listed in getChildNamespaces() is resolved against the registry. If the artifact was never registered via getCards() / getTiles() / getCharts(), 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.

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:

php
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.

php
public function getRequiredSlots(): array
{
    return [CoreSlots::Period, SalesSlots::Region];
}

public function getSlotProposals(): array
{
    return [CoreSlots::Period->value => 'current-quarter'];
}

Core ships seven canonical slots on CoreSlots (currency, 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:

php
public function dispose(
    Ui5ArtifactInterface $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.

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) 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:

json
{
  "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 empty errors array means the child was vetoed, not that it failed.
  • params — the dashboard's own getRequiredSlots(), 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 typetype 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".