The rule language
Rules are the policy itself, written as a plain string. You’ll typically store these strings (per role, per user, per tenant) and load them in your resolver.
Throughout, “they” is the current user — the one your resolver was asked about. A rule set describes what this user can do with the resource it’s scoped to, not what everyone can do.
Anatomy of a rule
Section titled “Anatomy of a rule”A rule is an optional if <expression> followed by one or more they can /
they cannot clauses:
if is_selfthey can view, updatethey cannot deleteif <expression>— optional. When present, the clauses apply only where the expression holds. When omitted, the rule is unconditional (always applies).they can <abilities>— grants the listed abilities.they cannot <abilities>— denies the listed abilities.
Abilities are comma-separated. A rule may freely mix can and cannot clauses.
A cannot clause may also carry a denial message with because '<message>',
surfaced when that rule is the cause of a denial — see
Denial messages:
if is_lockedthey cannot update because 'This document is locked.'Grouping rules by ability
Section titled “Grouping rules by ability”When several rules are about one ability, an ability block names it once. The
header reads can they <abilities>, and the clauses inside take those abilities
and name none of their own:
can they view { if is_public they can if is_locked they cannot because 'This document is locked.'}That is the same as writing each clause out in full:
if is_public they can viewif is_locked they cannot view because 'This document is locked.'A block is grouping and nothing more. It produces exactly those rules, and since rule order never matters, the two forms are indistinguishable to everything downstream.
A header may list several abilities, or use the * wildcard:
can they edit, delete { if is_owner they can}
can they * { if is_suspended they cannot because 'Your account is suspended.'}Blocks and ordinary rules mix freely, in any order:
for documents { if is_admin they can *
can they view { if is_public they can }
if is_archived they cannot edit because 'This document is archived.'}Three things are rejected:
- A clause inside a block naming its own abilities —
can they view { if x they can edit }. The header is the one place the ability is said, so it stays a complete account of what the block is about. - A block inside a block. An inner header would answer a question the outer one already settled.
- A headless clause outside a block —
if is_public they canat the top level has nothing to take its abilities from.
The same ability may appear in more than one header. Nothing is lost when it does: both blocks’ rules apply, exactly as the longhand would.
A block is also where an @include most often sits:
the header names the abilities, so the include needs no for list of its own.
can they view, edit { @include requires_approval}can, cannot, and how they combine
Section titled “can, cannot, and how they combine”Warrant combines grants and denials with one rule: a cannot always beats a
can. For a given ability the compiled predicate is:
( any `can` rule for it matches ) AND ( no `cannot` rule for it matches )- A
cannotis an absolute veto. An unconditionalthey cannot deletemeans this user can never delete any row — nocanrule can bring it back. - An ability with no
canrule is denied. Silence is not permission. - Rule order does not matter — the combination is commutative.
This user can view every row, but never update or delete a locked one — even if
another rule grants update:
they can viewif is_lockedthey cannot update, deleteSee Grants and denials for the full semantics.
Boolean logic
Section titled “Boolean logic”The if expression is a boolean combination of conditions:
if is_self or is_managerif is_self and not is_lockedif is_manager and (in_team('sales') or in_team('eng'))and,or— binary operators.not— negation.!is an accepted synonym (!is_locked≡not is_locked);notis the canonical spelling.- Parentheses group sub-expressions.
Each bare name (is_self, is_manager) is a condition declared on the
schema.
Operator precedence
Section titled “Operator precedence”From tightest to loosest binding: not / ! > and > or. Parentheses
override. So:
if is_self or not is_manager and is_ownerparses as is_self OR ((NOT is_manager) AND is_owner). When in doubt,
parenthesize.
Wildcards
Section titled “Wildcards”* stands for every ability the schema declares, on both sides — an admin gets
every ability; a suspended user loses every one (a lockout that wins):
if is_adminthey can *
if is_suspendedthey cannot *they cannot * is the idiomatic kill switch — it vetoes every ability at once.
Passing arguments to conditions
Section titled “Passing arguments to conditions”A condition can take arguments in three ways resolved before compilation — inline literals, named bindings, and positional bindings. A fourth source, check-time context, is resolved later, when the check runs.
Inline literals
Section titled “Inline literals”Written directly in the rule. Supported types: string (single- or
double-quoted), int, float, bool, null.
if in_team('sales', 'eng') they can viewif seen_recently(30, true) they can viewStrings may be delimited by single (') or double (") quotes — pick whichever
avoids escaping (e.g. "can't touch this"). The closing quote must match the
opener; escape a quote or backslash with \', \", and \\. Lists and other
complex values cannot be written inline — pass them via a binding.
Named bindings (:name)
Section titled “Named bindings (:name)”Placeholders filled from a bindings array. The name is what matters: a binding may be reused any number of times, appear anywhere in the string (even across rules), and array order is irrelevant.
WarrantRuleSet::fromSyntax(' if is_specific_user(:uid) they can view if delegated_to(:uid) they can approve', 'documents', ['uid' => $currentUserId], // one value, used twice);Positional bindings (?)
Section titled “Positional bindings (?)”Filled left-to-right across the entire string from a flat array.
WarrantRuleSet::fromSyntax( 'if in_team(?, ?) they can view', 'documents', ['sales', 'eng'], // ? ? -> 'sales', 'eng');Rules for bindings — enforced at parse time
Section titled “Rules for bindings — enforced at parse time”- A binding value may be any PHP value — string, int, array, an object,
anything. (Only inline literals are restricted to scalars.) Your condition
receives it verbatim — as the corresponding parameter (and on
$c->arguments). - You may not mix named and positional bindings in one parse.
- Every placeholder must have a value, and every provided value must be used. A missing binding, an unused binding, or a positional count mismatch is an error.
Check-time context (@context)
Section titled “Check-time context (@context)”Some values are known only when the check runs — the current tenant, an
academic year, an as-of date. Reach these with @context <key>, which stays
symbolic in the rule and is filled from a context: array at check time:
if in_workspace(@context workspace_id) they can view, editA @context key needs no declaration to be referenced — any
key name is accepted and filled from the context: array (mark a key
#[RequiredContext] only if it must be present on every check).
Unlike :name / ? bindings, a @context reference is not subject to the
parse-time “every binding used / no mixing” rules — it carries no value at parse
time, may sit alongside literals and bindings, and never consumes a positional
?:
if scoped_to('projects', @context project_id, :region) they can viewFull behaviour — required vs. optional keys, and how a missing optional key fails closed — is covered in Check-time context.
Column references (@column)
Section titled “Column references (@column)”Sometimes an argument needs to be a database column, not a value — most often
to correlate a subquery against the row being checked. Write @column <column>:
if pay_period_matches(@column pay_period_id) they can viewThat names no table, and deliberately so. It means the rows this rule is
already about, which is decided when the rule is compiled rather than when it is
written — the schema’s own table, the alias a caller used
(filterQuery($query->from('timesheets as t'), ...)), or the frame a
can(...) / check(...) hop selected. A reference that names nothing cannot
name the wrong thing, so this is the form to reach for.
At compile time the frame is resolved to whatever identifier it carries and the
whole thing is quoted through the connection’s grammar, so the condition receives
an Illuminate\Database\Query\Expression — e.g.
`timesheets`.`pay_period_id` on MySQL, "timesheets"."pay_period_id" on
Postgres/SQLite. Because it is an Expression, a condition can drop it straight
into the query builder (->where(...), ->whereColumn(...)) and it is emitted
verbatim — never re-quoted, never bound as a value.
Like @context, a @column reference carries no value at parse time, so it is
exempt from the binding rules, may sit alongside literals and bindings, and never
consumes a positional ?.
Naming a frame
Section titled “Naming a frame”Write @column <name>.<column> when a rule can see more than one frame and you
have to say which:
# grants view on a timesheet when its pay period is openif check(is_open for pay_periods(@column pay_period_id)) they can viewThat row selector is read where it is written — in the timesheet’s frame — so it
compiles to ... exists (select * from pay_periods where pay_periods.id = timesheets.pay_period_id and (...)). It works identically as a can(...) row
selector and as a with map value.
Inside a check(...) predicate, both frames are in scope: the schema the handle
named, and the one the rule is written on. Naming them apart is what lets a
single predicate compare the two:
if check(owner_matches(@column timesheets.owner_id) for pay_periods(@column pay_period_id))they can viewA name may be a schema key or an alias an enclosing handle introduced with
as (see Cross-schema checks). Anything else is
rejected at validation, and the message lists what is in scope:
A @column reference names [folders], which is not in scope here; the names inscope are [timesheets, pay_periods].That check is why an unqualified reference is worth preferring: a name has to keep being right everywhere the rule is reached from, and a rule reached through a hop sees a different set of names than one at the top of a query.
What a row selector may be
Section titled “What a row selector may be”The arguments inside a can(... for schema(<args>)) or
check(... for schema(<args>)) handle identify one row of the referenced schema.
(Both builtins — handles, the with map, and the SQL they compile to — have their
own page: Cross-schema checks.)
They are the arguments of that schema’s row key, bound positionally exactly as
a condition’s are. By default a schema is addressed by its primary key, so there
is one argument and it lands in where <table>.<key> = ?. A schema that overrides
matchKey() is addressed by whatever that
declares — a natural key, or several columns where no single one is unique:
if can(assign for shift_days(@column team_id, @column starts_on)) they can createHow many arguments a handle must supply is decided by that key’s parameters, and supplying too few is reported when the rule is validated.
Each argument must be something a database can compare against a column. Warrant accepts:
| Value | What happens |
|---|---|
| a string, int, float, or null | bound as written |
| the referenced schema’s own model | its key is bound — and if the model is hydrated, the referenced schema’s row conditions also receive it as $c->model and may answer in PHP |
a BackedEnum |
Laravel unwraps it to its scalar value |
a DateTimeInterface |
Laravel formats it for the connection |
@column / @sql |
spliced as raw SQL rather than bound (see above) |
Passing the model is often the most direct thing to write, since you usually have it already:
if can(view for folders(@context folder)) they can view$user->warrant()->can('view', $document, ['folder' => $folder]);A model of a different schema is rejected — its key would be compared against
the wrong table, which matches nothing and looks like a permission problem rather
than a mistake. Any other object is rejected for the same reason: it has no
meaning as a row key, and left alone it would reach the database as whatever its
__toString() produces.
Raw SQL references (@sql)
Section titled “Raw SQL references (@sql)”When a column reference is not enough — you need a scalar subquery, a function
call, or any other expression the DSL has no syntax for — write @sql "<sql>".
The body is a quoted string (single or double quotes, using the usual \' / \"
/ \\ escapes), or a :name / ? binding that resolves to a string — a binding
is substituted for its value at parse time, so @sql :q with q => 'select 1' is
identical to @sql "select 1". Either way the body is spliced into the query
verbatim:
if pay_period_matches(@sql "select pay_period_id from settings limit 1") they can viewAt compile time the body is wrapped in a single pair of parentheses and handed to
the condition as an Illuminate\Database\Query\Expression — exactly what
DB::raw('(' . $sql . ')') produces. The parentheses are always added (even if
you wrote your own), so a bare select ... is valid as a scalar subquery in a
comparison. Nothing else is done to the string: it is never bound as a value or
re-quoted.
Like @context and @column, the resolved @sql reference carries no value of
its own into the compiled tree and may sit alongside literals and bindings. Note
the one difference from those two: if you write the body as a :name / ?
binding, that binding is consumed — it feeds the SQL string and counts toward
the “every binding used / no mixing” rules just like any other placeholder. (The
string-literal form, @sql "...", consumes nothing.) It works everywhere an
argument is accepted: condition parameters, can(...) / check(...) row
selectors, and with map values.
Whitespace, multiple rules, reserved words
Section titled “Whitespace, multiple rules, reserved words”-
Whitespace is insignificant. Newlines are cosmetic; an entire rule set can be one line. These are identical:
if is_self they can view if is_manager they can approveif is_selfthey can viewif is_managerthey can approve -
ifstarts a new rule. Everyifbegins a new rule;they can/cannotclauses attach to the most recentifabove them. Clauses before anyifform a single leading unconditional rule. -
Reserved words —
if,they,can,cannot,because,check,and,or,not,for,with,as— cannot be used as an exact condition or ability name, and neither can the literalstrue,false, andnull. A name may contain or start with one, though:canonical,cannot_publish,is_and_somethingare all fine. -
Identifiers (condition, ability, and binding names) match
[A-Za-z_][A-Za-z0-9_-]*— start with a letter or underscore; may contain letters, digits, underscores, and dashes. No dots.
Formal grammar
Section titled “Formal grammar”ruleset = ( clause+ | "if" expr clause+ | ability_block | include )* ;ability_block = "can" "they" ability ( "," ability )* "{" ruleset "}" ;include = "@include" IDENTIFIER [ "(" [ arg { "," arg } ] ")" ] [ "for" ability { "," ability } ] ; (* the `for` list is required outside an ability block and forbidden inside one *)clause = "they" ( "can" ability ( "," ability )* | "cannot" ability ( "," ability )* ( "because" message )? ) ; (* inside an ability block the ability list is omitted entirely *)ability = IDENTIFIER | "*" ;message = STRING | NAMED_BINDING | POSITIONAL ;expr = or ;or = and ( "or" and )* ;and = not ( "and" not )* ;not = ( "not" | "!" ) not | primary ;primary = "(" expr ")" | can_ref | check_ref | condition ;condition = IDENTIFIER ( "(" ( arg ( "," arg )* )? ")" )? ;can_ref = "can" "(" IDENTIFIER ( "for" handle ( "with" with_map )? )? ")" ;check_ref = "check" "(" expr "for" handle ( "with" with_map )? ")" ;handle = IDENTIFIER ( "(" arg ")" )? ( "as" IDENTIFIER )? ;with_map = IDENTIFIER "=" arg ( "," IDENTIFIER "=" arg )* ;arg = STRING | INT | FLOAT | BOOL | NULL | NAMED_BINDING | POSITIONAL | CONTEXT_REF | COLUMN_REF | SQL_REF ;CONTEXT_REF = "@context" IDENTIFIER ;COLUMN_REF = "@column" IDENTIFIER [ "." IDENTIFIER ] ;SQL_REF = "@sql" ( STRING | NAMED_BINDING | POSITIONAL ) ;Syntax errors
Section titled “Syntax errors”Malformed syntax throws Warrant\DSL\Parsing\WarrantSyntaxException eagerly,
with the line, column, and a caret pointing at the offending token — debuggable
even when the whole rule set is one line:
Reserved word 'can' cannot be used as a name; expected an ability name. (line 1, column 21)
if is_self they can can ^Name validation (does this ability/condition actually exist on the schema?) happens later, at compile time, when a rule set is compiled against a schema — also a hard error. See Errors & exceptions for the catalogue.
