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

Cross-schema checks

A rule normally asks questions of its own schema: if is_author they can update tests a condition the schema declares, about the row being checked. Sometimes the answer lives somewhere else — a timesheet is editable because its pay period is open, a document is visible because the user can view its folder.

The rule language has two builtins for that, and they are the only way a rule reaches across a schema boundary:

Builtin Asks the other schema for Consults the other schema’s rules?
can(<ability> for <handle>) permission — does this user hold that ability there? yes
check(<predicate> for <handle>) domain state — do those conditions hold there? no

Both are expressions: they sit anywhere a condition may sit inside an if, and combine with and, or, not, and parentheses like anything else.

# permission delegated to the folder
if can(view for folders(@column documents.folder_id)) they can view
# domain state delegated to the pay period
if check(is_open and not is_locked for pay_periods(@column timesheets.pay_period_id))
they can submit

Throughout this page A is the schema the rule belongs to, and B the schema being referenced.

can(<ability> for <handle>) is true when the current user holds <ability> on B. B’s rule set is resolved for that same user and compiled in place, so B’s whole policy — every can, every cannot, its conditions — decides the answer.

if can(approve for pay_periods(@context period_id)) they can submit

This is the tool for “they may do this here because they may do that there”. Because it re-enters another schema’s rules, it is also the one with a cycle guard (see Cycles and depth).

check(<predicate> for <handle>) evaluates a boolean expression whose leaves are B’s own conditions. It never looks at B’s rules and never asks about permission — it asks about the state of a row:

if check(is_open for pay_periods(@context period_id)) they can submit

The predicate is a full boolean expression, so and, or, not, parentheses, and condition arguments all work, resolved against B’s vocabulary:

if check((is_open or is_grace_period) and not is_locked for pay_periods(@context period_id))
they can submit

A predicate is read against B’s vocabulary, and it is a full expression, not just a list of conditions. It may nest another check(...), whose handle is read in B’s frame, and it may hold a can(...), which asks about one of B’s abilities:

if check(
is_open and check(is_active for tenants(@column tenant_id))
for pay_periods(@column pay_period_id)
) they can submit

That reads “this timesheet’s pay period is open, and that pay period’s tenant is active” — the inner @column tenant_id is resolved in the pay period’s frame, not the timesheet’s, which is what makes nesting worth having.

What a predicate may not hold is a constant: one that decides itself asks B nothing.

Both builtins take the same handle after for — a schema key, optionally followed by a row selector:

can(view for folders(@context folder_id)) # row-bound: one specific folder
can(access for billing) # unbound: the schema as a whole
  • Row-bound — schema(<row>) names one row of B. B is compiled against that row, so B’s row conditions have something to run against.
  • Unbound — the bare schema form asks B a question with no row at all, exactly like a no-target check. B’s row conditions have nothing to run against, so they are unanswerable — they neither grant nor lift a deny — which makes this form one for capability schemas and global conditions.

An unbound check(...) behaves the same way, and a predicate may freely mix the two kinds of condition — a row condition among them is simply unanswerable, while its siblings answer as usual:

if check(tenant_ok or is_open for pay_periods) they can view

tenant_ok is global and decides what it can; is_open reads a column and has no row here, so it contributes an unknown. Nothing is rejected, and you never have to remember which of a schema’s conditions are row conditions and which are global — that is the schema’s own business, and it is free to change.

A row-bound handle requires B to be model-backed. A capability schema has no table and no rows, so a row selector on one is rejected.

The value inside schema(<row>) identifies one row of B. It is bound into where <b_table>.<key> = ?, so it must be something the database can compare against a key: a scalar, B’s own model, a BackedEnum, a DateTimeInterface, or a @column / @sql reference (spliced as SQL rather than bound). See what a row selector may be for the full table and the reasoning.

The two references worth calling out here:

# correlate B against A's row — the outer table is in scope inside the subquery
if check(is_open for pay_periods(@column timesheets.pay_period_id)) they can view
# name the row at check time
if can(view for folders(@context folder_id)) they can view

An explicit null row selector — a null literal, or a :name binding that resolved to null — is rejected, not treated as “no row”:

A can(...) reference to schema [folders] specifies a row target that is null;
supply a row id or a @context reference, or drop the row selector.

That is deliberate. A $folder?->id that came back null should fail loudly rather than quietly widen a question about one row into a question about the schema.

A handle’s rows can be given a name:

if can(view for documents(@column parent_id) as parent) they can view

Two things follow from it.

In the emitted SQL that name becomes the subquery’s alias — from "documents" as "parent" — which is worth having when the same table appears more than once in one query, since the correlation is then readable rather than a numbered guess. You never have to supply one: two frames over one table always get distinct identifiers, and an unnamed one takes the table’s own name or, when that is already spoken for, the same name with a numeric suffix.

On a check(...) it is also a name the predicate can use. Naming the inner frame leaves the target’s schema key still meaning the enclosing one, which is the only way a predicate can compare two frames of the same table:

if check(owner_matches(@column documents.owner_id) for documents(@column parent_id) as parent)
they can view

An alias needs a row to name, so it is only valid on a row-bound handle.

Leave the for clause off and nothing is crossed. It asks about another ability of this schema, over the row this rule is already about:

for documents {
if is_owner they can read
if can(read) they can comment
if can(comment) they can share
}

Because there is no boundary, there is nothing to declare across one: the check-time context comes along unchanged, and the form takes no with map and no as. It also emits no subquery — the named ability’s predicate is compiled straight into the frame the reference sits in, so the whole chain above collapses to a single documents.owner_id = ?.

B never inherits A’s check-time context. Whatever bag the check was made with belongs to A; B is handed a fresh one, built only from an explicit with map:

if can(view for folders(@context folder_id) with tenant_id = @context tenant_id)
they can view

Each key names one of B’s context keys; each value is resolved in A’s frame — a literal, a binding, @context, @column, or @sql — and lands in B’s bag under that key. Duplicate keys in one map are a syntax error.

# A's `region` becomes B's `scope`; B's rules read @context scope
if check(in_scope(@context scope) for folders(@context folder_id) with scope = @context region)
they can view

Both builtins are ordinary leaves of the if expression:

if is_author
and check(is_open for pay_periods(@column timesheets.pay_period_id))
and not can(freeze for pay_periods(@context period_id))
they can submit

Negation follows the usual De Morgan rule and lands on the leaf, so a negated row-bound reference becomes not exists (...). The same happens under a cannot:

if not check(is_open for pay_periods(@context period_id))
they cannot submit because 'That pay period is closed.'

Reachability treats a rule containing either builtin like any other conditional rule — it is structural, so a can(...) never turns into ALWAYS or NEVER on its own.

A row-bound reference becomes an EXISTS over B’s table, keyed to the selector, with B’s compiled predicate inside it:

if can(view for folders(@context folder_id)) they can view
select * from "documents" where (
exists (
select * from "folders"
where "folders"."id" = 'f-1'
and (folders.owner = 'role-1')
)
)

Under negation the same subquery is emitted as not exists (...). Two references to the same schema on sibling branches produce two independent EXISTS clauses.

An unbound reference has nothing to correlate, so B’s boolean tree is spliced inline into A’s predicate rather than wrapped in a subquery — which lets a B that decides outright (a global condition returning a bool) fold into A’s predicate instead of stopping at a literal.

When the row selector is a hydrated model of B rather than a key, the EXISTS can disappear altogether. The subquery only ever asked two things — does that row exist, and does B allow it — and a hydrated model has already settled the first. B’s row conditions receive it as $c->model and may answer in PHP; if the whole of B folds to a constant, the reference becomes that constant and no subquery is built:

if can(view for folders(@context folder)) they can view
$user->warrant()->can('view', $document, ['folder' => $folder]);

A key alone cannot do this — whether the row exists is exactly what a key has not established. See How it compiles.

Row selectors and with values are usually runtime values, which is where the rule builder tends to read better than a string:

use Warrant\Builders\Ref;
use Warrant\Rules\WarrantRule;
WarrantRule::build()
->if('is_author')
->orIfCan('approve', PayPeriod::class, Ref::context('period_id'))
->andIfCheck(
fn ($p) => $p->if('is_open')->andIfNot('is_locked'),
'pay_periods',
Ref::column('pay_period_id'),
)
->theyCan('submit')
->toRule();

The two things to remember: omitting $row gives the unbound handle (its default is a NoRow sentinel, not null), and there are no negated variants — negate with a group, ->ifNot(fn ($c) => $c->ifCan(...)). Full signatures are in the rule-building API.

Because can(...) compiles B’s rules, and B’s rules may reference C, a chain can close on itself. The compiler tracks the (schema, ability) frames on the current path and throws when one repeats:

Cross-schema can(...) cycle detected: timesheets:create → pay_periods:approve →
timesheets:create. A can(...) reference must not, directly or transitively, depend
on the ability being compiled.

The guard is path-scoped, so two sibling references to the same schema are fine — only re-entering a frame already on the path is a cycle. Nesting is also capped at a depth of 32.

A check(...) dispatch touches no rules of its own, so the dispatch itself cannot close a loop. A can(...) inside its predicate can, and is guarded by the same (schema, ability) frames — the check adds a layer to the depth budget either way.

Validation-time (InvalidArgumentException, when the rule set is validated or compiled against its schema):

  • The target schema must be registered, and for can(...) it must declare the ability. A reference may target its own schema.
  • A row-bound handle needs a model-backed target; a capability schema cannot be row-targeted.
  • A row selector that resolves to a literal null is rejected. One that resolves to nothing at check time — an absent @context — is unanswerable rather than rejected; see How it compiles.
  • An as <alias> needs a row to name; an unbound handle selects none.
  • A check(...) predicate is read against the target’s vocabulary, and may hold that schema’s conditions, a can(...), and a nested check(...). It may not hold a constant, and on an unbound handle it may not hold a row condition.
  • A can(<ability>) with no for clause takes no with map and no as — it crosses nothing, so there is nothing to hand over or to name.
  • A @column reference must name a table in scope where it is written.

Compile-time (InvalidArgumentException / RuntimeException):

  • A row selector that is a model of the wrong schema, or any other object with no meaning as a row key, is rejected rather than silently matching nothing — see Cross-schema row selectors.
  • B must be on the same database connection as the query being filtered; a subquery cannot reach across connections.
  • A cycle, or nesting deeper than 32.
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 is the same argument production the rule language uses everywhere else — literals, :name / ? bindings, @context, @column, and @sql.