7 · Actions and dashboard widgets
Goal: a one-click Publish button on the rows that need it, and a dashboard that tells you something at a glance.
An action is a backend-owned operation the frontend can request by name. The definition that crosses the wire carries a label, an icon and confirmation copy — it never carries the handler. The browser sends an action name, a resource slug and record keys; the server looks the action up in the schema that declared it, authorizes it, and runs it.
Do this — the publish action
Add it to the table's record actions. In app/Panels/Admin/Resources/Products/Tables/ProductsTable.php:
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;2
3
4
5
->recordActions([
Action::make('publish')
->label('Publish')
->icon('check')
->variant(ActionVariant::Outline)
->visible(static fn (?Model $record): bool
=> $record?->getAttribute('status') !== 'published')
->authorize(static fn (?Model $record): bool
=> $record !== null && ProductResource::canEdit($record))
->requiresConfirmation(
heading: 'Publish this product?',
description: 'It becomes visible in the catalogue immediately.',
button: 'Publish',
)
->successMessage('Product published.')
->action(static function (Product $record): void {
$record->forceFill([
'status' => 'published',
'published_at' => $record->published_at ?? Date::now(),
])->save();
}),
ViewAction::make(ProductResource::class),
EditAction::make(ProductResource::class),
DeleteAction::make(ProductResource::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
Reload the list. Every draft row now carries a Publish button; published rows do not. Pressing it opens a confirmation, posts {"resource": "products", "action": "publish", "record": 42}, and redirects back with a toast.
The two guards do different jobs
->visible(...) // hides the button; implies nothing about permission
->authorize(...) // the permission, asked again when the action runs2
visible() is presentation: a published product does not need a Publish button. authorize() is access control, and it is re-asked on execution — a row action the policy refuses is absent from the row and refused at the endpoint. Hiding a button is never the control.
Both closures are called with null when the action is serialized without a record — for a header or bulk action — which is why every built-in begins with $record !== null &&.
One action, three shapes
The kind is derived from what you gave it, never set directly:
| Given | Kind | What the frontend does |
|---|---|---|
url() | link | Navigates to the server-produced URL |
schema() | form | Opens a dialog and fetches the form when it opens |
| neither | callback | Posts the action name to the action endpoint |
// Ask for a reason before rejecting — an action with its own form:
Action::make('archive')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('reason')->required(),
]))
->action(static function (Product $record, array $data): void {
$record->forceFill(['status' => 'archived'])->save();
Log::info('Product archived', ['id' => $record->getKey(), 'reason' => $data['reason']]);
});2
3
4
5
6
7
8
9
$data is what the dialog submitted, already validated and dehydrated. A handler declared with one argument never sees it — which is why adding a form to an existing action is additive.
Add a bulk version
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;2
->bulkActions([
Action::make('publish-selected')
->label('Publish selected')
->icon('check')
->requiresConfirmation()
->successMessageUsing(static fn (int $count): string
=> "{$count} product(s) published.")
->authorizeEachUsing(static fn (Model $record): bool
=> ProductResource::canEdit($record))
->bulkAction(static function (Collection $records): void {
DB::transaction(static function () use ($records): void {
$records->each->forceFill([
'status' => 'published',
'published_at' => Date::now(),
])->each->save();
});
}),
DeleteBulkAction::make(ProductResource::class),
])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
A bulk run authorizes every record before touching any of them, so a selection containing one forbidden record changes nothing. executeBulk() is not itself wrapped in a transaction — with your own bulkAction() handler, the wrapping is yours, which is why the DB::transaction() is there.
The bulk endpoint caps at 500 keys
Anything larger belongs in a table action that queues a job.
Do this — the dashboard widget
php artisan make:panel-widget ProductStats --panel=Admin --type=stats<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\Product;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class ProductStats extends StatsWidget
{
protected static int $sort = 0;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 3];
protected static ?string $heading = 'Catalogue';
protected static ?string $description = 'Everything currently in the products table.';
/**
* @return list<Stat>
*/
public function stats(): array
{
$counts = Product::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
return [
Stat::make('Products', (int) $counts->sum())
->icon('package')
->url(ProductResource::url()),
Stat::make('Published', (int) $counts->get('published', 0))
->icon('check')
->color(StatColor::Success)
->description('Visible in the catalogue'),
Stat::make('Catalogue value', (float) Product::query()->sum('price'))
->icon('circle-dollar-sign')
->format(prefix: '$', decimals: 2),
];
}
}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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Open /admin. The widget is already there — the panel's discoverWidgets(app_path('Panels/Admin/Widgets')) covers that directory, so nothing needs registering.
What the pieces do
$sort | Order on the page, ascending |
$columnSpan | How much of the page grid the row occupies. Responsive, and clamped to the grid |
$heading, $description | The title and sub-line above the widget |
Stat::make(label, value) | A string value is printed exactly as written; a number is formatted |
->format(prefix:, suffix:, decimals:) | Formatting happens on the server — a figure is a number and how it should be read |
->url(...) | Makes the card a link. The destination authorizes for itself when followed |
->color(StatColor::…) | A closed set, because each case maps to literal Tailwind classes |
Two more things Stat can wear
Stat::make('Revenue', 12_045)->trend('up', 12.4); // ↗ 12.4% Increased
Stat::make('Sign-ups', 412)->chart([4, 9, 7, 12, 18, 21]); // a sparkline, needs 2+ values2
The direction decides the colour, not the sign of the value — pass the magnitude and say which way it went. And compute a sparkline in one query; six queries for decoration is not a trade worth making.
Widgets are authorized too
public static function canView(): bool
{
return auth()->user()?->is_admin === true;
}2
3
4
A widget the user may not see is absent from the payload, not hidden with CSS.
Check it worked
- A draft product's row shows Publish; press it and the row's badge turns green.
- A published product's row does not show it.
- Select two drafts and publish them together — one toast, one count.
/adminshows three figures, and the first card links to the products list.
If it did not work
| Symptom | Cause | Fix |
|---|---|---|
PanelSchemaException: inert action | The action has no handler, URL, form or modal | Give it an action() |
PanelSchemaException: duplicate actions | Two actions share a name in one set | Rename one |
| The button 400s when pressed | The scope needs a different handler — a bulk action needs bulkAction() or action() | Match the handler to where the action lives |
| The widget does not appear | It is outside the discovered path, or panel:cache is stale | php artisan panel:clear |
| The icon is missing | The key is not in the build-time registry | php artisan panel:icons |
A TypeError on $record->… | The closure dereferenced a null record during serialization | Start with $record !== null && |
Next
It works on your machine. One page left.
See also
- Action basics — the full API, and how execution stays safe
- Bulk actions, Action forms, Modals
- Stats widgets, Chart widgets, Widget layout
- Widget authorization