spindle.by jsj
spindle/table

Actions

Frontend-owned action UI with server-side execution: row, bulk, toolbar and cell actions, CSV import and export.

Actions describe an operation, its authorization, and its input validation. There is no PHP form or schema builder. Your frontend owns dialogs, forms, wizards, uploads, read-only views, navigation, and any other interaction. Spindle supplies a typed action context and native Inertia submission.

Define an action

use Spindle\Table\Actions\Action;

Action::make('edit')
    ->label('Edit project')
    ->handler('projectEditor')
    ->data(fn ($record) => ['name' => $record->name])
    ->rules(['name' => ['required', 'string', 'max:100']])
    ->authorize(fn ($record, $user) => $user->can('update', $record))
    ->action(fn ($record, array $data) => $record->update($data));

Register actions with recordActions() (actions() is an alias), toolbarActions(), emptyStateActions(), or bulkActions(). Toolbar and empty-state actions receive null as the record. data() supplies only the JSON values you explicitly select; no model is serialized automatically. It accepts an array or fn ($record, $user) => array. The returned data is visible to the user whenever the action is included in the table response.

rules() accepts ordinary Laravel validation rules or fn ($record, $user) => rules, resolved after the scoped record and authorization checks. Only Laravel’s validated result reaches the callback. Use array:key1,key2 rules when accepting nested arrays so undeclared child keys cannot pass through a validated parent array. Inputs with no registered rules are discarded. Validation rules and callbacks are never sent to the browser.

requiresInput() marks an action as requiring a frontend handler. Actions with validation rules and read-only actions default to requiring one. Use requiresInput(false) for a button that can submit its declared data directly. Without a registered handler, input/read-only actions are disabled in the prebuilt table. handler('projectEditor') lets several server action names share one frontend implementation; otherwise handlers are selected by action name.

visible(Closure) adds presentation rules, and color() adds a styling hint. Custom actions deny server execution until authorize() is provided. A normal url(string|Closure) action renders a link. ActionGroup::make('manage')->actions([...]) creates a dropdown; individual actions can also use group('Manage').

Handle it in Svelte

<script lang="ts">
  import { Table } from '@spindle/table-svelte';
  import type { ActionInvocation, TablePayload } from '@spindle/table-core';
  import ProjectEditor from './ProjectEditor.svelte';

  let { projects }: { projects: TablePayload } = $props();
  let editing = $state<ActionInvocation | null>(null);
</script>

<Table table={projects} prop="projects"
  actions={{ projectEditor: invocation => { editing = invocation; } }} />

{#if editing}
  <ProjectEditor invocation={editing} onclose={() => editing = null} />
{/if}

ProjectEditor is your component. Use any UI kit, Inertia form helper, validation library, or plain HTML. For example, its save handler can call:

const result = await invocation.submit({ name });
if (result.status === 'success') onclose();
else if (result.status === 'validation') errors = result.errors;

Here is a complete plain-HTML example with a modal, editable state, loading state and validation errors. It is application code, not a package-generated form.

<script lang="ts">
  import type {ActionInvocation} from '@spindle/table-core';
  let {invocation,onclose,norwegian=false}:{invocation:ActionInvocation;onclose:()=>void;norwegian?:boolean}=$props();
  let dialog:HTMLDialogElement;let name=$state('');let busy=$state(false);let errors:Record<string,string>=$state({});
  $effect(()=>{name=String(invocation.data.name??'');});
  $effect(()=>{dialog?.showModal();});
  async function submit(){
    busy=true;errors={};const result=await invocation.submit({name});busy=false;
    if(result.status==='success')onclose();
    else if(result.status==='validation')errors=result.errors;
    else if(result.status==='failed')errors={form:'Could not save this project.'};
  }
</script>
<dialog bind:this={dialog} class="st-dialog m-auto w-[calc(100%-2rem)] max-w-md rounded-[1rem] border-0 bg-st-popover p-6 text-[13px] text-st-fg shadow-st-floating backdrop-blur-xl backdrop:bg-black/25 backdrop:backdrop-blur-[2px]" aria-label={invocation.action.label} onclose={onclose} oncancel={event=>{if(busy)event.preventDefault();}}>
  <form onsubmit={event=>{event.preventDefault();submit();}}>
    <h2 class="m-0 text-[15px] font-semibold">{invocation.action.label}</h2>
    <label class="st-field mt-3 flex flex-col gap-1.5 text-xs font-medium text-st-muted"><span>Project name</span><input class="h-8 rounded-st-control border-0 bg-st-subtle px-2.5 text-[13px] text-st-fg outline-none focus:ring-[3px] focus:ring-st-ring" required maxlength="100" bind:value={name} disabled={busy}/></label>
    {#each Object.values(errors) as error}<p role="alert" class="mt-3 text-st-danger">{error}</p>{/each}
    <footer class="mt-6 flex justify-end gap-2"><button type="button" class="h-8 rounded-st-control bg-st-subtle px-3.5 text-[13px] font-medium hover:bg-st-subtle-strong disabled:opacity-40" disabled={busy} onclick={()=>dialog.close()}>{norwegian?'Avbryt':'Cancel'}</button><button type="submit" class="st-primary h-8 rounded-st-control bg-st-accent px-3.5 text-[13px] font-medium text-st-accent-fg hover:brightness-110 disabled:opacity-40" disabled={busy}>{busy?'Saving…':invocation.action.label}</button></footer>
  </form>
</dialog>

Use onAction={invocation => ...} as a catch-all handler. Resolution order is actions[action.handler], actions[action.name], then onAction. A custom handler completely owns the interaction, including whether to show confirmation, submit, navigate, or do nothing. Merely receiving an invocation does not make a request. Async handler errors produce a localized error message in the prebuilt table.

URL actions can also be intercepted by a registered handler. Modified clicks (Cmd/Ctrl/Shift/Alt) keep normal browser link behavior. To navigate from your handler, use your application’s Inertia router or the action’s URL as appropriate.

Action context

The same API works in every framework through the headless controller:

const invocation = controller.createAction(action, row.id);
const result = await invocation.submit({ name: 'Updated' });

const bulk = controller.createAction(bulkAction, undefined, true);
await bulk.submit({ reason: 'Project completed' });
Member Meaning
action Name, label, confirmation, handler, URL, read-only/input flags, metadata
data A copy of the explicitly supplied initial JSON data
record Serialized row for a loaded record, or undefined for toolbar/bulk/unloaded records
bulk Whether this invocation is a bulk action
selection IDs or all-matching/exclusions captured when the action opened
table Current payload at invocation time
submit(data?) Native Inertia POST; defaults to the invocation’s initial data
cancel() Cancels this invocation’s pending submission

Selection and filter state are captured when an invocation opens, so a delayed custom dialog still targets the original selection. Laravel re-resolves that selection and its authorization at execution time. A read-only action cannot be submitted through the invocation helper; display its declared data in your own UI.

submit() resolves to one of these results: success, validation with an errors map, cancelled, busy, or failed with an Error. Invalid submissions leave UI decisions to your component. Busy submissions are not queued. Canceling stops the client visit; a server operation that has already begun may still finish. File values are supported through Inertia’s normal FormData conversion.

For lower-level use, controller.runAction(name, recordId, data?, onSuccess?), runToolbarAction(name, data?, onSuccess?) and runBulkAction(name, data?, onSuccess?) also return the result promise. These use the table’s state/selection at submission time. Existing success callbacks still work.

Without a custom handler, a simple action submits immediately, or opens a small confirmation dialog if requiresConfirmation() is set. This fallback collects no input.

Server execution

Callbacks run in a database transaction and usually return nothing; the handler redirects back with 303. Row/toolbar callbacks may return a Symfony/Laravel response instead, such as a redirect. Direct file downloads should use ordinary links, not an Inertia page response. External side effects should be dispatched after commit or through an application job.

Helper Policy ability / behavior
CreateAction::make()->model(Project::class, ['team_id' => $team])->rules([...]) create on the class; trusted attributes override input
ViewAction::make()->data(fn ($record) => [...])->handler('projectPreview') view; frontend-owned read-only interaction
EditAction::make()->rules([...])->data(fn ($record) => [...]) update; writes validated attributes
DeleteAction::make() delete; confirmation and Eloquent delete
RestoreAction::make() restore; Eloquent restore
ForceDeleteAction::make() forceDelete; confirmation and permanent deletion
ReplicateAction::make() replicate; confirmation, replicate and save
DeleteBulkAction::make() delete checked for every selected record

Register policies in your Laravel application. Soft-deleted actions need an authorized base query that resolves those rows, such as withTrashed(). Replication supports attribute exclusions and transaction-scoped hooks; see the replication example below. Application-specific unique constraints still need explicit values.

Replication and relationship copying

ReplicateAction::make()
    ->excludeAttributes(['slug', 'external_id'])
    ->handler('duplicateProject')
    ->data(fn ($record) => ['name' => $record->name.' (copy)'])
    ->rules(['name' => ['required', 'string', 'max:100']])
    ->beforeReplicaSaved(function ($replica, $original, array $input): void {
        $replica->slug = Str::uuid()->toString();
        $replica->tenant_id = $original->tenant_id;
    })
    ->afterReplicaSaved(function ($replica, $original, array $input): void {
        foreach ($original->tasks()->get() as $task) {
            Gate::authorize('replicate', $task);
            $replica->tasks()->save($task->replicate());
        }
    });

The frontend handler collects the new name and calls invocation.submit({ name }). The server creates an unsaved replica using Laravel’s default exclusions plus excludeAttributes(array|Closure), applies validated input, invokes beforeReplicaSaved(), saves, then invokes afterReplicaSaved(). The exclusion callback receives the original model and validated input. Exclusion prevents copying the original attribute; it does not prohibit a validated replacement or a trusted hook from setting it.

Only the explicitly selected data() is sent to the frontend. Copying retains the original model connection/table and clears inherited loaded relationship objects. Relationships are copied only by your hook, where you control child authorization, ownership, pivot attributes and unique keys. For many-to-many relationships, attach the authorized related IDs to the replica instead of duplicating shared records.

Both hooks run inside the table action’s transaction. Throw Laravel ValidationException to abort and return errors to the frontend, including after related writes; changes on that connection roll back. afterReplicaSaved() can return a redirect/response. Dispatch external side effects after commit. .action() still replaces the entire implementation when needed.

Bulk actions

BulkAction::make('archive')
    ->requiresConfirmation()
    ->authorize(fn ($record, $user) => $record === null
        ? $user->can('archiveAny', Project::class)
        : $user->can('archive', $record))
    ->action(fn ($records, $data) => $records->each->archive());

Authorization receives null for general availability, then each record before any writes. By default, the callback receives an Eloquent collection and validated data; see chunked and ID-only callbacks for other modes. Explicit IDs and “all matching except these IDs” retain query scope and filters. Unselectable rows cannot be changed via bulk requests.

The default maximum is 1,000 records. Larger selections are rejected before the callback. Set spindle-table.max_bulk_records conservatively, or use QueuedBulkAction for durable per-record background execution and progress.

CSV export

For asynchronous CSV/XLSX files, progress, notifications, and private authorized downloads, use queued exports. The following helper streams CSV directly in the HTTP response.

$table->exportable(fn ($record, $user) => $record === null
    ? $user->can('export', Project::class)
    : $user->can('view', $record));

// Separate GET route, protected by the same middleware and scoped table factory:
return $table->export($request);

Exports stream the filtered, ordered query in chunks of 500. Only declared visible columns are exported; values use raw column state. Spreadsheet formula prefixes are escaped. Every row is authorized before inclusion. Use a URL action or ordinary download link that preserves the table-state parameter.

CSV import

ImportAction::make()
    ->columns(['Project name' => 'name', 'Budget' => 'budget'])
    ->rowRules(['name' => ['required', 'string', 'max:100'], 'budget' => ['required', 'integer']])
    ->model(Project::class, ['team_id' => $teamId])
    ->maxRows(5000);

Register a frontend import handler that lets the user choose a file, then calls invocation.submit({ file }). The import helper has upload validation for a 2 MB file and separate rowRules() for each CSV row. Override the action’s ordinary rules() only when changing upload validation.

Imports use an explicit server-owned header mapping. Only declared, validated attributes reach the model. The row limit is 5,000 (configurable up to 50,000). Duplicate/missing headers and invalid rows fail and roll back database writes. using(fn (array $validatedRow, int $line) => ...) integrates your importer; provide authorize() when not using model(). Use userMapping() for user-selected source headers as described below. Queued imports provide per-row transactions, progress, and private failure reports; queued CSV/XLSX exports include progress and downloads. The frontend upload/mapping UI belongs to the application.

User-selected CSV mappings

ImportAction::make()
    ->columns(['Project name' => 'name', 'Budget' => 'budget'])
    ->userMapping()
    ->requiredMapping(['name']) // Defaults to every declared destination.
    ->csvDelimiter(',')        // Also supports semicolon or tab.
    ->rowRules(['name' => ['required', 'string'], 'budget' => ['sometimes', 'integer']])
    ->model(Project::class, ['team_id' => $teamId]);

The server owns the allowed destination attributes. A client mapping is destination attribute → uploaded CSV header. Unknown destinations, nonexistent source headers and missing required mappings fail validation. Null/empty mappings skip optional destinations, which still pass through rowRules(). Without a submitted mapping, the configured header mapping is used. Fixed imports reject attempts to override their mapping. Trusted attributes supplied to model() override imported input.

import { getImportConfig, parseCsvHeaders } from '@spindle/table-core';

const config = getImportConfig(invocation.action);
const headers = parseCsvHeaders(await file.text(), config?.delimiter ?? ',');
// Your frontend chooses headers for the declared destinations.
const result = await invocation.submit({
  file,
  mapping: { name: 'Title', budget: 'Cost in EUR' },
});

getImportConfig() reads action.meta.import: {userMapping, delimiter, columns: [{name, label, required}]}. This contract contains import destinations only; it defines no form fields or UI. The example below is an application-owned upload/mapping dialog with retries and row errors. Register it through the ordinary import action handler.

<script lang="ts">
  import {getImportConfig,parseCsvHeaders} from '@spindle/table-core';
  import type {ActionInvocation} from '@spindle/table-core';
  let {invocation,onclose}:{invocation:ActionInvocation;onclose:()=>void}=$props();
  const config=$derived(getImportConfig(invocation.action));
  let dialog:HTMLDialogElement;let file=$state<File|null>(null);let headers=$state<string[]>([]);
  let mapping=$state<Record<string,string>>({});let busy=$state(false);let reading=$state(false);let errors=$state<Record<string,string>>({});
  let fileGeneration=0;
  $effect(()=>{dialog?.showModal();});
  async function chooseFile(candidate:File|undefined){
    const generation=++fileGeneration;file=null;headers=[];mapping={};errors={};reading=false;
    if(!candidate)return;
    if(candidate.size>2*1024*1024){errors={file:'Choose a CSV file smaller than 2 MB.'};return;}
    reading=true;
    try{
      const text=await candidate.text();if(generation!==fileGeneration)return;
      headers=parseCsvHeaders(text,config?.delimiter??',');
      mapping=Object.fromEntries((config?.columns??[]).map(column=>[column.name,headers.find(header=>header.toLowerCase()===column.label.toLowerCase())??'']));
      file=candidate;
    }catch(error){if(generation===fileGeneration)errors={file:error instanceof Error?error.message:'Could not read this file.'};}
    finally{if(generation===fileGeneration)reading=false;}
  }
  async function submit(){
    if(!file||busy)return;busy=true;errors={};
    const result=await invocation.submit({file,mapping});busy=false;
    if(result.status==='success')onclose();
    else if(result.status==='validation')errors=result.errors;
    else if(result.status==='failed')errors={file:'Could not import this file.'};
  }
</script>
<dialog bind:this={dialog} class="st-dialog m-auto w-[calc(100%-2rem)] max-w-md rounded-[1rem] border-0 bg-st-popover p-6 text-[13px] text-st-fg shadow-st-floating backdrop-blur-xl backdrop:bg-black/25 backdrop:backdrop-blur-[2px]" aria-label="Import projects" onclose={onclose} oncancel={event=>{if(busy)event.preventDefault();}}>
  <form onsubmit={event=>{event.preventDefault();submit();}}>
    <h2 class="m-0 text-[15px] font-semibold">Import projects</h2>
    <p class="mt-1 text-st-muted">Choose a CSV file and match its columns to project fields.</p>
    <label class="st-field mt-3 flex flex-col gap-1.5 text-xs font-medium text-st-muted"><span>CSV file</span><input class="text-[13px] text-st-fg file:me-3 file:h-8 file:rounded-st-control file:border-0 file:bg-st-subtle file:px-3 file:text-[13px] file:font-medium" type="file" accept=".csv,text/csv" disabled={busy} onchange={event=>chooseFile(event.currentTarget.files?.[0])}/></label>
    {#if headers.length}{#each config?.columns??[] as column}
      <label class="st-field mt-3 flex flex-col gap-1.5 text-xs font-medium text-st-muted"><span>{column.label}</span><select class="h-8 rounded-st-control border-0 bg-st-subtle px-2.5 text-[13px] text-st-fg outline-none focus:ring-[3px] focus:ring-st-ring" required={column.required} disabled={busy} value={mapping[column.name]??''} onchange={event=>mapping={...mapping,[column.name]:event.currentTarget.value}}><option value="">{column.required?'Choose a column':'Skip this column'}</option>{#each headers as header}<option value={header}>{header}</option>{/each}</select></label>
    {/each}{/if}
    {#each Object.entries(errors) as [key,error]}<p role="alert" class="mt-3 text-st-danger">{key.startsWith('rows.')?`Row ${key.split('.')[1]}: `:''}{error}</p>{/each}
    <footer class="mt-6 flex justify-end gap-2"><button type="button" class="h-8 rounded-st-control bg-st-subtle px-3.5 text-[13px] font-medium hover:bg-st-subtle-strong disabled:opacity-40" disabled={busy} onclick={()=>dialog.close()}>Cancel</button><button type="submit" class="st-primary h-8 rounded-st-control bg-st-accent px-3.5 text-[13px] font-medium text-st-accent-fg hover:brightness-110 disabled:opacity-40" disabled={busy||reading||!file}>{busy?'Importing…':'Import'}</button></footer>
  </form>
</dialog>

parseCsvHeaders() parses the first record, including UTF-8 BOM, quoted delimiters, doubled quotes and embedded newlines. It does not parse/import data rows. It checks empty/duplicate headers, the 100-column limit and 255-byte header-name limit. CsvHeaderError.code is delimiter, empty, duplicate, limit or malformed; your UI can translate those codes. Header parsing examines at most 100 KB. Bound file size before calling file.text(); the example uses the server’s default 2 MB limit. PHP validates the uploaded bytes and mapping again.

Import row errors use keys such as rows.3.budget. Row numbers count CSV records including the header, ignoring blank records; quoted multiline values count as one record. Errors preserve the application’s action UI and roll back the entire synchronous import. If you override upload rules(), mapping validation remains enforced independently.

Security boundaries

  • Keep routes behind authentication, CSRF protection and normal rate limits.
  • Authorize table viewing and scope its base query to the tenant on every request.
  • Server callbacks are trusted application code; removing scopes inside them can bypass isolation.
  • Client handlers are presentation and interaction hooks, never authorization boundaries.
  • Declare only safe action data. Hidden table columns do not filter a separately configured action’s data.
  • Saved-view scope comes from server code, never a posted tenant field.
  • No PHP callbacks or model class names are deserialized from action requests.

Cell actions

Columns can trigger inline actions or reference named record actions through the same frontend handler API. See cell actions for examples, target validation, and link behavior. openUrlInNewTab() sets the target for URL actions.

Toolbar actions and record-action placement

Toolbar actions render beside the table’s search/filter controls. They have their own namespace, so a toolbar action can share a name with a row or bulk action without invoking the wrong callback:

use Spindle\Table\Actions\Action;
use Spindle\Table\Actions\BulkAction;
use Spindle\Table\Enums\RecordActionsPosition;

$table
    ->toolbarActions([
        Action::make('create')
            ->label('New project')
            ->authorize(fn ($record, $user) => $user->can('create', Project::class))
            ->handler('projectEditor')
            ->rules(['name' => ['required', 'string']])
            ->action(fn ($record, $data) => Project::create([...$data, 'team_id' => request()->user()->team_id])),
        BulkAction::make('review')
            ->authorize(fn ($record, $user) => $record === null
                ? $user->can('reviewAny', Project::class)
                : $user->can('review', $record))
            ->action(fn ($records) => $records->each(fn ($record) => $record->update(['status' => 'review']))),
    ])
    ->recordActions([
        // Your ordinary record actions.
    ], position: RecordActionsPosition::BeforeColumns);

BulkAction subclasses in the toolbar enable record selection and stay disabled until records are selected. They use the normal scoped bulk-selection validation and per-record authorization. Grouped toolbar actions use ActionGroup or group() as elsewhere. Header actions and the selection-bar bulkActions() configuration remain independent. Aggregate-only reports omit toolbar bulk actions.

QueuedBulkAction, QueuedImportAction, and ExportAction can be toolbar actions and retain their toolbar identity in the worker. A toolbar ExportAction exports the filtered table; use bulkActions([ExportAction::make(...)]) for a selected-record export. Background configuration, private download/report routes, and frontend-owned upload/format handlers work as documented in their respective guides.

The typed frontend action handler receives the ordinary ActionInvocation; toolbar bulk actions set bulk: true and capture the selection when opened. Headless clients can use controller.createAction(action) or controller.runToolbarAction(name, data). The wire action includes scope: 'toolbar' and a bulk boolean. A toolbar action cannot be invoked as a record/cell action or with the wrong single/bulk operation type.

Record-action positions accept the enum or matching strings:

Position Table placement Card/nested-layout placement
AfterColumns / after-columns (default) After the data columns Card footer
BeforeColumns / before-columns After selection/reorder controls, before data After the selection control, before content
BeforeCells / before-cells Before selection and all other cells Before the selection control

Use recordActionsPosition('before-cells') to change placement independently of the action definitions. Grouped headers, group rows, and summary rows retain their cell alignment. Placement is presentation only; it does not alter action authorization or handlers. Headless renderers read config.recordActionsPosition and choose their own layout.

Selection controls

Selection is enabled automatically when visible bulk actions exist. Use selectable() for an application-owned record picker or for a frontend action handler that needs the captured invocation.selection. selectable(false) hides selection controls and rejects selection-based requests on the server.

use Spindle\Table\Grouping\Group;

$table
    ->selectable()
    ->maxSelectableRecords(4)
    ->selectCurrentPageOnly()
    ->groups([Group::make('status')])
    ->defaultGroup('status')
    ->selectGroupsOnly();

These settings can be used independently:

  • maxSelectableRecords(4) caps explicit selection. Selected checkboxes remain available for deselection; other checkboxes disable at the limit. Selecting loaded records fills the remaining slots in display order. Pass null to remove the cap.
  • selectCurrentPageOnly() removes the all-matching shortcut. With native infinite scroll, the current page means the accumulated, loaded records. Loading another page preserves existing selection; selecting loaded records again includes the newly loaded rows. Replacing a page discards IDs that are no longer loaded.
  • selectGroupsOnly() restricts selection to one group while grouping is active. Group controls select or deselect that group’s loaded records, including collapsed groups. Clear the selection before selecting another group. When grouping is off, individual selection works normally, without an all-matching shortcut.

All three restrictions disable selection.all. Laravel rejects forged all-matching requests; maximum counts and mixed-group selections are validated for synchronous bulk actions, queued bulk actions, and selected-record exports. Relationship group validation eager-loads related values in a batch. Current-page mode governs the UI’s loaded records; submitted IDs still go through the normal filtered-query, tenant, and authorization checks, rather than trusting a client-supplied page boundary. Aggregate-only reports never enable record selection.

The headless controller exposes select(id, checked), selectPage(checked), selectGroup(groupKey, checked), selectAll(), and clearSelection(). Custom renderers can use the exported selectionEnabled(table), canSelectAll(table), and canSelectRow(snapshot, id) helpers for the same enabled/disabled behavior as the prebuilt table. selectGroup() takes the serialized group’s key, not its display label. Selection changes do not issue a network request.

Selected-record context for a row or cell action

Use accessSelectedRecords() when a single-record action also needs the selected records. Enable selection with selectable() if there are no bulk actions. The callback receives record, validated data, selected Eloquent collection, in that order:

use Illuminate\Database\Eloquent\Collection;
use Spindle\Table\Actions\Action;

$copy = Action::make('copyStatus')
    ->accessSelectedRecords()
    ->authorize(fn ($record) => auth()->user()->can('view', $record))
    ->authorizeSelectedRecordsUsing(
        fn ($selected, $record, $user) => $user->can('update', $selected)
    )
    ->action(function ($record, array $data, Collection $selectedRecords): void {
        $selectedRecords->each->update(['status' => $record->status]);
    });

$table->selectable()->recordActions([$copy]);
// The same Action can be assigned to a column with ->action($copy).

The frontend handler receives the usual ActionInvocation, including its captured selection and state. submit() sends that selection only when the descriptor opts in; changing the table after opening a dialog does not change its selected targets. The lower-level runAction() and runColumnAction() helpers send the current selection at call time. An empty explicit selection is valid and reaches the callback as an empty collection.

Laravel resolves selected records against the table’s filtered, scoped query, locks them in the same transaction as the action, enforces selection limits and record selectability, and authorizes every selected record before invoking the callback. By default, selected records use the action’s authorize() check. authorizeSelectedRecordsUsing() overrides that check for selected records only; the clicked record must still pass authorize(). A foreign, missing, or denied explicit target prevents the entire callback from running. All-matching selections honor exclusions and the table’s selection configuration.

Selected-record context uses the synchronous bulk-record bound (spindle-table.max_bulk_records, default 1,000). Use queued bulk actions for larger workloads. Actions that do not opt in keep their existing two-argument callback. Custom Action subclasses overriding run() can read the protected selectedRecordsContext; the table executes a cloned action for this call so context cannot leak into a later invocation.

Chunked and ID-only bulk callbacks

For synchronous bulk actions, chunkSelectedRecords(250) changes the callback’s first argument to an Illuminate\Support\LazyCollection of models. The callback runs once; records are delivered in primary-key order. Iterate the collection to load models in batches:

use Illuminate\Support\LazyCollection;
use Spindle\Table\Actions\BulkAction;

BulkAction::make('archive')
    ->authorize(fn ($record, $user) => $record === null
        ? $user->can('archiveAny', Project::class)
        : $user->can('archive', $record))
    ->chunkSelectedRecords(250)
    ->action(function (LazyCollection $records, array $data): void {
        foreach ($records as $record) {
            $record->update(['status' => 'archived']);
        }
    });

Before the callback, the table captures and locks the filtered selection’s keys and checks every record’s authorization and selectability in batches. It then loads the captured IDs for the callback, so changing a filter field while iterating does not skip later records. This trades an extra authorization pass and more queries for bounded model memory. Group-only selection is checked across batch boundaries; related group values are eager-loaded per batch. The query must return unique record keys.

All batches run in the same transaction. A denied record prevents the callback entirely; a later callback exception rolls back earlier database writes. Consume the lazy collection inside the callback: deferred iteration after it returns throws. Avoid collecting the entire lazy collection if you want to retain the model-memory benefit. Captured IDs remain in memory, bounded by spindle-table.max_bulk_records (default 1,000); chunking does not raise that limit or turn the action into a background job. Chunk sizes range from 1 to 1,000; pass null to restore an eager model collection. Use queued bulk actions for durable work that should outlive the request.

fetchSelectedRecords(false) instead passes an Illuminate\Support\Collection of string IDs. Model authorization still runs in batches before the callback by default:

use Illuminate\Support\Collection;

BulkAction::make('archive')
    ->authorize(fn ($record, $user) => $record === null
        ? $user->can('archiveAny', Project::class)
        : $user->can('archive', $record))
    ->fetchSelectedRecords(false)
    ->action(function (Collection $ids, array $data): void {
        Project::whereKey($ids->all())->update(['status' => 'archived']);
    });

This mode is for callbacks that consume IDs; model-oriented helper callbacks such as DeleteBulkAction’s default callback expect models. Bulk query updates do not fire individual Eloquent model update events. If both methods are configured, the callback still receives the ID collection and the chunk size controls model authorization batches.

Applications with an ID-based authorization service can explicitly replace the model authorization step:

BulkAction::make('archive')
    ->authorize(fn ($record, $user) => $user->can('archiveAny', Project::class))
    ->fetchSelectedRecords(false)
    ->authorizeEachSelectedIdUsing(
        fn (string $id, $user): bool => app(ProjectPermissions::class)->canArchiveId($user, $id)
    )
    ->action(fn (Collection $ids) => Project::whereKey($ids->all())->update(['status' => 'archived']));

The general authorize(null, user) check still runs. Every captured ID must pass the ID callback before execution, and the table always applies tenant/query/filter scope, exclusions, and selection limits. When no model-based selectability callback or active group-only restriction needs models, this path performs no model hydration. Those restrictions still load models in batches when necessary. authorizeEachSelectedIdUsing() applies only to ID-only synchronous callbacks; queued actions retain their per-record worker authorization.

Toolbar and selection-bar bulk actions support both modes. Frontend descriptors, captured selection, confirmation, Laravel input validation, and custom action handlers are unchanged.

Empty-state actions

Prompt users to create or import their first record with ordinary actions:

$table->emptyStateHeading('No projects yet')
    ->emptyStateDescription('Create a project to get started.')
    ->emptyStateIcon('◇')
    ->emptyStateActions([
        Action::make('create')->label('Create project')->handler('projectForm')
            ->authorize(fn ($record, $user) => $user->can('create', Project::class))
            ->rules(['name' => ['required', 'string', 'max:100']])
            ->action(fn ($record, $data) => Project::create($data)),
    ]);

Provide the projectForm frontend handler as described above; it owns the editor and calls invocation.submit(). Action groups, links, confirmation, validation, and named/global handlers work as usual. Synchronous and queued import actions can also appear here; queued actions retain their empty source when rebuilt by a worker. Bulk actions are rejected because this location has no record selection.

Empty-state actions are independent of toolbar/record actions, even when names match. The descriptor carries scope: 'empty'; headless UIs use controller.createAction(action).submit(data) with that descriptor. Do not use the header-oriented runAction(name) helper for this scope. Authorization is checked again on submission. Actions remain in the payload when rows exist, but the prebuilt UI only renders them for an empty loaded dataset. This display condition does not enforce a business rule; enforce any create/import restrictions in the action authorization and callback.

Use emptyStateIcon(null) to omit the glyph. The prebuilt renderer escapes the icon as text; icon libraries, illustrations, and full replacements belong in the Svelte empty-state snippet.