Skip to content

Ui5Report

Introduction

A Ui5Report is a server-rendered HTML document, parameterised by slots and displayed by the host inside the <lux:Report> control.

That sentence is the whole artifact. There is no selection screen to build, no column metadata to declare, no export pipeline to configure. A report is a Blade template that receives an array and emits HTML — it owns its own layout, its own CSS, its own print rules, and any SVG it wants to draw.

GET /ui5/report/{namespace}@{version}?date_from=…&currency=…   →   text/html

📝 This is the minimal Reporting API — a settled contract. It replaced the earlier Report infrastructure wholesale — the selection View/Controller pair, #[ReportParam], the export formats and the follow-up actions are all gone, not deprecated. Host-driven selection now feeds slots, and rendering the HTML to PDF is a host concern — the SDK's export covers tables (CSV, and XLSX when you bind a writer), not report documents.

Why it is only HTML

Reports were the one artifact type that kept accreting responsibilities — selection UI, parameter binding, column metadata, CSV/XLSX/PDF emitters, bulk actions. Each of those turned out to belong somewhere that already existed:

The old jobWhere it lives now
Selection screenthe host app — it owns a filter bar and binds it to <lux:Report parameters=…>
Parameter bindingthe slot pipeline — Request → (Actor) → Composition → Setting → Default
Column metadata, filtering, pagingan OData entity set — reports are documents, not tables
Exportthe SDK, which wraps the same HTML
Follow-up actionsa Ui5Action, callable from anywhere

What was left is a document. So Core ships a document, and nothing else.

Conceptual overview

A report has three moving parts, and you write two of them.

  ReportArtifact  ──getRequiredSlots()──▶  slot pipeline
        │                                       │
        │ getProvider()                         │ [name => value]
        ▼                                       ▼
   Provider::provide(array $slots): array  ──▶  report.blade.php  ──▶  HTML
  1. The report class declares identity, which slots it needs, and which provider feeds it.
  2. The provider turns the resolved slot values into view data.
  3. The Blade document renders that data as a complete HTML page.

ReportController joins them: it resolves the required slots through the Parameter API chain, calls the provider, renders the Blade file, and returns the HTML with Cache-Control: no-store (a report is per-actor and slot-parameterised, so it must never be cached by an intermediary).

📝 A report is a pure slot consumer. It implements SlottableInterface (getRequiredSlots()) but not SlotProposableInterface — a report is a leaf in the composition, never a node, so it consumes context and proposes none.

How to generate

bash
php artisan ui5:report Timesheet/Hours \
  --title="Booked Hours" \
  --description="Hours booked by employees this period"

This creates a report called Hours inside the existing Timesheet app module.

Options

OptionDefaultDescription
name (arg)(required)Format: {App}/{Report} — e.g. Timesheet/Hours
--titleHeadline of the report nameUI-facing report title
--descriptionReport generated via ui5:reportMetadata description

That is the whole signature. The generator locates the module by ui5/{App}/src/{App}Module.php, so there are no namespace options to pass — it reads the prefixes from the app it is landing in.

Output

Given Timesheet/Hours:

plaintext
ui5/
└── Timesheet/
    ├── src/
    │   └── Reports/
    │       ├── HoursReport.php          ← the artifact
    │       └── Provider/
    │           └── HoursProvider.php    ← the swappable behaviour half
    └── resources/
        └── ui5/
            └── reports/
                └── hours/
                    └── report.blade.php ← the document

The folder name under reports/ is the report name in snake_case — the same slug AbstractUi5Report::getReportView() resolves against, which is why a scaffolded report finds its own document with no configuration.

On success the command prints the registration directive: 💡 Register it in TimesheetModule::getReports().

The three parts

HoursReport.php — the artifact

php
namespace Acme\Timesheet\Reports;

use LaravelUi5\Core\Ui5\AbstractUi5Report;
use LaravelUi5\Core\Parameters\CoreSlots;
use Acme\Timesheet\Reports\Provider\HoursProvider;

class HoursReport extends AbstractUi5Report
{
    public const NAMESPACE   = 'com.acme.timesheet.reports.hours';
    public const VERSION     = '1.0.0';
    public const TITLE       = 'Booked Hours';
    public const DESCRIPTION = 'Hours booked by employees this period';

    public function getRequiredSlots(): array
    {
        return [CoreSlots::DateFrom, CoreSlots::DateTo];
    }

    public function getProvider(): string
    {
        return HoursProvider::class;
    }
}

Identity is four constants; the getters come from the HasArtifactIdentity trait. AbstractUi5Report supplies everything else:

MethodProvided by the base
getType()returns ArtifactType::Report
getModule()the module handed to the constructor
getRequiredSlots()defaults to [] — an unparameterised report is legal
getReportView()resolves <package-root>/resources/ui5/reports/<slug>/report.blade.php

getReportView() throws MissingReportViewException naming the resolved path if the file is not there — override it on the concrete class if your report keeps its document somewhere non-standard.

HoursProvider.php — the behaviour

php
namespace Acme\Timesheet\Reports\Provider;

use LaravelUi5\Core\Ui5\Capabilities\DataProviderInterface;
use LaravelUi5\Core\Ui5\Contracts\Ui5ContextInterface;

class HoursProvider implements DataProviderInterface
{
    public function __construct(private TimesheetRepository $timesheets) {}

    public function provide(array $slots, Ui5ContextInterface $context): array
    {
        return [
            'rows'  => $this->timesheets->between($slots['date_from'], $slots['date_to']),
            'from'  => $slots['date_from'],
            'to'    => $slots['date_to'],
        ];
    }
}

The controller invokes provide() through Container::call, passing the resolved slot bag explicitly under slots. That has one practical consequence worth knowing:

Service dependencies go in the constructor; per-request inputs go in the method signature. The container autowires the constructor fully, while method parameters beyond $slots resolve from the container too — so a repository belongs in __construct, and the per-request context belongs in provide().

Core's Ui5ContextInterface carries the resolved artifact() and the request locale() — nothing more, because Core is auth-blind. On an SDK host, providers that need the acting user or their organization method-inject the SDK's SdkContext in the same seat instead; that is the established idiom for scoping a report's data to the actor's org, and it puts the report's security seam in the provider where it belongs.

ExecutableInvoker is deliberately not used here. It has no seat for a scalar bag like $slots, and the Parameter API contract forbids extending it — so ReportController calls the same container mechanism the invoker wraps internally. Settings are unaffected: declare them on the report artifact, and a provider extending AbstractConfigurable receives them. What a report does not have is route parameters — its varying input arrives through Slots.

plannedReport providers are not injected yet; that is part of the same change.

report.blade.php — the document

A complete HTML page. The scaffold gives you this skeleton, with a generic title you replace:

blade
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <title>Report</title>
    <style>
        /* A Core report is just HTML — the document owns its own layout
           and print rules. Add SVG/D3 inline if the report needs charts. */
        @media print { body { margin: 0; } }
    </style>
</head>
<body>
    <h1>Report</h1>
    <table>
        @foreach ($rows as $row)
            <tr><td>{{ $row['label'] ?? '' }}</td></tr>
        @endforeach
    </table>
</body>
</html>

Whatever the provider returned arrives as view data. Because the document is loaded in an iframe rather than into the UI5 DOM, it is genuinely free: its CSS cannot leak into the shell, and the shell's cannot leak in.

Module integration

Reports are subordinate artifacts and must be registered explicitly. Pass the module instance — every artifact takes it:

php
public function getReports(): array
{
    return [
        new Reports\HoursReport($this),
    ];
}

Registering it does two things: the report becomes routable at /ui5/report/{namespace}@{version}, and it appears in the app's manifest under laravel.ui5/reports, keyed by namespace:

jsonc
"reports": {
  "com.acme.timesheet.reports.hours": {
    "url": "/ui5/report/com/acme/timesheet/reports/[email protected]",
    "params": ["date_from", "date_to"]
  }
}

The params array is exactly what getRequiredSlots() returned — that is how the client knows which query parameters this report will honour.

Displaying it

<lux:Report> resolves the URL from that manifest entry and drives an iframe:

xml
<lux:Report
    name="com.acme.timesheet.reports.hours"
    parameters="{selection>/}"
    reportLoaded=".onReportLoaded" />
PropertyPurpose
namethe report's namespace — looked up in laravel.ui5/reports
parametersa flat object of slot overrides, composed into the query string

parameters is where host-driven selection lands. Bind it to your own filter bar's model and the control rewrites the iframe src in place. A partial map is valid, and so is an empty one: any slot you omit falls through to the server-side chain and ends at its declared default. That is the design guarantee that replaced the old selection screen — a report always renders, from the instant its URL resolves, with no blocking "nothing selected yet" state.

The reportLoaded event carries the requested url and the frame's resolved finalUrl. When they differ, the frame followed a redirect — usually an expired session — and the consumer decides what that means.

Best practices

  • Let slots do the parameterising. If a report needs a new input, declare a slot for it rather than inventing a query parameter — the whole chain, defaults included, comes free.
  • Give every slot a default that renders something useful. The report will be requested with no parameters at least once; make that first render meaningful.
  • Keep the provider thin. It resolves data and shapes it; the document decides how it looks.
  • Style inside the document. The iframe is an isolation boundary — use it, and write real @media print rules while you are there.
  • Reach for OData instead when what you actually want is a sortable, filterable, pageable table. A report is a document you read or print, not a grid you interrogate.