Authorization
Lattice gates everything with the same two tools. can declares subject-less abilities — the
same word as Laravel’s can: middleware and $user->can(). authorize() holds the logic that
needs the request or the record it acts on.
| declare an ability | custom logic | |
|---|---|---|
| Definition — form, table, action, bulk action, fragment, layout | #[AsTable(can: 'x')] |
authorize() |
| Page | #[AsPage(can: 'x')] |
authorize() |
| Component, column, filter, row action | ->can('x') |
->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.
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:
Action::use(ArchiveProductAction::class)->context(['product_id' => $row['id']]);use Lattice\Actions\ActionDefinition;use Lattice\Core\Concerns\ResolvesContextModels;
class ArchiveProductAction extends ActionDefinition{ use ResolvesContextModels;
protected function product(): Product { return $this->contextModel('product_id', Product::class); }}contextModel() resolves the context value through the model’s own resolveRouteBinding() — the
same column a route parameter would bind against — and aborts with a 404 when the key is missing or
no record matches. It lives on the opt-in ResolvesContextModels trait rather than on Definition
itself, because the package does not depend on illuminate/database. Definition::context() and its
typed scalar siblings — contextString()/contextStringOrNull(), contextInt()/contextIntOrNull()
— are available on every definition without the trait; the strict variants abort with a 404 on a
missing or wrongly-typed value instead of coercing it.
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.