Rule templates
A rule template is a named, reusable body of rules declared on the schema and
expanded into a rule set by an @include. The abilities it applies to come from
the reference, not the template, so one template serves as many abilities as you
point it at.
What templates are for
Section titled “What templates are for”A condition already names a reusable predicate, and a condition may answer with an expression instead of SQL, so predicates compose without templates:
#[RowCondition]public function needsApproval(RowConditionContext $c){ return Warrant::condition('is_submitted and not is_approved');}What a condition cannot carry is the denial and its message, because a
message is only meaningful attached to a cannot. So this repeats, once per
ability, with nothing to name it:
if not is_approved they cannot view because 'This needs approval first.'if not is_approved they cannot edit because 'This needs approval first.'A template names that whole shape once.
Declaring a template
Section titled “Declaring a template”Mark a public method #[RuleTemplate]. It answers with the body as rule text:
use Warrant\Schema\RuleTemplate;
#[RuleTemplate]public function requiresApproval(): string{ return "if not is_approved they cannot because 'This needs approval first.'";}The name a rule uses is the method name snake-cased — requiresApproval →
requires_approval — exactly as for a condition. Override it by passing a key:
#[RuleTemplate('approval')]public function requiresApproval(): string { /* ... */ }Read what a schema declares with ruleTemplateKeys().
The body is headless
Section titled “The body is headless”Clauses in a template body name no abilities — they take the ones the @include
supplies:
if not is_approved they cannot because 'This needs approval first.'Three things follow from that, and all three are rejected:
- a clause naming its own abilities (
they can view); - an ability block, which would be a second answer to a question the reference settled;
- a
for <schema>header — a template belongs to the schema that declares it.
A body may hold several rules, and may include other templates.
Expanding a template
Section titled “Expanding a template”Inside an ability block, the header already names the abilities:
can they view, edit { @include requires_approval}Anywhere else, the include names them itself:
@include requires_approval for view, editThe for list is required outside a block and rejected inside one, for the same
reason a clause inside a block may not name abilities: the header is the single
place the ability is said.
Either way the result is the rules the longhand would have produced, in the
place the @include was written:
if is_owner they can view@include requires_approval for viewif is_admin they can viewis the same rule set as:
if is_owner they can viewif not is_approved they cannot view because 'This needs approval first.'if is_admin they can viewArguments
Section titled “Arguments”A template may take parameters. Pass them at the reference, as you would to a
condition — literals, :name / ? bindings, @context and @column all work:
@include inherited_from(@column parent_id) for viewGive the values back to the body through bindings, not by writing them into
the string. Warrant::ruleTemplate() pairs the text with them:
#[RuleTemplate]public function inheritedFrom(string $relation): WarrantRuleTemplate{ return Warrant::ruleTemplate( 'if is_child_of(:relation) they cannot because :why', [ 'relation' => $relation, 'why' => fn (WarrantDenialContext $c) => "No access through {$relation}.", ], );}Two reasons bindings rather than interpolation. Writing a value into the text is unsafe — a quote inside a string literal ends it early — and a closure denial message has no inline form at all, so a binding is the only way it reaches the DSL.
A body’s placeholders are its own: every parse gets its own binding state, so a
:named body expands cleanly inside a rule set parsed with positional ? ones.
Recursion
Section titled “Recursion”A template may include itself. Because the arguments may differ at each level, this is bounded by depth rather than by rejecting a repeated name — a template that recurs with an argument that decreases per level terminates, and rejecting on the name alone would ban exactly those:
#[RuleTemplate]public function ancestor(int $depth): string|WarrantRuleTemplate{ return $depth <= 0 ? 'they can' : Warrant::ruleTemplate('@include ancestor(:next)', ['next' => $depth - 1]);}The base case has to be a PHP one, as above: the DSL has no conditional, so a body cannot decide for itself when to stop.
A recursion that never ends is caught and reports the chain of templates. During
a compile it is bounded by the same budget as every other descent, so the error
also names the ability and check(...) hops that led there — see
How it compiles.
What sees through a template
Section titled “What sees through a template”| Compiling a check or filtering a query | Expands — a template’s rules decide access like any other |
| Reachability | Expands — an ability granted only by a template is still reachable |
| Denial messages | Expands — a template’s because surfaces like any other |
validate() |
Checks the template name, its arity and the abilities named; does not read the body |
toSyntax() |
Renders the @include back out rather than what it expands to |
Validation stops at the body deliberately. Reading one means calling the method
with concrete arguments, and an argument may be a @context reference whose
value arrives per check — so a mistake inside a body is reported when it is
expanded, in the same way a mistake inside a condition’s derived expression is
reported by the compiler.
Errors
Section titled “Errors”| Written | Reported |
|---|---|
@include nope for view |
Schema [...] declares no rule template [nope] |
| Too few arguments | Rule template [...] requires N argument(s), but the @include supplies M |
@include x for not_an_ability |
Ability [not_an_ability] is not declared by the schema |
@include x outside a block |
An @include outside an ability block must name the abilities it applies to |
can they view { @include x for edit } |
An @include inside an ability block may not name abilities |
| A body that never stops including | ... exceeded the maximum nesting depth — worded for the expansion on its own, or for the whole compile when one is under way |
The first three are reported by validate() from rule text alone, so CI catches
them without a user, a row or a query.
