Skip to content

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.

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.

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 lookup

That’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.

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:

app/Policies/DocumentPolicy.php
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.

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 update
if is_locked and not is_admin they cannot update
if 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:

app/Warrant/DocumentSchema.php
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 EXISTS
Document::query()->hasAbility('update')->paginate(); // "which rows?" → a WHERE clause
Document::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.

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

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?”