How it compiles to SQL
Warrant never evaluates rules in PHP. Every rule set is compiled to SQL and resolved by the database — that’s the whole model, and it’s what lets one check filter a list, decorate each row, or answer a yes/no question without ever loading a record. This page is how that compilation works.
You don’t need it to use Warrant, but it explains why the semantics are what they are.
The example.
Section titled “The example.”The schema — a documents entity with three abilities and four
conditions:
use Illuminate\Contracts\Database\Query\Builder;use Warrant\{Ability, RowCondition, GlobalCondition};use Warrant\Schema\WarrantSchema;use Warrant\Schema\Conditions\{RowConditionContext, GlobalConditionContext};
class DocumentSchema extends WarrantSchema{ public const model = Document::class;
#[Ability] public const VIEW = 'view'; #[Ability] public const UPDATE = 'update'; #[Ability] public const DELETE = 'delete';
#[RowCondition] public function isSelf(RowConditionContext $c): Builder { return $c->query->whereRaw('documents.user_id = ?', [$c->user->getAuthIdentifier()]); }
#[RowCondition] public function managesTeam(RowConditionContext $c): Builder { return $c->query->whereIn('documents.team_id', $c->user->managedTeamIds()); }
#[RowCondition] public function isLocked(RowConditionContext $c): Builder { return $c->query->where('documents.locked', true); }
#[GlobalCondition] public function isAdmin(GlobalConditionContext $c): bool { return $c->user->role === 'admin'; }}So each condition contributes this fragment of SQL:
| Condition | SQL it adds |
|---|---|
is_self (row) |
documents.user_id = 42 |
manages_team (row) |
documents.team_id in (7, 12) |
is_locked (row) |
documents.locked = 1 |
is_admin (global) |
none — a bool evaluated in PHP, folded into the branch around it |
The rules (resolved for the current user):
if is_self or manages_team they can view, updateif is_locked and not is_admin they cannot updateif is_admin they can *For the SQL below, assume the current user is not an admin — so is_admin
evaluates to false in PHP. The admin’s case is worth seeing on its own, because
a true swallows whole branches; it’s
at the end.
One predicate per ability
Section titled “One predicate per ability”For each requested ability, the compiler gathers every rule that mentions it (or
*) and folds them into a single predicate. Each can rule contributes its
if-expression to a big OR — any one grant is enough — and each cannot rule
contributes the negation of its if-expression to a big AND, so a single matching
cannot is enough to take the ability away. In other words:
predicate(ability) = ( OR of each `can` rule's if-expression ) AND ( AND of NOT(each `cannot` rule's if-expression) )A few cases resolve to a constant at the edges of that formula:
- An unconditional
cannotnegates toAND NOT(true), i.e.false, which zeroes out the whole predicate — the ability is gone on every row, no matter what anycanrule says. - An ability with no
canrule at all has an emptyOR, so it’sfalsetoo: denied by default. - An unconditional
cancontributes an always-true term, granting the ability on every row.
These are real booleans while the predicate is being assembled, not 1 = 1 /
1 = 0 text — which is what lets them fold away instead
of sitting in the emitted SQL.
See Grants and denials for how this plays out in practice.
A rough translation
Section titled “A rough translation”Here is a rough translation of the rules from the example. Warrant OR’s the can rules
together, and AND’s the negated cannot rules. For update, that’s roughly:
select * from documentswhere ( ( documents.user_id = 42 -- is_self or documents.team_id in (7, 12) -- manages_team ) and not (documents.locked = 1) -- not is_locked)That’s very close to what Warrant actually emits — each condition really is
spliced straight into the WHERE like this. The next sections cover the
refinements: constants are folded away before any SQL is emitted, relational
conditions reach other tables through a whereExists subquery, and NULL
columns follow SQL’s own logic rather than being normalized away.
Constants fold away
Section titled “Constants fold away”Warrant assembles the whole predicate as a tree and only then emits SQL, so a
branch that is provably true or false is a real boolean it can simplify —
not text it is stuck with. The ordinary identities apply:
| Branch | Folds to |
|---|---|
false AND x |
false — the rest of the AND never reaches the SQL |
true AND x |
x — the true is dropped |
true OR x |
true — the rest of the OR never reaches the SQL |
false OR x |
x — the false is dropped |
That is why is_admin leaves no trace in the update predicate above. For a
non-admin the wildcard they can * rule contributes false to the grant OR
and is dropped, and the and not is_admin inside the cannot De-Morgan’s into
an or is_admin that is false and is dropped too.
A 1 = 1 or 1 = 0 therefore reaches the SQL only when a constant decides the
whole predicate — at that point there is nothing left for it to fold into.
Everywhere else it disappears.
The same pass drops parentheses that carry no meaning: a group holding a single
branch is not wrapped, and a condition that emitted one where clause is spliced
in bare rather than nested in a group of its own. Nesting you see in the emitted
SQL reflects real boolean structure.
Conditions compile inline
Section titled “Conditions compile inline”The rough shape above is essentially the real output: each condition is spliced
directly into the WHERE as a nested predicate — there’s no EXISTS wrapper. Two
things are worth understanding about how that works.
1. A condition may only add where clauses — and must add at least one.
A condition method is handed a query builder, but whatever it emits has to be a
boolean the compiler can drop into an OR, AND together, and wrap in NOT. A
where — including whereExists, whereIn, and whereRaw — is exactly that. A
join, groupBy, having, aggregate, or union is not: it changes the query’s
row shape and can’t be spliced into an OR branch or negated in place, so
emitting one throws.
Returning the query untouched throws as well. It contributes nothing to the SQL
and so silently means “match every row” — almost always a forgotten branch rather
than an intent to grant everything. A condition that really does decide the
outcome says so by
returning a bool
instead.
To reach another table, use a correlated whereExists / whereNotExists instead
of a join — it stays a boolean and never multiplies rows:
#[RowCondition]public function managesTeam(RowConditionContext $c): Builder{ return $c->query->whereExists(fn ($sub) => $sub ->from('team_managers') ->whereColumn('team_managers.team_id', 'documents.team_id') ->where('user_id', $c->user->getKey()));}That whereExists is itself just a boolean where, so it drops into the same
OR as the flat is_self one — neither needs to know what the other looks like:
where ( documents.user_id = 42 -- is_self or exists ( select * from team_managers where team_managers.team_id = documents.team_id and user_id = 42 ) -- manages_team)2. NULL follows SQL’s own three-valued logic — and that’s the safe default.
SQL predicates are three-valued: TRUE, FALSE, or UNKNOWN (what you get when
NULL is involved). Warrant doesn’t try to hide that. A condition that touches a
NULL column is UNKNOWN, and the outer WHERE keeps only TRUE rows — so an
unknown condition simply contributes no access. Trace it both ways:
- On a
can, anUNKNOWNgrant isn’tTRUE, so it doesn’t fire — no access added. - On a
cannot, the deny side isAND NOT(condition); with the conditionUNKNOWNthat’sAND NOT(UNKNOWN)=AND UNKNOWN, which drops the row.
So the failure direction is always the safe one: an unknown condition never
grants access and never lifts a deny. The worst that can happen is a legitimate
user being blocked on a null row — never someone seeing a row they shouldn’t. (This
is provable, not incidental: in three-valued logic, replacing any part of the
predicate with UNKNOWN can never turn a not-TRUE result into TRUE.)
If you want a different outcome for nulls, handle it explicitly in the condition:
// treat a NULL `locked` as "not locked"return $c->query->where(fn ($q) => $q ->whereNull('documents.locked') ->orWhere('documents.locked', false));Boolean structure (and / or / not) becomes nested WHERE groups, with
negation pushed to the leaves via De Morgan — so a not lands on a single
condition (not (documents.locked = 1), or not exists (…) for a whereExists),
never on a whole group. A global condition like is_admin that returns a bool
doesn’t touch a row at all: the compiler evaluates it in PHP and folds the result
into the branch around it, so it usually vanishes from the SQL entirely.
Questions the compile cannot answer
Section titled “Questions the compile cannot answer”SQL answers a yes/no question three ways: yes, no, and unknown — the
value a comparison against NULL produces. A WHERE clause keeps a row only on a
yes, and not unknown is unknown again, so an unknown never selects a row and can
never be negated into selecting one.
The compiler holds itself to the same three answers, because some questions it genuinely cannot settle:
- a row condition with no row — a no-target check like
Warrant::abilities(Document::class), or a row condition inside an unboundcheck(... for <schema>)predicate; - a
@columnnaming rows that are not in scope, for the same reason; - a row selector that resolves to nothing, such as an absent
@contextkey.
A condition may reach the same conclusion about its own question and say so, by
returning null — see Conditions.
None of those is false. false is an answer, and negating an answer is
legitimate — so a false under a cannot would become true, and a question the
compiler could not answer would silently lift a deny. Each of them compiles to
unknown instead, which negates to itself.
The practical consequences:
- An ability that turns on an unanswerable question is not granted, and one
denied by an unanswerable
cannotis not granted either — an unknown deny is not a deny that failed to fire. getAbilitiesWithoutTarget()is correspondingly conservative.they can viewplusif is_owner they cannot view, asked with no row, reports nothing: the deny cannot be evaluated, so the ability cannot be claimed.- Where an unknown cannot be folded away it reaches the SQL as a literal
null, and the database applies the same rules.
Global conditions still evaluate normally. An absent optional
@context argument is a separate matter: it is passed to
the condition as null, and the condition decides what that means.
Cross-schema references with the row in hand
Section titled “Cross-schema references with the row in hand”The same idea reaches across a schema boundary. A row-bound
can(view for folders(@context folder)) normally compiles to an EXISTS over
the referenced table, which asks two things at once: does that row exist, and
does the other schema grant the ability on it?
Hand the row itself to the selector — a hydrated model rather than a key — and
both halves can be settled without the subquery. The referenced schema’s row
conditions receive it as $c->model and may fold to a constant, and
Model::$exists already answered whether the row is there. The whole reference
then becomes a literal and the EXISTS is never built.
A key alone cannot do this: the constant still has to be asked in SQL, because whether the row exists is exactly what a key has not established.
Row conditions with the row in hand
Section titled “Row conditions with the row in hand”The mirror image: when a check names one specific row and the caller passed the
loaded model — $guard->can('update', $document) — that model reaches the
condition as $c->model. A condition written to use it can
answer in PHP,
returning a bool that folds exactly like a global condition’s, so a rule whose
conditions all answer this way compiles to a constant and the check never queries
at all. Anything the compiler covers with more than one row — filterQuery, the
per-row ability list — has no single model to pass, so those conditions emit
their SQL as always.
Worked examples
Section titled “Worked examples”Now compile the example’s rule set, one ability at a time. Each
ability gets its own predicate, and its shape comes entirely from which rules
mention it. Our user is not an admin, so the wildcard is_admin rule contributes
false to every grant — dropped by the fold, leaving the row conditions to decide.
delete is the simplest — only the wildcard is_admin they can * grants it,
and no cannot mentions it. is_admin is false for this user, so the grant OR
has nothing left in it and the constant is the whole predicate:
select * from documents where (1 = 0)view is granted by three sources — is_self, manages_team, and the
wildcard is_admin — OR’d together, with nothing denying it. The is_admin term
is false and drops out, leaving the two conditions:
select * from documents where ( documents.user_id = 42 -- is_self or documents.team_id in (7, 12) -- manages_team)update has the same three grant sources as view, but the second rule adds a
cannot — so its predicate ANDs a deny side onto the grant:
select * from documents where ( ( -- grant side: the two-part first rule (the wildcard's `false` dropped out) documents.user_id = 42 -- is_self or documents.team_id in (7, 12) -- manages_team ) -- deny side: NOT(is_locked and not is_admin), De-Morgan'd onto the leaves and not (documents.locked = 1) -- not is_locked)The cannot update guarded by is_locked and not is_admin becomes
NOT(is_locked AND NOT is_admin), which De Morgan turns into
(NOT is_locked OR is_admin) — negation always lands on a leaf (a single
condition, or a constant for a global bool), never on a group. is_admin is
false for this user, so that OR branch drops and only NOT is_locked is
emitted.
Several abilities at once combine per the match mode.
userHasAbility(['view', 'update'], matchMode: ALL) ANDs the view and update
predicates from above (ANY would OR them):
select * from documents where ( /* the view predicate */ ) and ( /* the update predicate */ )Folding crosses the ability boundary too: under ANY, one ability the user holds
unconditionally makes the whole gate 1 = 1 and the others are never emitted;
under ALL, one ability they can never hold makes it 1 = 0.
Per-row abilities (selectUserAbilities)
run each ability’s predicate as a correlated subquery per row — one
SELECT '<ability>' as ability WHERE <predicate> UNION ALL branch per requested
ability, aggregated into a JSON array:
select *, ( select coalesce(json_group_array(ability), json_array()) from ( select 'view' as ability where ( /* the view predicate */ ) union all select 'delete' as ability where ( /* the delete predicate */ ) ) as available_abilities) as abilitiesfrom documentsEach row’s abilities column ends up holding just the abilities whose predicate
held for that row — e.g. ["view"] for a document the user owns but can’t
delete. (The JSON aggregate differs by driver — see below.)
When a constant decides everything
Section titled “When a constant decides everything”Run the same rule set for an admin and is_admin is true instead. The
wildcard rule’s true now swallows the grant OR for every ability, and on
update the De-Morgan’d or is_admin swallows the deny side too. All three
predicates reduce to the same thing:
select * from documents where (1 = 1)The is_self, manages_team and is_locked conditions are not merely OR‘d
against a true constant — they are absent from the query, and their condition
methods’ SQL is never used. This is the case where a 1 = 1 survives: the
constant is the whole predicate, so there is nothing for it to fold into.
One compiler behind every check
Section titled “One compiler behind every check”- “Which rows?” — the predicates become your query’s
WHERE(userHasAbility). - “What can they do to each row?” — the predicates run as correlated
subqueries producing a JSON column
(
selectUserAbilities). - “Can they?” — the predicate runs as a scoped
EXISTS(Warrant::can).
Because everything is one compiler, the yes/no check, the filtered list, and the per-row abilities can never disagree.
Per-row aggregation is driver-specific
Section titled “Per-row aggregation is driver-specific”The selectUserAbilities JSON column uses each database’s native JSON aggregate:
| Driver | Aggregate |
|---|---|
| PostgreSQL | coalesce(json_agg(...), '[]'::json) |
| MySQL / MariaDB | coalesce(json_arrayagg(...), json_array()) |
| SQLite | coalesce(json_group_array(...), json_array()) |
Any other driver throws at query-build time. The subquery builds one UNION ALL
branch per ability, which is why
narrowing with onlyAbilities is a
real cost saving on wide lists.
Validation happens at compile time
Section titled “Validation happens at compile time”Compilation validates every ability and condition name against the schema; an unknown name is a hard error, so a typo in a stored rule fails loudly rather than silently granting or denying. See Errors & exceptions.
