QueuedImportAction uses the existing application-owned upload/mapping handler and the background job system. It validates the upload and header mapping during the HTTP request, stores the file privately, and returns the normal Inertia redirect. Workers parse the CSV and import rows in batches. Successful rows commit independently; failed rows appear in a downloadable CSV report.
Define the import
Declare the action in the header of a BackgroundTable definition. Use that same definition in the page, action, and report-download controllers. Rebuild and authorize tenant context as described in the background guide.
use Spindle\Table\Actions\QueuedImportAction;
// Inside BackgroundTable::make(Context $context):
return Table::make('projects')
->query(Project::query()->where('tenant_id', $context->data['tenant']))
->background(self::class, $context->data, connection: 'database', queue: 'tables', chunkSize: 100)
->columns(/* your table columns */)
->toolbarActions([
QueuedImportAction::make('import')
->label('Import projects')
->columns(['Project name' => 'name', 'Budget' => 'budget'])
->userMapping()
->rowRules([
'name' => ['required', 'string', 'max:100'],
'budget' => ['required', 'integer', 'min:0'],
])
->model(Project::class, ['tenant_id' => $context->data['tenant']]),
])
->importReportUrl(fn (string $id) => route('projects.imports.report', $id));
model() checks Laravel’s create policy and writes through the run’s database connection. Trusted attributes supplied to model() override imported values. Destinations must be explicitly declared and validated. Keep tenant identifiers and ownership in trusted attributes. For updates, relationships, casts, row-specific policies, or other application logic, use authorize() and using():
QueuedImportAction::make()
->columns(['External ID' => 'external_id', 'Project name' => 'name'])
->rowRules(['external_id' => ['required', 'string'], 'name' => ['required', 'string']])
->authorize(fn ($record, $user) => $user->can('import', Project::class))
->using(function (array $row, int $csvRow, array $options) use ($context) {
$project = Project::query()
->where('tenant_id', $context->data['tenant'])
->firstOrNew(['external_id' => $row['external_id']]);
Gate::authorize($project->exists ? 'update' : 'create', $project->exists ? $project : Project::class);
$project->fill([...$row, 'tenant_id' => $context->data['tenant']])->save();
});
Custom callbacks must write through the same database connection as the table. Each callback runs in a transaction/savepoint on that connection; another database or external side effect cannot be rolled back with it. Queue external side effects after commit, and use idempotency keys for external systems. The callback receives the mapped, validated row, its CSV record number, and validated extra action input as $options. CSV record numbering includes the header and ignores blank records; quoted multiline values count as one record.
To accept additional options, add ordinary upload/action rules(), keeping the file rules, then submit those values from your handler. Workers revalidate these options against the current rules. There is no PHP form builder.
Frontend upload and progress
The action uses the same import handler and meta.import mapping contract as synchronous imports. The ImportProjects.svelte example works for both:
await invocation.submit({
file,
mapping: { name: 'Title', budget: 'Cost' },
});
A successful submission means the file was queued. The default Svelte table shows progress, completion counts, cancellation, and the report link. It preserves the current rows while polling; “Show updated records” reloads the table after successful imports. Headless renderers use snapshot.runs, refreshRuns(), cancelRun(), and readRun().
File or header/mapping validation errors return to the open action handler. Data-row validation errors happen in the worker and appear in the failure report. Synchronous ImportAction still rolls back the whole import when a row fails; QueuedImportAction keeps successfully committed rows.
Private failure reports
Register an authenticated ordinary GET route using your authorized tenant context:
Route::get('/projects/imports/{id}/report', function (Request $request, string $id) {
$table = app(ProjectsTable::class)->make(new Context(
$request->user(), ['tenant' => $request->user()->currentTenant->id],
));
return $table->downloadImportReport($request, $id);
})->middleware('auth')->name('projects.imports.report');
The report contains only failed/skipped rows, declared destination columns, CSV row numbers, and validation messages. Unexpected exception details are logged on the server and replaced with a generic report message. Values and messages are escaped against spreadsheet formula prefixes. That protection can add an apostrophe to text beginning with =, +, -, @, or whitespace control characters; review such values before reimporting a report.
The endpoint checks the owner, table/definition/context scope, current action authorization, mapping-definition signature, and expiry before streaming. It returns a private, uncached CSV attachment. Links use the same optional BackgroundRun.download shape as exports; raw input and exception details never appear in progress props. Reports are available only once a run is terminal and has failed or skipped rows. Cancellation can therefore leave a report for rows already attempted; it does not include pending rows. A report expires after 24 hours by default. Re-importing the original file is a new operation; use application upsert logic when repeated uploads must not create duplicates.
Storage, limits, and recovery
Publish and run all package migrations, including the run-record data column migration dated 2026_01_03. Configure:
// config/spindle-table.php
'import_disk' => 'local',
'import_report_retention_hours' => 24,
Use a private disk outside the public web root, shared between web and queue workers when they run on different hosts. Public disk names/visibility are rejected and uploads use private visibility. Bucket/CDN policies and application routes must also keep the files private. Upload storage occurs before the database write transaction to avoid holding database locks during remote transfer. A hard process crash or an ambiguous database failure during initial upload can leave an unreferenced private source file; include old unreferenced upload files in deployment storage maintenance.
The default upload limit is 2 MB. You can replace the upload rules() and customize your frontend picker up to the queued-import hard cap of 20 MB. maxRows() defaults to 5,000 and supports 1–50,000 rows. Headers allow at most 100 unique UTF-8 names, 255 bytes each. Each mapped row must be valid JSON under 64 KB. These are explicit bounds, not truncation. The example frontend picker retains its own 2 MB limit until you change it.
Preparation reads the bounded source once and stages encrypted rows in batches before executing any importer callback. Structural errors (such as inconsistent column counts, row-limit overflow, or invalid text) roll back preparation and stop the run without importing records. Preparation uses the normal 120-second job timeout; choose upload/row limits that fit your database and queue environment. It does not provide a percentage while parsing.
After preparation, each job processes chunkSize rows. Progress and row mutations commit together, so redelivering a committed job does not repeat its rows. Database infrastructure errors retry the whole uncommitted slice; individual validation/callback failures roll back that row and allow other rows to proceed. A changed header mapping, delimiter, row limit, or required mapping invalidates a queued definition rather than reinterpreting the original upload. Current authorization and options are checked again in the worker.
The original source file is removed after preparation commits or on cancellation/failure. Cleanup failures are logged and retried when spindle-table:prune-runs removes the terminal run. Successful row input is discarded after commit; failed/skipped and unprocessed input remains encrypted until run pruning. Report expiry controls access independently of the default seven-day run retention. Keep the application encryption key available while retained runs exist.
Use the worker, recovery, cancellation, and retention setup in background jobs. Scope changes or authorization revocation stop remaining work. Cancellation takes effect between jobs and preserves prior successful rows. Tests cover a real browser upload/database-queue flow, mapping, multiline CSV, scoped model connections, rollback/retry behavior, duplicate delivery, malformed input, cancellation, authorization, report expiration, and pruning on SQLite.