Laravel Warrant vs. spatie/laravel-permission
Most teams reach for spatie/laravel-permission
expecting it to handle authorization in their app. It’s a well-built, popular
library — but it solves a much smaller problem than people assume, and seeing
the gap is the fastest way to understand what Warrant is for.
What people hope it solves
Section titled “What people hope it solves”The mental picture when you install a “permissions” package is usually the whole thing: “A user can edit a document if it’s their own, or if they manage the team it belongs to — but not once it’s locked, unless they’re an admin.”
That one sentence is real authorization: it depends on the row, on state, and on relationships. It’s also where all the effort and all the bugs live.
What Spatie actually does
Section titled “What Spatie actually does”Spatie manages the storage and assignment of flat permission strings. It gives
you tables and a tidy API to say “this user has the edit documents
permission” and to check it:
$user->givePermissionTo('edit documents'); // insert a pivot row$user->assignRole('manager'); // insert a pivot row$user->can('edit documents'); // boolean lookupThat’s genuinely useful — but it’s essentially insert/delete on a few pivot tables plus a boolean lookup. It’s the easy, mechanical part. A permission is a string you either hold or you don’t; it has no idea which document, whether it’s locked, or which team it belongs to.
The part it leaves you to build
Section titled “The part it leaves you to build”Because edit documents can’t express “their own, unless locked, unless admin,”
that logic lands back on you. Spatie’s own best-practices guide points you straight
at Laravel’s Model Policies for it — it calls them
“the best way to incorporate access control”,
the place to combine your application logic with your permission rules.
So for any permission with real rules behind it, you write a policy — and the flat Spatie permission is just the entry check inside it, wrapped in the logic that actually matters:
class DocumentPolicy{ public function update(User $user, Document $doc): bool { if (! $user->can('edit documents')) return false; // ← the Spatie part
// ...everything that actually decides the outcome is hand-written: if ($user->hasRole('admin')) return true; if ($doc->locked) return false;
return $doc->user_id === $user->id || $user->managesTeam($doc->team_id); }}That policy answers “can they edit this one?” But it can’t answer “which ones can they edit?” — a policy needs an instance, and a list page has none. So you write the same logic a second time, by hand, as a query scope:
// Documents this user may edit — the policy's rules, rewritten for a query.Document::query() ->when(! $user->hasRole('admin'), fn ($q) => $q ->where('locked', false) ->where(fn ($q) => $q ->where('user_id', $user->id) ->orWhereIn('team_id', $user->managedTeamIds()))) ->get();Now the rule lives in two hand-written places that share no code. Six months later someone loosens the locked-document check in the policy and never touches the scope — the edit button says yes while the list quietly says no. That drift is the single most common authorization bug in Laravel apps. And this is per resource, times a third copy for the per-row “what can they do to each of the 50 rows on this page?” question. Spatie sits underneath all of it as a permission store — the actual authorization complexity is still 100% yours.
What Warrant actually solves
Section titled “What Warrant actually solves”Warrant is built for that hard part. You write the rule once, as data — the whole policy in three lines:
if is_self or manages_team they can updateif is_locked and not is_admin they cannot updateif is_admin they can *Each condition name (is_self, manages_team, is_locked, is_admin) is defined
once in a schema, which teaches Warrant how it becomes SQL:
class DocumentSchema extends WarrantSchema{ public const model = Document::class;
#[Ability] public const VIEW = 'view'; #[Ability] public const UPDATE = 'update';
#[TargetedCondition] // is_self public function isSelf(TargetedConditionContext $c): Builder { return $c->query->whereRaw('documents.user_id = ?', [$c->user->getAuthIdentifier()]); }
#[TargetedCondition] // manages_team — the current user manages the document's team public function managesTeam(TargetedConditionContext $c): Builder { return $c->query->whereIn('documents.team_id', $c->user->managedTeamIds()); }
#[TargetedCondition] // is_locked public function isLocked(TargetedConditionContext $c): Builder { return $c->query->whereRaw('documents.locked = ?', [true]); }
#[GlobalCondition] // is_admin public function isAdmin(GlobalConditionContext $c): bool { return $c->user->hasRole('admin'); }}And every question traces back to that one rule — no second copy to keep in sync:
$document->hasAbility('update'); // "can they?" → a scoped EXISTSDocument::query()->hasAbility('update')->paginate(); // "which rows?" → a WHERE clauseDocument::query()->selectAbilities()->get(); // "what per row?" → one computed column“Which rows?” becomes a WHERE clause the database answers, not a collection loaded
into memory and filtered in PHP — and the check, the filter, and the per-row column
cannot drift, because they compile from that single rule. See
How it compiles to SQL.
Side by side
Section titled “Side by side”| spatie/laravel-permission | Laravel Warrant | |
|---|---|---|
| What it is | a store for flat permission / role assignments | the authorization logic itself, compiled to SQL |
| Row-level conditions (own it, same team, locked, unless admin) | not expressible — you write a Policy by hand | first-class conditions in the rule language |
| “Which rows can they act on?” | not addressed — you write a query scope by hand | Model::query()->hasAbility('update') → a WHERE clause |
| Per-row abilities for a list | a check per row × ability | ->selectAbilities() → one query, a JSON column |
| Keeping the check and the filter in sync | your problem (two hand-written copies) | one source of truth; they compile together |
| Storage | ships migrations + permission / role tables | owns no tables; rules come from a resolver |
| Best at | assigning and looking up flat permissions | expressing and enforcing row-dependent rules |
Can you use them together?
Section titled “Can you use them together?”Yes — they operate at different layers, so Spatie (or any role system) can stay as your source of roles, while Warrant does the actual authorization. Your resolver translates the current user’s roles into the rules for a resource:
use Warrant\RuleResolutionContext;use Warrant\RuleResolver;use Warrant\RuleSyntaxTree\WarrantRuleSet;
class DatabaseRuleResolver implements RuleResolver{ public function resolve(RuleResolutionContext $context): WarrantRuleSet { if ($context->user->hasRole('admin')) { // Spatie answers "what role?" return WarrantRuleSet::fromSyntax($context->schemaKey, 'they can *'); }
return WarrantRuleSet::fromSyntax( $context->schemaKey, 'if is_self they can view, update', // Warrant answers "on which rows?" ); }}Spatie tells you “what role is this user?” Warrant answers the question that actually took all the work: “so what can they do to these rows?”
Next steps
Section titled “Next steps”- Quick start — a schema, a rule, a resolver, and the three questions end to end.
- Core concepts — how schemas, rules, and the resolver divide the work.
- How it compiles to SQL — why the three questions can never disagree.
