June 27, 2026

Actions in Lattice: a click runs PHP, the server sends effects back

By Manuel Christlieb — Staff Engineer

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.