Skip to main content
Actions in tables operate on rows. A row click can approve a single order. A bulk selection can deactivate 50 users. A combined bulk action can process all selected row IDs in one call. A “select all” can archive every match across every page. The action itself still stays focused on its input shape — either a single row or an ids array — and the table handles the selection plumbing.

How It Works

Actions are pure. They always accept the same parameters and operate on a single item. The table layer handles:
  • Mapping row model to action parameters via schema ObjectProperty auto-binding
  • Single-row execution (one action call)
  • Bulk execution — synchronous loop per row for asBulkAction(), single-call combined execution for asCombinedBulkAction(), background workflow for select-all

Registering Actions on a Table

Add actions to your table’s coreActions() method. Each action that should operate on the row model must declare an ObjectProperty in its schema() that matches the table’s row model type. Prefer configuring bulk behavior inside the action’s defaults() method so it stays attached when the action is later auto-wired through object actions or generated scaffolding. The table auto-binds the property:
app/Tables/OrderTable.php
public function coreActions(): array
{
    return [
        LinkCoreAction::make(__('View'))
            ->href(fn (Order $order) => route('orders.show', $order))
            ->icon('Eye'),

        ApproveOrderAction::make(),

        CancelOrderAction::make(),

        SendInvoiceAction::make(),

        ArchiveSelectedOrdersAction::make()
            ->asCombinedBulkAction(),
    ];
}
The table inspects each action’s schema() for an ObjectProperty whose model class matches the table row’s model. When found, that property is automatically bound to the row — no manual wiring needed.
app/Actions/Order/ApproveOrderAction.php
class ApproveOrderAction extends CoreAction
{
    protected function schema(): SchemaDefinition
    {
        return SchemaDefinition::make([
            ObjectProperty::make('order', __('Order'))
                ->model(Order::class)
                ->required(),
        ]);
    }

    protected function handle(Order $order): ActionResult
    {
        $order->approve();
        return $this->success(__('Order approved.'));
    }
}

asBulkAction()

Marks an action as available for bulk selection. Without this, the action only appears as a row action. Prefer calling asBulkAction() from the action’s defaults() method instead of from the table. That keeps the action self-describing and lets later table wiring pick up the bulk behavior automatically.
class CancelOrderAction extends CoreAction
{
    protected function defaults(): void
    {
        $this->label(__('Cancel'));
        $this->icon('X');
        $this->variant(CoreVariant::DESTRUCTIVE);
        $this->confirm(__('Cancel this order?'));
        $this->asBulkAction();
    }
}

class SendInvoiceAction extends CoreAction
{
    protected function defaults(): void
    {
        $this->label(__('Send invoice'));
        $this->icon('Mail');
        $this->asBulkAction(selectAll: false);
    }
}
Actions that generate documents or perform expensive operations should use asBulkAction(selectAll: false) to prevent accidental processing of thousands of records.

asCombinedBulkAction()

Marks an action as bulk-only, but executes it once with the selected row IDs as an ids array.
ArchiveSelectedOrdersAction::make()
    ->asCombinedBulkAction();
Use this when the action needs the full selection in one handler call. The action still receives ids when exactly one row is selected, so your handler can always read $this->collect('ids').
Combined bulk actions never support select-all execution. Hide them for select all selections and use asBulkAction(selectAll: false) when you need the per-row loop instead.

Bulk Execution

The platform uses two distinct strategies depending on how items were selected:
SelectionRouteBehavior
Explicit IDs + asBulkAction()POST /actions/{action}/bulkSynchronous loop — one action call per row
Explicit IDs + asCombinedBulkAction()POST /actions/{action}/executeSingle execute() call with ids array
Select all (*)POST /actions/{action}/bulk-tableBulkActionWorkflow dispatched in background

Synchronous Execution

When the user selects explicit rows and clicks a bulk action, each item is processed in a sequential loop. On completion the user receives a flash message:
  • All succeeded":count items processed successfully."
  • Partial failure":succeeded of :total items processed. :failed failed."
Each item gets its own authorization check. Failures are counted but do not stop the loop. Combined bulk actions use the regular execute flow, so the action runs once and reads the selected IDs from $this->collect('ids'). The bulk endpoint also supports the same ids payload when you need to call it directly outside the table UI.

Background Workflow

“Select all” dispatches a BulkActionWorkflow. The workflow:
  1. Runs a COUNT query against the table’s current filter state to determine the total
  2. Yields one ProcessBulkTableChunkActivity per 100 records
  3. Each activity reconstructs the table query, fetches its page via offset, and runs the action on each item
  4. Tracks progress via StoredWorkflow counters
This approach avoids loading all IDs into memory — only 100 IDs are fetched per activity job. The table state (filters, search, sort) is captured at dispatch time and replayed in each chunk. The frontend shows workflow progress on the bulk action button — the same mechanism as workflow integration.

Bulk Forms

When a bulk action requires additional input from the user — such as choosing a new status to apply to all selected orders — the table automatically presents a form dialog before executing. This uses the same schema() and form() mechanism as single-item actions. No separate bulk form is needed.

Schema-Driven Bulk Forms

Any required, visible schema property beyond the model ObjectProperty triggers a form dialog in bulk context. The user fills in the extra fields, and those values are sent alongside the selected item IDs.
app/Actions/Order/SetOrderStatusAction.php
class SetOrderStatusAction extends CoreAction
{
    protected function schema(): SchemaDefinition
    {
        return SchemaDefinition::make([
            ObjectProperty::make('order', __('Order'))
                ->model(Order::class)
                ->required(),

            // This extra required property triggers the bulk form dialog
            EnumProperty::make('status', __('Status'))
                ->enum(OrderStatus::class)
                ->required(),
        ]);
    }

    protected function handle(Order $order, OrderStatus $status): ActionResult
    {
        $order->update(['status' => $status]);
        return $this->success(__('Order status updated.'));
    }
}
When this action is used as a bulk action, clicking “Set status” in the table opens a form dialog with a status selector. After the user submits, the selected status is applied to every chosen order. The order property is hidden in the bulk form dialog (it’s injected server-side per item) — only the status field is shown. For combined bulk actions, the selected row IDs are still sent once through bulkContext, so your handler can always work from ids even when the user selected a single row.

Custom Bulk Forms

Override form() to provide a custom React component. The same component handles both single-item and bulk execution — CoreActionForm detects the bulk context automatically via the bulkContext prop it receives:
protected function form(): ?ActionForm
{
    return $this->component('orders/set-order-status-form');
}
resources/js/pages/orders/set-order-status-form.tsx
export default function SetOrderStatusForm({ action, bulkContext, onSuccess }) {
  return (
    <CoreActionForm
      action={action}
      bulkContext={bulkContext}
      onSuccess={onSuccess}
      footer={
        <CoreActionSubmitButton action={action}>
          {__('Apply')}
        </CoreActionSubmitButton>
      }
    >
      <PropertyCoreFormInput data={useActionSchema(action).status} />
    </CoreActionForm>
  );
}
When bulkContext is present, CoreActionForm routes the submission to the bulk endpoint, automatically excluding the model param (which is injected server-side per item). When it’s absent — such as when the action is used on a single row — submission proceeds normally.
The bulkContext prop is only passed when the form is opened from a bulk selection. When used as a row action modal, the prop is absent. Your component can handle both with a single implementation.

Preconditions in Tables

When an action defines preconditions(), the table evaluates them per row. Actions that return hidden() are removed from that row entirely. Actions that return check() with unmet conditions show as disabled with a popover:
class ApproveOrderAction extends CoreAction
{
    protected function preconditions(Order $order): PreconditionResult
    {
        if ($order->isCancelled()) {
            return PreconditionResult::hidden();
        }

        return PreconditionResult::check([
            __('Order is in draft status') => $order->status === OrderStatus::DRAFT,
            __('Order has line items') => $order->lines()->exists(),
        ]);
    }
}
In the table: a cancelled order has no “Approve” button at all. A draft order with lines shows an enabled button. A shipped order shows a disabled button with a popover listing the unmet conditions.
If your preconditions() method accesses relationships (like $order->lines()->exists() above), make sure the table’s resource() query eager-loads them. Without eager loading, every row triggers a separate query per relationship — the classic N+1 problem.
public function resource(): Builder
{
    return Order::query()->with(['customer', 'lines']);
}

Performance

Preconditions run per row per action, but the platform optimizes aggressively:
  • schema() and defaults() are cached once per action class — not recomputed per row
  • form() is never called in table context — custom form components load on-demand when the user clicks
  • Actions without preconditions() skip the check entirely (the platform detects this once per class)
The only per-row cost is the schema ObjectProperty binding + preconditions() + hidden()/disabled() closures.

Auto-Modal for Incomplete Parameters

When a row action has schema properties beyond what the table auto-binds from the row model, clicking the row action opens a modal form automatically:
class CancelOrderAction extends CoreAction
{
    protected function schema(): SchemaDefinition
    {
        return SchemaDefinition::make([
            ObjectProperty::make('order', __('Order'))  // auto-bound by table
                ->model(Order::class)
                ->required(),

            StringProperty::make('reason', __('Cancellation reason'))  // missing → triggers modal
                ->required(),
        ]);
    }
}
The user clicks “Cancel” on a row, a modal opens asking for the cancellation reason, and on submit the action runs with both the pre-resolved order and the user-provided reason. This same mechanism also powers bulk forms — see Bulk Forms above.

Using Object Actions in Tables

When your model declares objectActions(), you can pull actions directly:
app/Tables/OrderTable.php
public function coreActions(): array
{
    return [
        LinkCoreAction::make(__('View'))
            ->href(fn (Order $order) => route('orders.show', $order))
            ->icon('Eye'),

        ...Order::actions()
            ->only(['approve', 'cancel', 'send_invoice'])
            ->forTable(),
    ];
}
forTable() returns the actions ready for table use. The table maps the row’s model to the action’s ObjectProperty parameter automatically based on the model type.

Reference

Table Action Methods

MethodDescription
asBulkAction(bool $selectAll = true)Enable bulk selection. Pass false to disable “select all”
asCombinedBulkAction()Enable bulk selection with one execution and an ids array

Next Steps

Actions

Action fundamentals: schemas, return values, preconditions, and object actions.

Data Tables

Table configuration, columns, filters, and sorting.