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 tabThe three contracts
The app declares that it exports. It owns its filter vocabulary — the controller knows nothing about roles, validity or types:
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:
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:
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:
// 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:
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:
// 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()androws()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 acursor(), and the ordering is deterministic (name, thenid) so a file exported twice is the same file.