Skip to content
Laravel Warrant

Laravel Warrant

A permission isn't a string you hold — it's a rule about your data. Warrant lets you write that rule once and compiles it straight to SQL, so "can they?", "which rows?", and "what can they do to each row?" all have one answer.

Edit a timesheet if it’s your own, or if you manage the department it belongs to — but never once it’s locked, unless you’re an admin. In Laravel that one rule gets written twice: once as a policy to answer “can they?”, and again as a query scope to answer “which rows?”

⛔ Without Warrant — the same rule, written twice
// app/Policies/TimesheetPolicy.php — the "can they?" answer.
class TimesheetPolicy
{
public function update(User $user, Timesheet $t): bool
{
if ($user->is_admin) return true;
if ($t->locked) return false;
return $t->user_id === $user->id
|| $user->managesDepartment($t->department_id);
}
}
// The "which rows?" answer — the same rule, rewritten by hand as a query scope.
Timesheet::query()
->when(! $user->is_admin, fn ($q) => $q
->where('locked', false)
->where(fn ($q) => $q
->where('user_id', $user->id)
->orWhereIn('department_id', $user->managedDepartmentIds())))
->paginate();

Warrant replaces both with one rule, stored as data. A schema defines, once, how each condition name compiles to SQL:

✅ With Warrant — one rule, stored as data
if is_self or manages_department('sales') they can update
if is_locked and not is_admin they cannot update
if is_admin they can *

Every question now traces back to that single rule — the list filter and the edit button can’t drift apart, because they compile from the same source:

$timesheet->hasAbility('update'); // "can they?" → a scoped EXISTS
Timesheet::query()->hasAbility('update')->paginate(); // "which rows?" → a WHERE clause
Timesheet::query()->selectAbilities()->get(); // "what per row?" → one computed column

Take something a PM would call trivial:

A user can edit a timesheet if it’s their own, or if they manage the department it belongs to — but not once it’s locked, unless they’re an admin.

That one sentence quietly asks three different authorization questions, and the tools you already reach for each answer only one of them.

Can they edit this one?

A Laravel Policy nails this — $user->can('update', $timesheet). It’s exactly what policies were built for.

Which ones can they edit?

The policy can’t say — it needs an object. So you rewrite the same logic as a query scope. Now the rule lives in two places.

What can they do to each row?

Your table has view / edit / delete / approve buttons. That’s 50 rows × 4 abilities = 200 policy calls per page load.

Nothing keeps the policy and the scope in sync. Six months from now someone loosens the locked-timesheet rule in the policy, ships it, and never touches the scope — and now the edit button says yes while the list page says no. It’s the most common authorization bug in Laravel apps, and it’s baked into the pattern.

And a flat permission package (like spatie/laravel-permission) can’t rescue this: 'update timesheets' has no slot for “their own,” “the department they manage,” or “unless locked.” The moment a permission depends on the row, you’re back to a hand-written policy and scope.

One source of truth

The list filter, the per-row ability column, and the one-off check are all compiled from the same rule. No twin scope to keep in step.

Answers in SQL

“Which rows can this user touch?” becomes a WHERE clause the database answers — not a table loaded into memory and filtered in PHP.

Rules are data

Warrant stores nothing and owns no tables. A resolver you write hands back rules from a DB table, a settings screen, a JWT claim, or a plan tier — auditable, changeable without a deploy.

A readable language

if is_self they can view — booleans, parentheses, wildcards, and conditions that take arguments, all validated against your schema.

Predictable semantics

Deny-overrides, EXISTS-wrapped conditions (no three-valued-logic surprises), and loud failures on unknown names or missing context.

Runs on your stack

Laravel 11 & 12, PHP 8.2+, on PostgreSQL, MySQL / MariaDB, or SQLite. No new tables to migrate.

Install & first check

Installation then the Quick start — a schema, a rule, a resolver, and the three questions, end to end.

The mental model

Core concepts — how schemas, rules, and the resolver divide the work, and why deny-overrides is the whole idea.

Build for real

The Guides go deep on schemas, conditions, the rule language, resolvers, context, middleware, and testing.

Look something up

The API Reference has the cheat sheet, full signatures, and the complete error catalogue.