Actions in Lattice: a click runs PHP, the server sends effects back
Part 5 of the Building Lattice series. Part 4 covered tables.
Pages, forms and tables describe state. Actions describe behavior — what happens when someone clicks a button. And like everything else in Lattice, an action is a thing you declare in PHP, not something inferred for you. This is the post where the server-driven model starts to feel genuinely interactive: a click runs PHP on the server, and the server sends a list of effects back for the client to carry out.
An action is two methods
Extend ActionDefinition, mark it with #[AsAction], and implement
definition() (the button) and handle() (what the click does):
#[AsAction('app.products.archive')]
class ArchiveProduct extends ActionDefinition
{
public function definition(Action $action): Action
{
return $action
->label('Archive')
->method(HttpMethod::Patch)
->variant(ButtonVariant::Destructive)
->confirm('Archive this product?', 'It will be hidden from the catalogue.');
}
public function handle(Request $request): ActionResult
{
$product = Product::findOrFail($request->integer('id'));
$product->update(['status' => 'archived']);
return ActionResult::success()
->toast(Variant::Success, 'Product archived.')
->reloadComponent('app.products');
}
}
definition() returns a serialized button; handle() runs on the server and
returns an ActionResult. A page or layout drops the button in with
Action::use(ArchiveProduct::class), and a table exposes it per row through its
actions($row) hook — same definition, different placement.
Confirmation and forms, declared not wired
The ->confirm(...) above isn’t a JavaScript hand-off. It builds a small
Confirmation value object that serializes into the node; React reads it and
opens a dialog before it ever calls the server. No confirm dialog wired by hand.
Actions can also collect input first. ->form([...]) attaches a schema rendered
in a modal — the same fields you’d put in a FormDefinition, validated live with
Precognition and re-validated on submit:
->form([
Textarea::make('reason', 'Reason')->required()->rules(['string', 'max:255']),
])
For an edit action where the modal needs to be prefilled per record, ->lazyForm()
ships a flag instead of the schema and lets the client fetch the filled-in form
when the modal opens — so you can reuse an existing form’s definition without
inlining a stale copy into every row of the page.
Effects are the return value
Here’s the part I like. handle() doesn’t echo HTML or decide what the UI does
next — it returns effects, and the client knows how to apply each one:
return ActionResult::success(['id' => $product->id])
->toast(Variant::Success, 'Archived.')
->reloadComponent('app.products')
->closeModal();
ActionResult is immutable; each helper returns a new one with the effect
appended. The built-ins cover what a click usually needs: toast, redirect,
reloadPage, reloadComponent (refresh one table without a full navigation),
openModal/closeModal, resetForm, download and callout. Under the hood an
effect is just a tagged value object ({ type, ...payload }) declared with
#[AsEffect('toast')]; the React side dispatches it — some act imperatively
(redirect calls the router), others fire a lattice:* DOM event that a feature
component picks up. Because it’s an open union, you can register your own effect
(confetti, anything) as a plugin without touching the framework.
Bulk actions get the selection
Table selections run through almost the same shape — #[AsBulkAction], and
handle() receives the selected records first:
#[AsBulkAction('app.products.archive-selected')]
class ArchiveSelectedProducts extends BulkActionDefinition
{
public function definition(Action $action): Action
{
return $action->label('Archive selected')->confirm('Archive all selected?');
}
public function handle(Collection $records, Request $request): ActionResult
{
$records->each->update(['status' => 'archived']);
return ActionResult::success()
->toast(Variant::Success, "Archived {$records->count()} products.")
->reloadComponent('app.products');
}
}
The framework resolves which records are selected (or all matching the current
filters) and hands you a Collection — you never parse ids out of the request.
The shape holds
Actions are the same bet as everything before them: declare it in PHP, let it serialize, let React render and dispatch. The new idea is the return channel — the server doesn’t push markup, it pushes a small, typed list of things to do. That keeps behavior on the server, where your authorization and domain logic already live, while the client stays a dumb, predictable renderer.
Next: layouts and menus — the shell that wraps every page, and where that
toggleSidebar effect comes from.