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() |
Declaring can
Section titled “Declaring can”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.
Declaring a gate subject with on
Section titled “Declaring a gate subject with on”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.
Writing authorize()
Section titled “Writing authorize()”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.
Reading trusted context
Section titled “Reading trusted context”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.