Skip to content

Authorization

Lattice gates everything with the same two tools. can declares an ability — the same word as Laravel’s can: middleware and $user->can() — either subject-less or, with on, checked against a specific record. authorize() holds the logic that needs the request or something can/on can’t express.

declare an ability custom logic
Definition — form, table, action, bulk action, fragment, layout #[AsTable(can: 'x', on: 'y')] authorize()
Page #[AsPage(can: 'x', on: 'y')] authorize()
Component, column, filter, row action ->can('x', on: 'y') ->visible() / ->hidden()

Put the ability on the attribute — no method needed:

#[AsTable('admin.users', can: 'admin.users.manage')]
class AdminUsersTable extends EloquentTableDefinition { /* … */ }

Pass an array when several must hold — every one has to pass:

#[AsTable('admin.users', can: ['admin.access', 'admin.users.manage'])]

Pages take the same argument:

#[AsPage(route: '/admin/users', can: 'admin.access')]
class AdminUsersPage extends Page { /* … */ }

And any component, column, filter, or row action takes it as a method:

TextColumn::make('cost')->can('finance.costs');
Heading::make('Internal notes')->can(['support.access', 'support.notes']);

A can declaration is checked against Gate::forUser($request->user()) and is never widened by the custom logic beside it — an authorize() override can only narrow it further, and ->visible(true) cannot bring back a component whose can failed. That holds wherever the thing is reached from, which includes a bulk action, gated by its own declaration and its table’s.

Add on when the ability needs a specific record rather than a subject-less check — Gate::check('update', $product) rather than Gate::check('manage-widgets'). on names a context key; its resolved value becomes the Gate::check() subject, so a gate closure written for can('update', $product) works unchanged.

On a definition or a plain component, on names a context key exactly as contextModel() would read it:

#[AsAction('app.products.archive', can: 'update', on: 'product')]
class ArchiveProductAction extends ActionDefinition { /* … */ }
Heading::make('Internal notes')->can('update', on: 'product');

On a page, on names a route parameter instead — a bound model as-is, or an unbound scalar resolved through a registered Lattice::context() resolver of the same name:

#[AsPage(route: '/products/{product}/edit', can: 'update', on: 'product')]
class ProductEditPage extends Page { /* … */ }

A missing subject — the key absent, or its resolver finding nothing — denies outright. It never falls back to a subject-less check.

can and on are inherited from a base page the same way layout, width, and middleware are: a concrete page declaring its own can replaces — rather than merges with — an inherited ability, and the same holds for on.

Abilities that need a subject — can('view', $project) — go in authorize(), where the sealed context is available to resolve the record. It returns true by default, so a definition or page is open until you say otherwise.

use Illuminate\Http\Request;
public function authorize(Request $request): bool
{
return $request->user()?->can('update', $this->product()) ?? false;
}

authorize() is the only method you override. The framework never calls it directly — it composes it with whatever can declared, so the two can’t drift apart.

The gate runs on the definition’s own endpoint before any work happens:

  • An action or bulk action that fails never reaches handle().
  • A form is validated and handled only when authorized.
  • A table or fragment that fails resolves to nothing rather than leaking data.

Because the same definition class owns both the rendered component and the endpoint that backs it, the authorization lives in one place and can’t be bypassed by calling the endpoint directly.

Hidden at render time, not just at the endpoint

Section titled “Hidden at render time, not just at the endpoint”

A component that fails its gate doesn’t just 403 if you call its endpoint — it’s hidden from the page in the first place. Registries resolve a failed check to an unsealed, hidden component, and every place that embeds definition-backed components (page schemas, table row actions, notification actions, a form nested under an action) filters them out before serializing. The client never sees a trace of it: no id, no endpoint, no signed reference. A plain component’s ->can() drops it the same way.

A definition often needs the record it acts on. Pass it as context when placing the component, and read it back with a typed accessor — contextModel(), backed by a resolver registered once via Lattice::context(), or the explicit form on the opt-in Lattice\Core\Concerns\ResolvesContextModels trait. Definition::context() and its typed scalar siblings — contextString()/contextStringOrNull(), contextInt()/contextIntOrNull() — are available on every definition without the trait. See Context for registering resolvers, memoization, and how context inherits into a definition’s children, a page’s frame, slots, and closure-built modals.

The context is sealed into the component’s signed reference, so the value authorize() and handle() read is the value the server issued — not something a client can change. See Security for how that sealing works.