Providing rules
Rules are data. Warrant never invents them — it asks your resolver for them at request time. This is the seam where your access-control model meets Warrant.
The RuleResolver interface
Section titled “The RuleResolver interface”Implement one method. Given a context, return the WarrantRuleSet that governs
this user’s access to that resource:
use Warrant\Rules\RuleResolutionContext;use Warrant\Rules\RuleResolver;use Warrant\Rules\WarrantRuleSet;
class DatabaseRuleResolver implements RuleResolver{ public function resolve(RuleResolutionContext $context): WarrantRuleSet { // $context->user — the Authenticatable being checked (nullable) // $context->schemaKey — e.g. 'documents' // $context->schema — the schema class string // $context->model — the model class string, or null (schema with no model)
$grants = DB::table('role_permissions') ->where('role_id', $context->user->role_id) ->where('resource', $context->schemaKey) ->pluck('rule'); // ['if is_self they can view', ...]
return WarrantRuleSet::fromSyntax( $grants->implode("\n"), // rules concatenate freely $context->schemaKey, ); }}Store rule strings in a table, compose them from role flags, read them from JWT
claims — whatever fits. Warrant only cares that you return a WarrantRuleSet.
Building a rule set
Section titled “Building a rule set”Three ways to construct a WarrantRuleSet. The first argument is always the
schema (a model instance, a schema instance, or a schema/model class string, or a
plain schema-key string):
From syntax
Section titled “From syntax”Parse a string, resolving bindings inline:
WarrantRuleSet::fromSyntax('if is_self they can view', 'documents', $bindings = []);Warrant::ruleSet() is the same call from the facade, and it is the one to reach
for when the rules are stored as text, because it lets the schema live in the
string’s own for header:
Warrant::ruleSet('for documents { if is_self they can view }', bindings: $bindings);A header travels with the string, so editor tooling reading your source knows which schema to check the condition and ability names against. Passing the schema as a PHP argument instead leaves the string unchecked — still valid, just unverifiable from the outside.
From already-parsed rules
Section titled “From already-parsed rules”Build individual WarrantRules and compose them. fromRules takes a variadic
list or a single array (it flattens a mix of both), accepts builders directly,
and takes no bindings (the rules are already resolved):
use Warrant\Rules\WarrantRule;
$own = WarrantRule::fromSyntax('if is_self they can view, update');$noDelete = WarrantRule::fromSyntax('they cannot delete');
WarrantRuleSet::fromRules('documents', $own, $noDelete);WarrantRuleSet::fromRules('documents', [$own, $noDelete]); // equivalentWith a build callback
Section titled “With a build callback”WarrantRuleSet::build hands you a factory; each $rule() call appends a builder:
WarrantRuleSet::build('documents', function ($rule) { $rule()->if('is_self')->theyCan('view', 'update'); $rule()->theyCannot('delete');});Directly with the parser
Section titled “Directly with the parser”If you want the parsed rules without a rule set:
use Warrant\DSL\Parsing\WarrantParser;
$rules = WarrantParser::parse('if is_self they can view', $bindings = []); // WarrantRule[]$one = WarrantParser::parseSingleRule('they cannot delete'); // WarrantRuleBuilding rules programmatically
Section titled “Building rules programmatically”When a rule’s shape depends on runtime data — a list of team ids, a feature
flag, values that don’t belong in a string — the fluent builder is often clearer
than assembling DSL text. WarrantRule::build() produces the same AST the
parser does, and nothing is serialized to a string, so arbitrary PHP values in
condition parameters survive untouched:
use Warrant\Rules\WarrantRule;
$rule = WarrantRule::build() ->if('is_self') ->orIf(fn ($c) => $c->if('is_manager')->andIf('in_region')) ->theyCan('view', 'update') ->toRule();The builder is its own topic — connectives, parenthesized groups, dynamic composition, and splicing in DSL text are all covered in The rule builder.
Implicit rules
Section titled “Implicit rules”A schema can declare rules always merged into the rule set, regardless of
what the resolver returns, by overriding implicitRules(). They’re added to
every resolved rule set before compilation, so they’re validated and combine
exactly like resolver rules — and, like every rule, they’re still
evaluated against the current user via their conditions:
use Warrant\Rules\WarrantRule;
class DocumentSchema extends WarrantSchema{ protected function implicitRules(): array|WarrantRuleSet { return [ WarrantRule::fromSyntax('if is_admin they can *'), WarrantRule::fromSyntax('if is_suspended they cannot *'), ]; }}You may return either a plain list of rules (above) or a fully-formed
WarrantRuleSet for this schema — whichever your baseline logic produces most
naturally. A returned rule set must target this schema.
Because rule order never matters, an implicit cannot beats any
resolver-supplied can — ideal for baseline guarantees like an admin escape
hatch or a suspension lockout.
Registering the resolver
Section titled “Registering the resolver”Warrant ships no default resolver. Configure one in config/warrant.php,
plus the list of schemas:
return [ 'rule_resolver' => App\Warrant\DatabaseRuleResolver::class,
'schemas' => [ 'documents' => App\Warrant\DocumentSchema::class, 'projects' => App\Warrant\ProjectSchema::class, ],];Resolution lifetime
Section titled “Resolution lifetime”Your resolver is not called once per check. Warrant memoizes a guard per user
for the life of the request, and each guard memoizes the rule set it resolved, so
resolve() runs at most once per (user, schema) — no matter how many checks
follow:
Warrant::can('view', $documentA); // resolve() runsWarrant::can('update', $documentB); // memoized — no resolver callWarrant::abilities($documentC); // memoizedThe rule set is also validated once, not once per check. This matters most on
list endpoints and Blade loops, where a @can inside a @foreach would otherwise
hit your rule store once per row.
When the memo is dropped
Section titled “When the memo is dropped”Automatically, wherever a process moves on to unrelated work:
| Runtime | Dropped when |
|---|---|
| PHP-FPM | The process ends — the memo never outlives one request. |
| Octane | RequestTerminated, TaskTerminated |
| Queue workers | JobProcessed, JobFailed |
Without this, a long-lived worker would keep answering from rules resolved for an earlier request, long after a role change should have taken effect.
Flushing manually
Section titled “Flushing manually”The memo is keyed by user identity, not by rule content, so it cannot notice that you changed someone’s permissions mid-request. Flush after a write that must take effect immediately:
$user->roles()->attach($editorRole);
Warrant::flush($user); // just this userWarrant::flush(); // every userWarrant::flush($user) matches on the auth identifier, so any instance of that
user works — you don’t need the object you ran the original check with.
If your resolver reads from a store that changes rarely, this in-request memoization may be all the caching you need. Anything longer-lived — surviving across requests — belongs in your resolver, where you control invalidation.
