QueuedBulkAction processes large selections through Laravel’s queue. The HTTP request validates input and creates a durable operation; it does not hydrate the selection. Callbacks receive one Eloquent record at a time. This API is separate from the synchronous BulkAction collection callback.
The worker, headless monitoring API, prebuilt Svelte progress panel, cancellation, persistent completion notifications, and recovery commands are implemented. Queued CSV/XLSX exports use the same progress system. Queued CSV imports use the same application-owned upload/mapping handler and provide private failure reports.
Rebuild the table in workers
Implement BackgroundTable in an application class. The same class builds the HTTP and worker table. Its context contains the freshly loaded user and explicitly selected server-owned JSON, such as a tenant ID:
use App\Models\Project;
use Illuminate\Support\Facades\Gate;
use Spindle\Table\Actions\QueuedBulkAction;
use Spindle\Table\Background\Context;
use Spindle\Table\Columns\TextColumn;
use Spindle\Table\Contracts\BackgroundTable;
use Spindle\Table\Table;
final class ProjectsTable implements BackgroundTable
{
public function make(Context $context): Table
{
$team = $context->user->teams()->findOrFail($context->data['team']);
Gate::forUser($context->user)->authorize('view', $team);
return Table::make('projects')
->query(Project::where('team_id', $team->id))
->background(self::class, $context->data, queue: 'tables', chunkSize: 100)
->columns([TextColumn::make('name')->searchable()])
->bulkActions([
QueuedBulkAction::make('archive')
->requiresConfirmation()
->authorize(fn ($record, $user) => $record === null
? $user->can('archiveAny', Project::class)
: $user->can('archive', $record))
->eachRecord(fn ($record) => $record->update(['status' => 'archived'])),
]);
}
}
In the controller, instantiate the definition through the container and pass new Context($request->user(), ['team' => $authorizedTeam->id]). Return $table->toProps() from the Inertia GET and $table->handle($request) from the normal authenticated POST route. Pass the same context on both routes. Do not copy context, definition classes, connection names, or tenant scope from submitted action data.
background() accepts an optional Laravel queue connection, queue name, and chunkSize from 1–500 records. The default is 100. It requires an Eloquent record table. Store the package’s run tables on the same database connection as its query model so record writes and progress commit together. For applications with multiple model connections, migrate each applicable database.
The user must be an authenticatable Eloquent model. The worker reloads it from its original connection and table; deleted users cannot continue. Use Context::$user, action authorization’s $user argument, request()->user(), or Laravel Gate policies. Workers have no browser session, route parameters, or request headers; do not depend on auth() session state. The package restores the worker’s request and Gate bindings after each slice.
No closures or model objects are serialized into queue messages. A message contains only an operation UUID, database connection, and progress revision. The encrypted database payload stores the definition class, context, validated state/selection/input, and user identity. Context and action input each allow up to 64 KB of JSON with a depth limit of 20. Files and arbitrary objects are not valid bulk input. Keep the application encryption key available to workers.

Selection and execution
The normal frontend bulk-action submission works unchanged, including application-owned input handlers. rules() validates input at submission and again in the worker, with null as the record argument. There is no action form/schema builder.
Explicit selections allow up to 10,000 IDs. “All matching” selections are not limited by max_bulk_records; exclusions are limited to 10,000 IDs. Preparation materializes target keys in pages of 500 before any mutation. Missing explicit IDs fail preparation without changing records. Each operation fixes an upper primary key at preparation start, so later increasing IDs cannot extend the job indefinitely. Preparation is not a database-wide point-in-time snapshot: concurrent changes below that upper key can affect membership until their page has been scanned. Once preparation completes, new records are not added.
Before each callback, the worker rechecks current table scopes, filters, selectability, and action authorization. Missing or newly inaccessible records are skipped. A validation or application exception rolls that record back, records a failure, and continues. Database query failures retry the entire uncommitted slice; after exhausted retries, the operation is failed. Earlier committed slices remain committed. A completed operation may therefore contain failed or skipped records: inspect the counters.
A row lock serializes slices of the same operation. Revision checks discard stale deliveries and failure callbacks after another worker has advanced the operation. Target status and model writes commit in the same database transaction, so redelivery does not repeat already committed records. Callbacks must write through that model’s connection for this guarantee. It does not provide exactly-once delivery to external services or another database. Use an application outbox or idempotency key for those effects. Actions that require all selected records to commit atomically should use the bounded synchronous bulk API.
Cancellation takes effect between slices and preserves completed changes. Permission revocation, invalidated state, or an unavailable definition stops the remaining work. The worker has three attempts with 10/30-second backoff and a 120-second timeout per slice. Reduce chunkSize for expensive callbacks.
Progress and notifications
The table payload’s runs array and optional <name>Runs prop contain up to 20 operations, prioritizing active jobs and unread notifications before recent history, scoped to this user, table, definition, and context. Completed exports may include download: {url, fileName, format, expiresAt} from the application download route. No storage paths, private payload, or raw exception is included. BackgroundRun is exported by the headless TypeScript core.
// Use your framework's native Inertia router. The GET controller returns toProps().
router.get(window.location.href, {}, {
only: ['projectsRuns'],
async: true,
preserveUrl: true,
preserveState: true,
preserveScroll: true,
onSuccess: page => { runs = page.props.projectsRuns; },
});
Each run includes id, kind, label, status, total, processed, succeeded, failed, skipped, message, unread, createdAt, and finishedAt. Status is queued, preparing, running, completed, failed, or cancelled. total grows during preparation; show indeterminate progress until running. Poll only while needed and while the page is visible. Optional progress requests do not load table records or disturb its scroll prop.
Headless controller and prebuilt panel
The controller exposes current progress through snapshot.runs, snapshot.runsLoading, and snapshot.runsError. Use these reactive fields rather than snapshot.table.runs, which is the initial data from the last table prop. Monitoring does not replace the table prop or clear the selected records.
const unsubscribe = controller.subscribe(snapshot => {
renderJobs(snapshot.runs, snapshot.runsLoading, snapshot.runsError);
});
await controller.refreshRuns();
await controller.cancelRun(run.id);
await controller.readRun(run.id);
refreshRuns() requests only the optional progress prop, preserves the URL and scroll, and rejects missing/invalid responses with an Error. Replaced, interrupted, or disposed requests reject with an AbortError; stale responses cannot overwrite progress. It skips polling during table navigation, mutations, and debounced search. cancelRunsRefresh() stops only the outstanding progress request. cancelRun() and readRun() return the same ActionResult union as other mutations and preserve current selection and loaded rows. Dispose the controller and unsubscribe when unmounting.
The prebuilt <Table> automatically shows BackgroundRuns for a table configured with background(). It shows active operations and unread completion notifications, with History for acknowledged jobs. Dismiss persists acknowledgment; reloading the page does not restore dismissed notifications. Partial failures are labeled Completed with issues and show success/failure/skip counts. Show updated records reloads table data explicitly, so a background completion does not unexpectedly reset an accumulated infinite-scroll window.
The panel polls active operations every two seconds while visible and idle. It backs off up to 30 seconds after errors, and supports manual refresh. Native progress bars are indeterminate during preparation; completion uses a polite screen-reader announcement. All text uses the existing message overrides. You can compose the exported BackgroundRuns yourself with {controller} and snapshot={$controller}; its interval prop sets the polling interval, with a one-second minimum. Custom headless renderers decide when and how to poll.
POST {operation: 'run.cancel', id} to the page (see the protocol) to stop an owned run. POST {operation: 'run.read', id} to acknowledge its notification. Use a partial reload of <name>Runs and errors for these requests. Terminal runs remain unread until acknowledged; this survives reloads and navigation. The server rejects another user’s or another context’s operation ID.
Background\RunFinished is emitted after the terminal transaction commits. Applications may listen and send their own Laravel notifications. The stored terminal/unread state is durable; the event itself is not an outbox and should not be used as an exactly-once external delivery guarantee. No email or broadcast transport is configured automatically.
Setup, recovery, and retention
Publish the package migrations and migrate before enabling background():
php artisan vendor:publish --tag=spindle-table-migrations
php artisan migrate
php artisan queue:work --queue=tables,default
Use a durable asynchronous queue such as database, Redis, SQS, or Beanstalkd. Sync, null, deferred, and background-process drivers are rejected. Configure the queue’s retry/visibility timeout above the job’s 120-second timeout (or above the export finalization timeout, 600 seconds by default); Laravel explains the interaction in its queue worker documentation. The application also needs the queue driver’s own storage/migrations.
Schedule recovery and retention commands in the application:
Schedule::command('spindle-table:resume-runs --after=300')->everyMinute();
Schedule::command('spindle-table:prune-runs --days=7')->daily();
Recovery redispatches nonterminal operations whose progress has not changed for at least the requested interval (minimum 120 seconds). It covers a worker crash after committing a slice but before enqueueing the next one, or an unavailable queue when dispatching. Duplicate delivery remains safe for committed database work. Pruning deletes terminal operations, their private export files, and their target records, including encrypted input, after the retention period. It also removes expired completed-export files while retaining their notifications until run retention elapses. Active operations are never pruned. Both commands accept --database=<connection>; schedule them for every database that stores runs.
Tests exercise a real serialized Laravel database-queue job, multiple preparation pages, duplicate delivery, scoped progress props, revocation, per-record rollback, recovery, and retention on SQLite. Distributed worker contention and other database/queue drivers still require deployment-specific verification.