Record Actions
Record actions are the buttons at the end of a row: view, edit, delete, and whatever else a single record can be asked to do. They are declared on the table schema, resolved per record on the server, and executed through the panel's action endpoint — the button is never what authorizes the operation.
A minimal working example
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Tables;
use App\Panels\Admin\Resources\Posts\PostResource;
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\EditAction;
use PandaPanel\Actions\ViewAction;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class PostsTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table
->columns([
TextColumn::make('title')->searchable()->sortable(),
])
->recordActions([
ViewAction::make(PostResource::class),
EditAction::make(PostResource::class),
DeleteAction::make(PostResource::class),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Each row now carries the three buttons, minus any the policy refuses for that particular record.
Declaring them
use PandaPanel\Actions\Action;
use PandaPanel\Tables\Enums\RecordActionsPosition;
use PandaPanel\Tables\TableSchema;
TableSchema::recordActions(array $actions): self // array<array-key, Action>
TableSchema::recordActionsPosition(RecordActionsPosition $position): self
TableSchema::recordActionsLabel(string $label): self
TableSchema::frozenActions(bool $frozen = true): self
TableSchema::getRecordActions(): array // list<Action>
TableSchema::getRecordAction(string $name): ?Action2
3
4
5
6
7
8
9
10
recordActions() refuses two sets outright, at the line that declared them rather than at render time:
- Two actions with the same name — the endpoint resolves by name, so it would always run the first.
- An action that does nothing: no
url(), noaction(), nobulkAction(), notableAction(), noschema(), noform(), nomodal().PanelSchemaException::inertAction()says which one and what to add.
Built-in record actions
Each is a static factory returning a configured PandaPanel\Actions\Action, so anything below can still be chained onto it.
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\EditAction;
use PandaPanel\Actions\ForceDeleteAction;
use PandaPanel\Actions\ReplicateAction;
use PandaPanel\Actions\RestoreAction;
use PandaPanel\Actions\ViewAction;
ViewAction::make(string $resource): Action
EditAction::make(string $resource): Action
DeleteAction::make(string $resource): Action
RestoreAction::make(string $resource): Action
ForceDeleteAction::make(string $resource): Action
ReplicateAction::make(string $resource, array $except = [], ?Closure $using = null): Action2
3
4
5
6
7
8
9
10
11
12
13
| Factory | Name | Label | Icon | Variant | Type | Authorized by |
|---|---|---|---|---|---|---|
ViewAction | view | View | eye | ghost | link | canView($record) |
EditAction | edit | Edit | pencil | ghost | link | canEdit($record) |
DeleteAction | delete | Delete | trash-2 | destructive | callback | canDelete($record) |
RestoreAction | restore | Restore | rotate-ccw | outline | callback | canRestore($record) |
ForceDeleteAction | forceDelete | Delete permanently | trash-2 | destructive | callback | canForceDelete($record) |
ReplicateAction | replicate | Replicate | copy | outline | callback | canCreate() and canView($record) |
ViewAction and EditAction are also hidden when the resource declares no view or edit page, so a link to a route that does not exist is never drawn. RestoreAction and ForceDeleteAction are hidden for a record that is not trashed — a row shows either restore or delete, never both.
DeleteAction and ForceDeleteAction confirm by default. ReplicateAction confirms too, and takes the columns a copy must not carry over:
use App\Panels\Admin\Resources\Posts\PostResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use PandaPanel\Actions\ReplicateAction;
ReplicateAction::make(
PostResource::class,
except: ['slug', 'published_at'],
using: static function (Model $copy, Model $original): void {
$copy->forceFill([
'title' => $original->getAttribute('title').' (copy)',
'slug' => Str::uuid()->toString(),
]);
},
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
Eloquent's own replicate() already drops the key and the timestamps; except is for the columns this model must not duplicate — a unique slug, an invoice number, an API token.
Writing your own
use App\Models\Order;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
Action::make('approve')
->label('Approve')
->icon('check')
->variant(ActionVariant::Outline)
->requiresConfirmation(
heading: 'Approve this order?',
description: 'The customer is notified immediately.',
button: 'Approve',
)
->successMessage('Order approved.')
->visible(static fn (?Model $record): bool => $record?->getAttribute('status') === 'pending')
->authorize(static fn (?Model $record): bool => $record !== null && auth()->user()?->can('approve', $record))
->action(static function (Order $record): void {
$record->approve();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The methods a record action uses, all fluent and all returning static:
| Method | Signature | Default |
|---|---|---|
make | static make(string $name): static | — |
label | label(string $label): static | Str::headline($name) |
icon | icon(string $icon): static | none |
variant | variant(ActionVariant $variant): static | ActionVariant::Ghost |
requiresConfirmation | requiresConfirmation(bool $requires = true, ?string $heading = null, ?string $description = null, ?string $button = null): static | off |
successMessage | successMessage(string $message): static | "{Label} completed." |
successMessageUsing | successMessageUsing(Closure(int): string $callback): static | none |
visible | visible(Closure(?Model): bool $callback): static | always visible |
authorize | authorize(Closure(?Model): bool $callback): static | always allowed |
url | url(Closure(Model): string $callback): static | none — makes it a link |
action | action(Closure(Model, array): void $callback): static | none — makes it a callback |
before | before(Closure(Model, array): void $callback): static | none |
after | after(Closure(Model, array): void $callback): static | none |
schema | schema(Closure(?Model): FormSchema $callback): static | none — makes it a form |
form | form(Closure(?Model): string $callback): static | none — an external form URL |
modal | modal(Closure(Modal): void $callback): static | none |
modalWidth | modalWidth(ModalWidth $width): static | the modal default |
slideOver | slideOver(bool $slideOver = true): static | off |
modalHeading | modalHeading(string $heading): static | the action label |
modalDescription | modalDescription(string $description): static | none |
modalSubmitLabel | modalSubmitLabel(string $label): static | the action label |
modalContent | modalContent(string $component, array $config = []): static | none |
registerModalActions | registerModalActions(array $actions): static | [] |
databaseTransaction | databaseTransaction(bool $enabled = true): static | null, inheriting the panel |
ActionVariant is Default, Secondary, Outline, Ghost, Destructive. ActionType — reported by type(), never set directly — is Link when url() was given, Form when schema() or form() was, and Callback otherwise.
before() and after() run inside the same transaction as the handler, so an after hook that throws undoes the operation rather than leaving it half applied. They live on the action rather than on a page because the action endpoint executes without a page instance.
An action name may contain letters, numbers, dashes, dots and underscores and nothing else. It travels to the endpoint as an identifier, and a name that cannot be matched there renders as a button that fails only when pressed, so PanelSchemaException::unusableActionName() refuses it at construction.
Actions with a form
use App\Models\Order;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
Action::make('reject')
->label('Reject')
->modalHeading('Reject this order')
->modalSubmitLabel('Reject')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('reason')->label('Reason')->required(),
]))
->action(static function (Order $record, array $data): void {
$record->reject($data['reason']);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
The schema is fetched when the dialog opens rather than serialized into every row: a table of twenty records would otherwise ship twenty copies of the same form to open at most one. The submitted data is validated and dehydrated by that schema before the handler sees it, so a key the form never declared is discarded. A handler that only takes Model $record simply never sees the second argument.
Column actions
A whole cell can run an action:
use App\Models\Order;
use PandaPanel\Actions\Action;
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('reference')->action(
Action::make('approve')->action(static fn (Order $record) => $record->approve()),
);2
3
4
5
6
7
use PandaPanel\Actions\Action;
use PandaPanel\Tables\Columns\Column;
Column::action(Action $action): static
Column::getAction(): ?Action2
3
4
5
The action is resolved per record, so a cell the user may not act on renders as an ordinary value rather than as a button that answers 403. TableSchema::getRecordAction() searches the row actions and every column's action, so the endpoint finds it without a second lookup — a column action is a record action in every sense that matters.
A column may also carry url(). Declare one or the other: the renderer puts the action's button inside the link's anchor, and a cell that both navigates and does something is a coin toss.
Where the buttons sit
use PandaPanel\Tables\Enums\RecordActionsPosition;
$table
->recordActionsPosition(RecordActionsPosition::AfterColumns) // the default
->recordActionsLabel('Manage')
->frozenActions();2
3
4
5
6
| Case | Value | Effect |
|---|---|---|
RecordActionsPosition::AfterColumns | after_columns | a column of its own after the data columns |
RecordActionsPosition::BeforeColumns | before_columns | a column of its own before them |
RecordActionsPosition::AfterCells | after_cells | no column at all; the buttons are appended inside the last visible cell |
AfterCells is for a table narrow enough that a column of its own would be most of it. recordActionsLabel() names the actions column in the header; it defaults to "Actions". frozenActions() keeps that column in view while the table scrolls sideways — off by default, because pinning costs horizontal room. See Pinned columns.
What each row carries
TableSchema::toRow() resolves the actions for the record it is serializing:
[
'key' => 42,
'group' => null,
'cells' => ['title' => 'Hello'],
'cellMeta' => ['title' => ['action' => ['name' => 'approve', /* ... */]]],
'actions' => [
['name' => 'edit', 'label' => 'Edit', 'icon' => 'pencil', 'variant' => 'ghost',
'type' => 'link', 'url' => '/admin/posts/42/edit', 'formUrl' => null,
'hasForm' => false, 'modal' => null, 'modalActions' => [], 'confirmation' => null],
],
]2
3
4
5
6
7
8
9
10
11
Action::toArray(?Model $record) returns null when the action is hidden or unauthorized for that record, and the row drops it. Nothing executable ever crosses: a callback action carries its name, not its handler.
The endpoint
The frontend posts a record action to one endpoint per panel:
POST {panel path}/actions/record route name: panel.{panelId}.actions.record{ "resource": "posts", "action": "approve", "record": 42 }A nested resource also sends parent, which is resolved and bound the way route middleware does it for the resource's own pages.
What the controller checks, in order:
- The resource slug resolves inside the panel resolved for this request — a resource from another panel does not exist here.
TableSchema::getRecordAction($name)finds the action, or 404. An action the resource never declared cannot be addressed however the request spells it.Action::isExecutable()— a link action has no handler, so posting one is 400.- The key is a string or an int, or 422.
Resource::findRecord($key)resolves it, or 404. This is the record lookup rather than the list query, because a restore legitimately addresses a record the list hides.Action::isAuthorizedFor($record), or 403.
Then the action runs and the response is a redirect back with a success flash carrying Action::getSuccessMessage().
An action with a form goes to POST {panel path}/actions/submit with scope: "record" instead, and is checked the same way with the schema's validation in front of the handler.
Panel-wide defaults
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Core\Panel;
Panel::make('admin')
->configureActions(static function (Action $action): void {
if ($action->getVariant() === ActionVariant::Destructive) {
$action->requiresConfirmation();
}
});2
3
4
5
6
7
8
9
10
The configurator runs as each action is built, so anything the schema then sets still wins. It is read through the current panel rather than a static registry, so two panels can differ and nothing leaks between requests.
Notes
- Hiding a button is never what protects a record.
visible()andauthorize()decide whether it is drawn; the endpoint asksisAuthorizedFor()again before running anything. visible()andauthorize()receive?Model. They are also called withnullwhen the same action object is serialized without a record, so a closure must handle both — the built-in actions all begin with$record !== null &&.RestoreActionneeds two other things to be reachable. The resource must declare soft deletes, or a trashed record cannot be resolved, and the table needs aPandaPanel\Tables\Filters\TrashedFilter, or no trashed row ever appears for the action to sit on. See Soft deletes.- Record actions run on a relation manager's table too. They are declared on the same schema and posted to the relation action endpoint instead. See Relation tables.
- Success messages are flashed, not returned. The handler returns nothing; the endpoint redirects back and the panel renders the flash as a toast. See Toast notifications.
- A record action cannot see the selection. That is a bulk action, and it is authorized differently.