Skip to content

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

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.

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:

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.