Skip to content

Effects & results

handle() returns an ActionResult: optional data and a list of effects the client runs in order once the action responds.

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');
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));

->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.

->toast() accepts a message and an optional Variant (the shared PrimaryDanger 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.

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.

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);

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 redirect
Effects::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.

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.

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.

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.