Effects & results
handle() returns an ActionResult: optional data and a list of effects
the client runs in order once the action responds.
Results
Section titled “Results”Build a result with success(), optionally passing data:
ActionResult::success(['id' => $product->id]);Then chain effects — each returns a new result, so they read as a pipeline:
return ActionResult::success() ->toast('Archived.') ->reloadComponent('app.products');Effects
Section titled “Effects”| Effect | What it does |
|---|---|
->toast($message, $variant) |
Shows a toast. Variant defaults to success. |
->callout($callout) |
Shows a persistent in-flow banner in the layout’s callout slot. |
->retractCallout($key) |
Drops a keyed callout, even on a same-URL visit. |
->reloadComponent($id) |
Re-fetches a single component (e.g. the table the action changed). |
->reloadPage($full = false) |
Reloads the current page. full does a real browser reload. |
->to($url) / ->toRoute($name, $params) / ->back() |
Navigates to a URL, a named route, or the previous page. |
->download($url) |
Triggers a file download. |
->openModal($modal) / ->closeModal($id) |
Ships a Modal to open, or closes one by id (closeModal() with no id closes every open modal). |
->resetForm($id) |
Resets a form to its initial values (resetForm() resets the current form). |
->localeChange($locale) |
Persists the frontend locale and dispatches lattice:locale-change. |
->toggleSidebar($target) |
Toggles the layout sidebar (optionally a named target). |
return ActionResult::success() ->toast('Report ready.', Variant::Success) ->download(route('reports.download', $report));Shipping a modal with the effect
Section titled “Shipping a modal with the effect”->openModal($modal) takes a Modal instance, not an id — the modal’s full schema travels in the
effect’s payload, and the client-side host renders and opens it. There is no page placement to wire
up: the modal doesn’t sit anywhere in the page tree, the action ships it directly.
use Lattice\Ui\Components\Modal;
return ActionResult::success()->openModal( Modal::make('order-details') ->title('Order details') ->schema([...]),);Reach for this when the modal’s content depends on work the action just did — a record it looked up,
a document it generated — rather than data already available at the trigger. When the content is
already known at render time, embed the modal on the trigger instead with
->modal(). See Modals for both
patterns, including how to open one on page load.
Toasts
Section titled “Toasts”->toast() accepts a message and an optional Variant (the shared Primary…Danger vocabulary),
defaulting to success: ->toast('Saved.'), ->toast('Could not save.', Variant::Danger). Pass a
Toast instead to set a lifetime, control dismissal, or attach a link or action — see
Toasts.
Callouts
Section titled “Callouts”A callout is a persistent in-flow banner — appropriate for warnings, trials, subscription notices, or
any message that should stay visible until the user acts. Unlike a toast, a callout is not transient:
it appears in the layout where the Callouts slot is placed. There is no duration or auto-dismiss.
An unkeyed callout stays until the user dismisses it, and navigating between pages within the same
layout does not clear it; a keyed callout follows the lifetime described below instead.
Build a Callout value object and pass it to ->callout():
use Lattice\Ui\Enums\Variant;use Lattice\Ui\Effects\Builtin\Callout;
return ActionResult::success() ->callout( Callout::make('Your trial ends in 3 days.', Variant::Warning) ->title('Trial ending') ->link('Upgrade', '/billing') );The Callout builder options:
| Method | Effect |
|---|---|
->title($string) |
Optional heading above the message. |
->dismissible(bool) |
Show or hide the close button (default: dismissible). |
->link($label, $href, $method?) |
Render a link in the callout ($method defaults to HttpMethod::Get). |
->action($component) |
Render an action instead of a link. |
->unique($key) |
Treat the callout as a projection of server state under $key rather than an event. |
Callout lifetimes
Without a key, a callout is an event: emitted once, it stays until the user dismisses it.
With a key it is a projection of state. The client replaces any callout sharing the key, and drops it on navigation unless the server asserts it again — so a keyed callout must be re-emitted on every request for which it still holds, typically from middleware.
Retraction only happens on a navigation that changes the URL. A same-URL router.reload(), a
redirect()->back() to the same URL, polling, and partial reloads do not clear a keyed callout — the
server must overwrite it explicitly if the condition no longer holds. Browser back/forward is its own
case: Inertia restores those pages from a history snapshot that carries no flash data, so a keyed
callout disappears on back/forward regardless of whether the condition still holds, until the next
request re-asserts it.
Callout::make(__('billing.past-due'), Variant::Danger) ->unique('billing.state') ->dismissible(false);The example above disables dismissal: dismissing is a client-only action, and the next re-assertion of the same key brings the callout straight back, so letting the user dismiss a standing condition would just be undone on the next matching request.
This is what makes a standing condition — a failing payment, a maintenance window, a read-only mode — expressible. Flashing an unkeyed callout on every request instead would stack a fresh copy per navigation, because the layout that hosts the slot persists across visits.
Retracting a keyed callout
The same-URL cases described above — router.reload(), redirect()->back() to the same URL,
polling, and partial reloads — have no way to clear a keyed callout just by omitting it.
Callout::retract($key) states explicitly that the key no longer applies; the client drops any
callout carrying it regardless of visit type.
The natural place to assert or retract a keyed callout is middleware that runs on every request, so the callout always reflects current state:
Effects::flash($callout ?? Callout::retract('billing.state'));Here $callout is null when the condition (a failing payment, say) no longer holds, so the
middleware falls back to retracting the key instead of asserting a Callout. ActionResult and
LatticeResponse expose the same behavior as ->retractCallout($key), for when the clearing
condition is detected inside an action rather than middleware.
Callouts hold no server-side state. A message that needs identity, a recipient, and read/unread tracking is a notification, not a callout.
Placing the slot in your layout
A callout only renders where the Callouts layout slot is placed. Add Callouts::make() to the
layout’s schema(), typically between the header bar and Outlet::make():
use Lattice\Core\PageSchema;use Lattice\Layouts\Components\Outlet;use Lattice\Ui\Components\Callouts;
public function schema(PageSchema $schema, Request $request): PageSchema{ return $schema->schema([ $this->headerBar(), Callouts::make(), Outlet::make(), ]);}Callout vs. toast
| Callout | Toast | |
|---|---|---|
| Placement | In-flow, where the layout slot is | Overlay, anchored bottom center |
| Persistence | Always persistent | Auto-dismisses after a duration |
| Scope | Requires the layout slot | Global — rendered by the <Toaster> |
Use a callout when the message warrants visible, persistent attention; use a toast for transient confirmations. See Toasts for toast details.
Refreshing what changed
Section titled “Refreshing what changed”After an action mutates data, refresh just the affected component rather than the whole page.
->reloadComponent() takes the component id — a table’s #[AsTable] id, for example — so only that
component re-fetches:
return ActionResult::success()->reloadComponent('app.products');->reloadPage() re-fetches the current page’s props with an Inertia visit — the layout and its
client state (open menus, form drafts, scroll position) stay mounted. That is the right default for
almost every server-side change, including a stale callout that should just disappear: use
Callout::retract() for that, not a page reload.
Pass true for a full browser reload (window.location.reload()) when the change invalidates the
whole shell and an Inertia revisit is not enough — starting or stopping impersonation, a role change
that reshapes navigation, or an asset/version bump:
return ActionResult::success()->reloadPage(full: true);Flashing effects without an action
Section titled “Flashing effects without an action”Effects::flash() sends any effect(s) into the session and delivers them with the next Inertia
response — no ActionResult needed. Use it from a controller, before returning a redirect, in an
event listener, or in middleware — anywhere outside an action:
use Lattice\Ui\Effects\Builtin\Callout;use Lattice\Ui\Enums\Variant;use Lattice\Facades\Effects;
// Flash a callout after a controller redirectEffects::flash( Callout::make('Your export is being processed.', Variant::Info) ->title('Export queued'));
return redirect('/exports');Pass multiple effects to flash them all at once:
Effects::flash( Effects::toast('Settings saved.', Variant::Success), Callout::make('Some changes require a page reload.', Variant::Warning),);The flashed effects are stored in the latticeEffects session bag, drained on the next request, and
run through the normal client-side effect pipeline in order.
Deferred translation with rt()
Section titled “Deferred translation with rt()”Toast and callout messages (and callout titles) also accept rt() — a
Translatable that carries an i18next key plus replacements instead of a finished
string. The client resolves it in the viewer’s locale at display time. Reach for it when the code
producing the effect can’t know that locale — a queued listener flashing a callout, or a
realtime toast broadcast to many subscribers:
Effects::flash( Callout::make(rt('billing:trial-ending.body')->with(['days' => 3]), Variant::Warning) ->title(rt('billing:trial-ending.title')));->with() also accepts DateTimeInterface values, formatted client-side in the reader’s own
locale — see Translatable and rt() for how dates travel over
the wire and render.
Effects built inside a normal request already run in the user’s locale, so plain __() strings
remain the default there.
How effects reach the client
Section titled “How effects reach the client”The result serializes to { ok, data, effects }. Each effect is a { type, props } envelope — the
type discriminant (toast, callout, reload-component, reload-page, redirect, download,
open-modal, close-modal, reset-form, locale-change, toggle-sidebar) plus its props, exactly
like a node; the client dispatches them in order.
Each type is declared once on its PHP class via #[AsEffect('…')] and generated into the
discriminated Effect union, so the PHP helpers and the client dispatcher stay in lockstep.
Custom effects
Section titled “Custom effects”The effect system is extensible — define your own effect type and a client handler for it, the same way components and columns extend the renderer.
On the server, an effect is a readonly value object marked with #[AsEffect], where the constructor
arguments become the wire props:
use Lattice\Ui\Effects\Attributes\AsEffect;use Lattice\Ui\Effects\Effect;
#[AsEffect('confetti')]final class ConfettiEffect extends Effect{ public function __construct(public readonly int $pieces = 100) {}}Return it from handle() with ->effect(), alongside any built-ins:
return ActionResult::success() ->toast('Onboarding complete!') ->effect(new ConfettiEffect(pieces: 250));On the client, author a handler with effectHandler("confetti", …) and register it through your
registry plugin’s effects map. The handler receives
the typed payload:
import { effectHandler } from "@lattice-php/lattice";
const confetti = effectHandler("confetti", (effect) => { launchConfetti(effect.props.pieces);});Unknown effect types are skipped with a console warning, so a server effect with no registered handler fails soft rather than breaking the page.