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

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.

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.

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

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.

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]); // equivalent

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

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'); // WarrantRule

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.

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.

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,
],
];

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() runs
Warrant::can('update', $documentB); // memoized — no resolver call
Warrant::abilities($documentC); // memoized

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

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.

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 user
Warrant::flush(); // every user

Warrant::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.