Skip to content
Laravel Warrant is in beta and still being tested — expect API changes between releases. Report an issue.

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.

A rule is an optional if <expression> followed by one or more they can / they cannot clauses:

if is_self
they can view, update
they cannot delete
  • if <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_locked
they cannot update because 'This document is locked.'

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 view
if 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 can at 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
}

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 cannot is an absolute veto. An unconditional they cannot delete means this user can never delete any row — no can rule can bring it back.
  • An ability with no can rule 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 view
if is_locked
they cannot update, delete

See Grants and denials for the full semantics.

The if expression is a boolean combination of conditions:

if is_self or is_manager
if is_self and not is_locked
if 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); not is the canonical spelling.
  • Parentheses group sub-expressions.

Each bare name (is_self, is_manager) is a condition declared on the schema.

From tightest to loosest binding: not / ! > and > or. Parentheses override. So:

if is_self or not is_manager and is_owner

parses as is_self OR ((NOT is_manager) AND is_owner). When in doubt, parenthesize.

* 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_admin
they can *
if is_suspended
they cannot *

they cannot * is the idiomatic kill switch — it vetoes every ability at once.

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.

Written directly in the rule. Supported types: string (single- or double-quoted), int, float, bool, null.

if in_team('sales', 'eng') they can view
if seen_recently(30, true) they can view

Strings 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.

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
);

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.

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, edit

A @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 view

Full behaviour — required vs. optional keys, and how a missing optional key fails closed — is covered in Check-time context.

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 view

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

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 open
if check(is_open for pay_periods(@column pay_period_id)) they can view

That 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 view

A 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 in
scope 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.

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 create

How 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.

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 view

At 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 approve
    if is_self
    they can view
    if is_manager
    they can approve
  • if starts a new rule. Every if begins a new rule; they can/cannot clauses attach to the most recent if above them. Clauses before any if form 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 literals true, false, and null. A name may contain or start with one, though: canonical, cannot_publish, is_and_something are 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.

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 ) ;

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.