Export
Export the entire filtered result — not just the current page — streamed to the client in bounded key-range windows. Memory stays flat regardless of size, provided the grid declares a key (see Grids without a key). Measured through the HTTP endpoint: 1.67M rows (6 columns) in ~52 s at constant memory.
Server
GridDefinition::export() returns a StreamedResponse:
use Elyra\DataGrid\Protocol\GridRequest;
Route::post('/api/sales/export', fn (Request $r) => salesGrid()->export(
GridRequest::fromArray(json_decode($r->input('request'), true) ?: []),
'csv', // 'csv' (default) | 'xlsx'
'sales', // filename (without extension)
));
The export honors the request's columns, filter and search, ignores paging (it streams everything that matches), and orders by the key column for a stable, single-pass scan.
Formats
| Format | Notes |
|---|---|
csv (default) |
UTF-8 with BOM + ; delimiter (opens cleanly in Excel). No dependencies. |
xlsx |
Rich styling (bold header band, right-aligned numeric columns with a #,##0.00 number format, sensible column widths). Requires openspout/openspout; falls back to CSV if not installed. |
How it streams
By default the exporter uses keyset pagination (WHERE key > ? ORDER BY key ASC LIMIT n, 5000 rows per batch): it works for numeric and string/UUID keys,
skips gaps in the key space (no empty queries), and keeps memory bounded to one
batch — the standard, index-friendly approach for MySQL, ClickHouse and SQLite /
SQL Anywhere.
ElyraSQL is the exception (exportsByKeyWindow()): it can't stream an
unbuffered cursor, a huge buffered transfer fails, and open-ended key > ? seeks
were full scans, so for a numeric key it walks double-bounded key windows
(key >= a AND key < b) instead. The streamed response sets set_time_limit(0)
and works across SAPIs (built-in server, fpm, CLI).
Windows walk the key's value range, which only works while ids are reasonably dense. With Snowflake/ULID-style ids (~1e18) the range would need on the order of 1e12 windows, so past a bounded window count the exporter switches to keyset pagination instead — slower per query on ElyraSQL, but bounded in both memory and query count for any key distribution.
Add a compound (key)/(filterColumn, key) index for the fastest windows on
very large tables.
Grids without a key
A grid with no ->key(...) cannot be paginated: there is no stable order to page
by, so LIMIT/OFFSET could duplicate or skip rows. The only deterministic
option is a single read, which puts the whole result set in PHP memory — so
export() refuses unless you have set a row cap:
// Either give the grid a key so the export can be paginated…
GridDefinition::table('sales')->key('id')->columns([...]);
// …or accept a bounded export and cap it explicitly.
// config/datagrid.php → 'limits' => ['max_export_rows' => 100_000]
// or ELYRA_DATAGRID_MAX_EXPORT_ROWS=100000
With a cap set, the limit is applied in SQL (LIMIT n), so memory is bounded by
the cap rather than by the table.
Spreadsheet formula injection
CSV is written for Excel, which evaluates any cell whose text begins with =,
+, -, @, a tab or a carriage return — so a stored =WEBSERVICE(...) would
run on the machine of whoever opens the download. Such values are exported with a
leading apostrophe, which Excel reads as literal text and does not display.
Numbers are untouched, so numeric columns still import as numbers. XLSX needs no
equivalent: openspout writes inline strings, which are never evaluated.
composer require openspout/openspout # optional, for real .xlsx
Queued exports
A streamed export holds the HTTP response open for its whole duration. That runs
into infrastructure long before it runs into the database: 60 s is the default
idle timeout on an AWS ALB and on nginx's proxy_read_timeout, 100 s on
Cloudflare — and the 1.67M-row benchmark above takes ~52 s. A buffering proxy is
worse still: it collects the whole response before forwarding, which defeats the
streaming and moves the memory pressure into the proxy. Meanwhile each export
occupies one PHP-FPM worker from first byte to last.
Queue it instead: the toolbar shows progress and then a signed download link.
// A queued export rebuilds its grid by name, because authorize() holds a closure
// and cannot be serialized. Register it (AppServiceProvider::boot):
Grid::define('sales', fn () => salesGrid());
// Mount the status/download routes with YOUR middleware. Nothing is registered
// unless you ask — this package ships no routes by default.
Route::middleware(['web', 'auth'])->group(function () {
Grid::exportRoutes();
});
// Your own enqueue endpoint decides who may export, then hands off:
Route::post('/api/sales/export/queue', function (Request $r) {
$export = app(GridExportManager::class)->queue(
grid: 'sales',
request: GridRequest::fromArray(json_decode($r->input('request'), true) ?: []),
format: 'csv',
filename: 'sales',
userId: (string) $r->user()->id,
);
return [
'id' => $export->id,
'statusUrl' => URL::temporarySignedRoute(
'elyra-datagrid.exports.status',
now()->addHour(),
['id' => $export->id],
),
];
})->middleware(['web', 'auth']);
Then point the client at it — queueExportUrl in Vue/Svelte, or
public bool $queuedExport = true; on a Livewire grid.
Needs a queue worker. On the sync driver the job runs inline, which puts you
back in a long request.
What it does not solve
Queueing fixes transport, not the read. The exporter's bounds still do that,
and they matter more here: a queue worker usually has a laxer memory_limit
and no max_execution_time, so an unbounded read fails harder and less visibly
than it would in a web request. Keyless grids are still refused without a cap.
The file at rest
This is the part streaming never had. A queued export writes customer data to a disk and hands out a URL, so four things guard the download — a signed link is a bearer capability, and Laravel's signed URLs do not authenticate:
- the export exists and finished
- it has not expired (
export.link_ttl,export.retain_for) - the caller is the user who requested it
- the grid's export policy still allows it, re-evaluated at download time, not trusted from when the job was queued
And nothing else cleans up, so schedule the prune command:
Schedule::command('elyra-datagrid:prune-exports')->hourly();
| Setting | Default | Meaning |
|---|---|---|
export.disk |
local |
Laravel disk the files live on |
export.directory |
elyra-datagrid-exports |
Directory on that disk |
export.queue |
default |
Queue the job goes on |
export.link_ttl |
3600 | How long a signed link stays valid |
export.retain_for |
86400 | How long the file exists at all |
Progress
Rows written are always reported. Percent needs the row count up front, which
is a full scan — so it appears only when the request asked for total in meta.
This package does not run a COUNT behind your back.
Livewire
Enable the toolbar button; it calls export() directly:
class SalesGrid extends DataGrid
{
public bool $exportable = true;
public string $exportFormat = 'csv'; // or 'xlsx'
}
Vue / Svelte
Set exportUrl; the toolbar button submits the current request to it (a form
POST that triggers a browser download):
const options = { /* ... */ exportUrl: '/api/sales/export' };
grid.exportData(); // programmatic
The client posts a request field (JSON of the current GridRequest) plus the
CSRF token. Make sure the endpoint is reachable — for a plain POST route, add it
to the CSRF exceptions (see Inertia integration).
Why this beats client-side export
Client-only grids can only export the rows they have loaded. Because Elyra DataGrid streams from the database, you can export the full result set — millions of rows — with constant client and server memory.