Skip to content

Table Export ​

Every list a user works with eventually has to leave the screen — into a spreadsheet, into a mail, into someone else's process. The SDK gives an app a downloadable view of its own data with one interface on the app, one class describing the rows, and one line in the controller.

Why the server does it ​

The obvious implementation is client-side: read the table's rows, build a CSV, save it. It is wrong here for one reason — the list is paged. An export must be complete over the active filter, not over the page the user happens to be looking at, so a client-side exporter would have to walk every page and re-implement what the backend already does.

Paging is why export belongs on the backend.

The flow ​

LaravelUi5.exportTable({ filter, format })         the app's own filter vocabulary
        │
        ▼
POST /ui5/export/{app-namespace}                   resolved to the app; #[Access] enforced
        │
        │  app->exportable($request)     →  an ExportableInterface scoped to that filter
        │  count() vs ui5.export.max_rows →  422 if too wide
        │  writer for the format          →  streams the file
        ▼
res.blob() → a synthetic <a download> click        no new tab, no empty tab

The three contracts ​

The app declares that it exports. It owns its filter vocabulary — the controller knows nothing about roles, validity or types:

php
class PartnersApp extends AbstractUi5App implements ProvidesExportInterface
{
    public function exportable(Request $request): ExportableInterface
    {
        return new PartnersExportable(
            roleCode: $request->input('roleCode') ?: null,
            validity: (string) $request->input('validity', 'current'),
            // …the same options the app's OData list reads
        );
    }
}

The dataset describes itself — in the SDK's own terms, so no spreadsheet type ever reaches a signature:

php
interface ExportableInterface
{
    public function filename(): string;   // no extension — the writer appends it
    public function headings(): array;    // list<string>, the first row
    public function count(): int;         // cheap COUNT — the size guard reads this
    public function rows(): iterable;     // a generator; values aligned to headings()
}

rows() is an iterable by design: return a generator over a DB cursor and a large export never materialises server-side. count() must agree with what rows() yields.

The writer encodes one format:

php
interface ExportWriterInterface
{
    public function stream(ExportableInterface $exportable): StreamedResponse;
}

The writer is the only place a spreadsheet library is allowed to appear.

Wiring it up ​

The endpoint is a plain SDK route, POST /ui5/export/{slug}, where the slug is the app's namespace. It reaches the right app through ExportArtifactResolver, which must sit in your resolver chain:

php
// config/ui5.php
'artifact_resolvers' => [
    \LaravelUi5\Core\Runtime\PathBasedArtifactResolver::class,
    \LaravelUi5\Sdk\Platform\Context\ShellContextArtifactResolver::class,
    \LaravelUi5\Sdk\Export\ExportArtifactResolver::class,
],

That resolver is what makes the gate work. Because the request resolves to the app artifact, the ordinary UI5 middleware stack runs first and CheckAuthMiddleware enforces the app's own #[Access] before the controller is reached. There is deliberately no second ability for exporting: the gate that opens the console is the gate. A controller check behind that middleware would be dead code.

From the client:

ts
const filter = composeExportFilter(this.filterModel().getData());
void LaravelUi5.exportTable({ filter, format: "csv" })
    .catch((e: unknown) => MessageBox.error((e as Error)?.message));

exportTable carries the filter as the POST body with the same XSRF and credential setup as LaravelUi5.call, reads the response as a Blob, and saves it through a synthetic anchor. The filter is passed explicitly rather than lifted from the binding — a v4 binding's custom query options do not introspect reliably, and your list controller already holds the state.

The size guard ​

Before a single row is streamed, the controller compares count() against config('ui5.export.max_rows') — 500 by default — and refuses a wider export with a 422:

Only filtered exports are supported for table exports — narrow the list and try again.

The response body arrives as a Blob in the browser's memory, so the limit is real. It is also the right behaviour for a table export: it forces a filter, which is what someone exporting a list usually wanted anyway. Raise it in your own config/ui5.php if your data and your users disagree.

The other refusals are equally deterministic, and all of them are clean JSON rather than a 500: an app that does not implement ProvidesExportInterface is a 404, an unknown format token is a 422, and a known format with no bound writer is a 422 naming the format.

CSV out of the box ​

The SDK binds CsvExportWriter for csv — plain fputcsv to php://output, zero dependencies, flat memory over the row generator. Three details in it exist because of what people actually do with the file:

  • A UTF-8 BOM, so Excel reads accented characters correctly instead of mangling them.
  • A ; delimiter by default — German Excel's list separator. Pass , to the constructor for a ,-locale host.
  • A formula-injection guard. A cell starting with = + - @ or a control character is prefixed with ', so a value like =CMD() cannot execute when the file is opened.

The filename you return is reduced to an ASCII slug before it reaches Content-Disposition, so a quote or a newline in it can never break the header.

Adding xlsx ​

.xlsx is a contribution, not an SDK feature. The controller picks the writer from a map, so you add a format by adding an entry — no SDK release, and your spreadsheet library is required by yourcomposer.json:

php
// config/ui5.php
'export' => [
    'max_rows' => 500,
    'writers'  => [
        'csv'  => \LaravelUi5\Sdk\Export\CsvExportWriter::class,
        'xlsx' => \App\Export\OpenSpoutXlsxWriter::class,
    ],
],

Then format: "xlsx" from the client. A format with no entry is the clean 422 above, never a 500.

Curate the columns

The most common mistake is to export the wire projection. The first version of the Partners export did, and it read as noise — an internal uppercase matchcode column, a redundant name field, a surrogate id. An Exportable exports human columns: the shipped one is Name / Type / Email, with the type integer resolved to a label. Values arrive at the writer presentation-ready; the writer only encodes, it never formats.

A worked example ​

PartnersExportable is worth reading once, because it solves the problem every export has: the file must match the screen. It does that by rebuilding the query through the same seams the OData list uses — the structural-role and validity predicate comes from the entity set's own filter method, and the native type and live-search filters are layered on identically. The export cannot drift from the list, because there is only one definition of the filter.

Two mechanical details from it are worth copying:

  • count() and rows() each build a fresh query. Sharing a builder between a count and a cursor is a bug waiting for a large dataset.
  • rows() yields from a cursor(), and the ordering is deterministic (name, then id) so a file exported twice is the same file.