ExportAction exports the current filtered table or a bulk selection through the background job system. It preserves visible column order, sorting and grouping, and uses raw column state. Downloads appear in the Svelte background panel; headless clients read snapshot.runs[].download. Your application owns any format picker or other action UI.
Define the action and download route
Use the same BackgroundTable definition for the page, action endpoint, queue worker, and download endpoint. Rebuild tenant scope from the current authorized user and trusted context in make(), as described in the background guide.
use Spindle\Table\Actions\ExportAction;
// Inside your BackgroundTable::make(Context $context):
$export = fn () => ExportAction::make('export')
->label('Export projects')
->formats(['csv', 'xlsx']) // First format is the default; CSV otherwise.
->fileName('projects') // Extension is added automatically.
->authorize(fn ($record, $user) => $record === null
? $user->can('export', Project::class)
: $user->can('view', $record));
return Table::make('projects')
->query(Project::query()->where('tenant_id', $context->data['tenant']))
->background(self::class, $context->data, connection: 'database', queue: 'tables')
->columns(/* your columns */)
->toolbarActions([$export()])
->bulkActions([$export()])
->exportDownloadUrl(fn (string $id) => route('projects.exports.download', $id));
Toolbar exports include matching records; bulk exports also respect selected IDs, select-all exclusions, and record selectability. Explicit selections containing unavailable records fail before processing. Server-hidden and user-hidden columns are excluded. A table needs at least one visible column. Each record is authorized again while processing; denied or missing records are skipped and included in the completion counts.
Register an authenticated, ordinary GET route. Obtain the tenant from your application’s authorized tenant context, never directly from an unchecked request parameter:
Route::get('/projects/exports/{id}', function (Request $request, string $id) {
$table = app(ProjectsTable::class)->make(new Context(
$request->user(), ['tenant' => $request->user()->currentTenant->id],
));
return $table->downloadExport($request, $id);
})->middleware('auth')->name('projects.exports.download');
This route streams a private attachment with Cache-Control: private, no-store. It checks ownership, definition/context scope, current column availability, general action authorization, and the current scope and authorization of every exported record before sending bytes. A record deleted, moved outside scope, or revoked after export causes the entire download to return 403. An expired or removed artifact returns 410. Preserve application tenant middleware and authorization on this route. Export files contain the values captured during processing, so re-export to obtain newer values.
The default button immediately queues CSV. For a frontend format picker, add ->requiresInput()->handler('export') and implement that application handler:
// Formats are declared in invocation.action.meta.formats.
await invocation.submit({ format: 'xlsx' });
Only server-declared csv and xlsx formats are accepted. formats(['xlsx']) gives a one-click XLSX action. fileName() accepts a plain name without a directory. Additional rules() remain ordinary Laravel validation rules; no form schema is generated. Record actions are not supported by this helper.
Storage, workers, and retention
Publish/run the package migrations, including 2026_01_03_000000_add_export_data_to_spindle_table_run_records.php. Configure a private Laravel filesystem disk accessible to both web and queue workers:
// config/spindle-table.php
'export_disk' => 'local',
'export_retention_hours' => 24,
'export_timeout' => 600,
A local disk must point outside the public web root. In a multi-host deployment, use shared private storage. The package rejects a disk named public or explicitly configured with public visibility and writes with private visibility. The application must also prevent public access through disk roots, bucket policies, CDN rules, or other routes. Never expose the export directory through a public storage link.
Downloads expire 24 hours after successful file creation by default. export_retention_hours is bounded to 1–8,760 hours. Run spindle-table:prune-runs on a schedule: it removes expired completed-export files while retaining their notifications until --days retention elapses, and removes files and encrypted staged rows when terminal runs are pruned. The command leaves failed deletions available for retry. Cancellation leaves staged encrypted rows until pruning and never makes an unfinished file downloadable.
Row batches use the normal 120-second timeout. Final file assembly uses export_timeout (default 600, bounded to 120–3,600 seconds). Set the queue connection’s retry/visibility timeout above the larger timeout; for example, 660 seconds for the defaults. Use a recovery interval longer than normal file assembly, such as spindle-table:resume-runs --after=900, to avoid unnecessary duplicate dispatches. The per-run database lock and revision still protect against duplicate processing. Final assembly cannot be cancelled mid-write; cancellation takes effect at job boundaries.
Data handling and performance
Preparation materializes record IDs in the table’s current order, 500 at a time, bounded by the initial highest primary key. Ordered preparation uses offset batches because table sorts may contain arbitrary supported expressions. It is not a point-in-time database snapshot: edits or inserts during preparation can move records between batches. Duplicate IDs are ignored. Use an application snapshot or immutable source when exact historical membership/order is required.
Processing reads up to background(..., chunkSize: 100) records with their declared eager-loaded relationships per batch. Values and progress commit together; staged row JSON is encrypted using Laravel’s application key. Each staged row is limited to 64 KB of JSON. XLSX strings are limited to Excel’s 32,767-character cell limit. Rows exceeding a bound fail visibly rather than being silently truncated. Unsupported/non-scalar column state is JSON-encoded. Current table filters are checked again when a row is processed.
Final assembly streams staged rows into a private temporary file and then uploads it to the configured disk. Memory use is bounded by batches; database staging and disk storage grow with the export. OpenSpout writes XLSX files without constructing the workbook in memory. It requires PHP’s DOM, XMLReader, ZIP, Fileinfo, Filter, and Libxml extensions, enforced by Composer. Numeric and boolean states remain typed in XLSX. String cells, including strings beginning with =, are always literal strings. CSV formula prefixes are escaped in both headings and values.
A retry after file creation overwrites the same private run-specific path; it does not append duplicate rows. The download is exposed only after completion commits. Temporary files use a private directory and are removed on normal success or exception; hard-killed workers rely on host temporary-directory cleanup. Final assembly may take longer than row batches for very large exports, so size timeout and temporary storage appropriately.
Tests cover CSV/XLSX contents, formula handling, ordering across preparation batches, filters, hidden columns, bulk selection, scoped authorization, expiration, pruning, duplicate deliveries, retry after file creation, cancellation, and browser downloads. Distributed queue/storage failure modes require deployment-specific verification. See queued CSV imports for asynchronous uploads and failure reports, or actions for synchronous imports.