patrickhanna/laravel-warrant

Schema-based authorization and permissions for Laravel, with database-scoped ability checks.

Maintainers

Package info

github.com/patrickjames242/laravel-warrant

pkg:composer/patrickhanna/laravel-warrant

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v3.0.0 2026-08-12 23:20 UTC

This package is auto-updated.

Last update: 2026-08-13 00:36:08 UTC


README

Schema-based authorization for Laravel that compiles a small, human-readable rule language directly into SQL — so "what can this user do?" and "which rows can this user touch?" are answered by the database in a single query, not by loading records and looping in PHP.

if is_self or (is_manager and same_department)
they can view, update
they cannot delete

That block is a real, complete Warrant rule. Warrant turns it into a WHERE clause. This README explains the problem Warrant solves, the language above in full detail, and exactly how you hand rules to the library.

Table of contents

The problem

Say the rule is: a user can update a timesheet if it's their own, or if it belongs to a specific department (say, sales) — but never once it's locked (unless they're an admin).

With a Laravel Policy you write that once, for a single object:

class TimesheetPolicy
{
    public function update(User $user, Timesheet $timesheet): bool
    {
        if ($user->is_admin) {
            return true;
        }

        if ($timesheet->locked) {
            return false;
        }

        return $timesheet->user_id === $user->id
            || $timesheet->department_id === 'sales';
    }
}

That works for $user->can('update', $timesheet). Now watch what happens with the two questions every real screen actually asks.

"Which timesheets can this user update?" The policy can't answer it — it needs an object. So you either fetch everything and filter in PHP:

// Loads the whole table into memory. Pagination is now impossible.
$editable = Timesheet::all()->filter(fn ($t) => $user->can('update', $t));

…or you re-implement the policy a second time as a query scope:

Timesheet::query()
    ->when(! $user->is_admin, function ($q) use ($user) {
        $q->where('locked', false)
          ->where(fn ($q) => $q
              ->where('user_id', $user->id)
              ->orWhere('department_id', 'sales')); // 'sales' hardcoded a second time
    })
    ->paginate();

Now the same rule lives in two places, in two different shapes, and they will drift the first time someone edits one and forgets the other.

"What can this user do with each row on this page?" Your table has view / update / delete / approve buttons. So, per page:

$rows = $timesheets->map(fn ($t) => [
    'timesheet' => $t,
    'can' => [
        'view'    => $user->can('view', $t),
        'update'  => $user->can('update', $t),
        'delete'  => $user->can('delete', $t),
        'approve' => $user->can('approve', $t),
    ],
]);
// 50 rows × 4 abilities = 200 policy evaluations, each possibly hitting the DB.

A flat permission package (e.g. spatie/laravel-permission) doesn't help here — its permissions are global strings:

$user->givePermissionTo('update timesheets');
$user->can('update timesheets'); // true or false, for ALL timesheets

There's no room in 'update timesheets' for "their own", "in the sales department", or "unless locked." The moment a permission is conditional on the record, you're back to writing a Policy — and back to both problems above.

And in every one of these, the rule is code. Want managers to approve timesheets in their department? That's a deploy. You can't store it per role or per tenant, can't let an admin screen define it, can't audit it as data.

The same thing in Warrant

Write the rule once, as data:

if is_self or manages_department('sales') they can update
if is_locked and not is_admin they cannot update
if is_admin they can *

(Note how the "unless they're an admin" exception is just and not is_admin on the deny — the whole rule is right there, readable, in three lines. And manages_department('sales') shows a condition taking a parameter: the same condition serves every department, and the rule says which one.)

Those condition names aren't magic strings — you define, once, how each one resolves. A schema declares the vocabulary and teaches every condition how to emit SQL:

namespace App\Warrant;

use App\Models\Timesheet;
use Illuminate\Contracts\Database\Query\Builder;
use Warrant\Ability;
use Warrant\GlobalCondition;
use Warrant\Schema\Conditions\GlobalConditionContext;
use Warrant\Schema\Conditions\TargetedConditionContext;
use Warrant\Schema\WarrantSchema;
use Warrant\TargetedCondition;

class TimesheetSchema extends WarrantSchema
{
    public const model = Timesheet::class;

    #[Ability] public const VIEW   = 'view';
    #[Ability] public const UPDATE = 'update';

    // Targeted: narrows WHICH timesheet rows the user matches.
    #[TargetedCondition]
    public function isSelf(TargetedConditionContext $c): Builder
    {
        return $c->query->whereRaw('timesheets.user_id = ?', [$c->user->getAuthIdentifier()]);
    }

    // The rule's argument arrives on $c->arguments — here the department the
    // rule named, e.g. manages_department('sales'). One condition, every
    // department; the rule assigned to the user picks which.
    #[TargetedCondition]
    public function managesDepartment(TargetedConditionContext $c): Builder
    {
        [$department] = $c->arguments;

        return $c->query->where('timesheets.department_id', $department);
    }

    #[TargetedCondition]
    public function isLocked(TargetedConditionContext $c): Builder
    {
        return $c->query->where('timesheets.locked', true);
    }

    // Global: a plain yes/no about the user, independent of any row.
    #[GlobalCondition]
    public function isAdmin(GlobalConditionContext $c): bool
    {
        return (bool) $c->user->is_admin;
    }
}

Each condition is written once, in PHP, and every rule that names it — in any tenant, role, or admin-defined policy — reuses that same SQL. The rules stay data; the schema is the only code.

Warrant compiles it to SQL, so one rule set answers all three questions — consistently, because there's only one source of truth:

// "Which can they update?"  -> a WHERE clause, paginates fine
Timesheet::query()->hasAbility('update')->paginate();

// "What can they do to each row?"  -> one computed column, one query
Timesheet::query()->selectAbilities()->get();   // each row ->abilities = ['view','update']

// "Can they update this one?"  -> a scoped EXISTS
$timesheet->hasAbility('update');

And notice what's missing: Warrant never told you where those rules live. Unlike laravel-permission or Bouncer — which own a set of tables and expect permissions to be stored their way — Warrant doesn't store anything. It doesn't care whether you keep rules in a database, generate them on the fly from a settings screen, read them off a JWT claim, or hardcode them for a plan tier. The only thing Warrant asks is that a resolver hand back the rules for the current request:

class DatabaseRuleResolver implements RuleResolver
{
    public function resolve(RuleResolutionContext $context): WarrantRuleSet
    {
        if ($context->schemaKey === 'timesheets'){
            // However you want to produce the rules — this is entirely yours:
            if ($context->user->is_manager) {
                return WarrantRuleSet::fromSyntax(
                    $context->schemaKey,
                    'if is_self or manages_department(:dept) they can update
                     if is_locked and not is_admin they cannot update
                     if is_admin they can *',
                    bindings: ['dept' => $context->user->department],
                );
            } else {
                return WarrantRuleSet::fromSyntax(
                    $context->schemaKey,
                    "if is_self they can update",
                );
            }
        }   
        // ...
    }
}

Fetch it from a table, build it from config, compose it per tenant — Warrant picks up wherever your resolver leaves off and compiles the result to SQL.

You point Warrant at your resolver and register your schemas in config/warrant.php:

'rule_resolver' => App\Warrant\DatabaseRuleResolver::class,
'schemas'       => [App\Warrant\TimesheetSchema::class],

The rest of this README is how that works.

How Warrant thinks about authorization

Warrant splits authorization into three separate things. Keeping them separate is the whole idea:

Piece What it is Who writes it
Schema The vocabulary for one resource: the abilities that exist (view, approve, …) and the conditions a rule may test (is_self, is_manager, …). Conditions know how to emit SQL. You, in a PHP class
Rules The policy itself, written in Warrant's rule language as a plain string (e.g. if is_self they can view). Rules reference the schema's vocabulary. Stored as data — a DB table, config, JWT claims, wherever
Resolver The glue that, at request time, produces the rules that apply to this user for this resource. You, one small class

A schema is not a policy. It doesn't decide anything — it only declares what words the language may use. The actual decisions live in the rules, which your resolver supplies. Warrant compiles those rules, validated against the schema, into SQL.

       your data (roles, grants)                    request-time
                │                                         │
                ▼                                         ▼
        RuleResolver ──▶ WarrantRuleSet ──▶ RuleSetCompiler ──▶ SQL WHERE / column
                                     ▲                    │
                                     │                    │ validated against
                              WarrantSchema ───────────────┘
                          (abilities + conditions)

Installation

composer require patrickhanna/laravel-warrant

The service provider auto-registers. Publish the config if you want to edit it in place:

php artisan vendor:publish --tag=warrant-config

Requirements: PHP 8.2+, Laravel 11 or 12. Supported drivers for the SQL Warrant generates: PostgreSQL, MySQL/MariaDB, and SQLite.

A complete example

The four pieces end-to-end. Read the rest of the README for the detail behind each.

1. The schema — declares the vocabulary for timesheets:

namespace App\Warrant;

use App\Models\Timesheet;
use Illuminate\Contracts\Database\Query\Builder;
use Warrant\Ability;
use Warrant\GlobalCondition;
use Warrant\Schema\Conditions\GlobalConditionContext;
use Warrant\Schema\Conditions\TargetedConditionContext;
use Warrant\Schema\WarrantSchema;
use Warrant\TargetedCondition;

class TimesheetSchema extends WarrantSchema
{
    public const model = Timesheet::class;

    #[Ability] public const VIEW    = 'view';
    #[Ability] public const UPDATE  = 'update';
    #[Ability] public const DELETE  = 'delete';
    #[Ability] public const APPROVE = 'approve';

    // Targeted: narrows WHICH timesheet rows the user matches.
    #[TargetedCondition]
    public function isSelf(TargetedConditionContext $c): Builder
    {
        return $c->query->whereRaw('timesheets.user_id = ?', [$c->user->getAuthIdentifier()]);
    }

    #[TargetedCondition]
    public function inDepartment(TargetedConditionContext $c): Builder
    {
        return $c->query->whereIn('timesheets.department_id', $c->arguments);
    }

    // No-target: a plain yes/no about the user, independent of any row.
    #[GlobalCondition]
    public function isAdmin(GlobalConditionContext $c): bool
    {
        return (bool) $c->user->is_admin;
    }
}

2. The rules — as data. Here inline; in practice from your DB:

if is_self they can view, update, delete
if in_department(?, ?) they can view, approve
if is_admin they can *

3. The resolver — hands those rules to Warrant for the current user:

namespace App\Warrant;

use Warrant\RuleResolutionContext;
use Warrant\RuleResolver;
use Warrant\RuleSyntaxTree\WarrantRuleSet;

class DatabaseRuleResolver implements RuleResolver
{
    public function resolve(RuleResolutionContext $context): WarrantRuleSet
    {
        // Look up the raw rule string + any binding values for this user/resource.
        [$syntax, $bindings] = MyRuleStore::for(
            user: $context->user,
            resource: $context->schemaKey, // 'timesheets'
        );

        return WarrantRuleSet::fromSyntax($context->schemaKey, $syntax, $bindings);
    }
}

4. Wire it up (config/warrant.php) and use it:

'rule_resolver' => App\Warrant\DatabaseRuleResolver::class,
'schemas' => [App\Warrant\TimesheetSchema::class],
// Which timesheets can the current user update? (one SQL query)
$editable = Timesheet::query()->hasAbility('update')->get();

// Can this user approve this specific timesheet?
if (Timesheet::userHasAbilities('approve', $timesheet)) { /* ... */ }

// Render buttons: attach the per-row ability list.
$rows = Timesheet::query()->selectAbilities()->get();
$rows->first()->abilities; // e.g. ['view', 'update']

Schemas: the vocabulary of a resource

A schema is an abstract class WarrantSchema subclass, one per resource. It declares two things: the abilities that exist, and the conditions a rule may test. It is registered against a model via the model constant.

class TimesheetSchema extends WarrantSchema
{
    public const model = Timesheet::class;   // the Eloquent model this governs
    // public const schemaKey = 'timesheets'; // optional override
}

The schema key (the resource's identifier in rules and lookups) is derived from the model's table name by default (timesheets). Override it with the schemaKey constant. A schema may also have no model (public const model = '') — a "capability" schema for things like settings that only answer no-target checks (see capability checks).

Abilities

Abilities are the verbs a rule can grant or deny. Declare each as a class constant marked #[Ability]. The constant's value is the ability name used in rules; the constant's name is irrelevant to Warrant.

#[Ability] public const VIEW    = 'view';
#[Ability] public const APPROVE = 'approve';
TimesheetSchema::declaredAbilities(); // ['view', 'approve', ...]

A rule that names an ability the schema doesn't declare is rejected at compile time (see validation). Warrant ships a Warrant\StandardAbilities helper with common names (VIEW, CREATE, UPDATE, DELETE, ARCHIVE) if you want a shared vocabulary.

Conditions

Conditions are the predicates a rule may test in its if. Each is a public method marked with #[TargetedCondition] or #[GlobalCondition]. The condition name used in rules is derived from the method name by snake-casing it (isSelfis_self). You can override it: #[TargetedCondition('is_owner')].

Every condition method takes a single context object and returns Builder (mutated) or, for a global condition, a bool. The context carries the current user, the query builder, the DSL arguments, and — for targeted conditions — the targetSqlId.

A condition's one job is to emit SQL. There is no in-memory evaluation path — even a single-object check runs as a scoped query. This keeps a condition's behavior identical whether you're filtering a list or checking one row.

Targeted vs. global conditions

The distinction is: does this predicate talk about a specific row?

  • #[TargetedCondition] — the predicate constrains which rows match. Its context is a TargetedConditionContext carrying targetSqlId, the qualified primary-key SQL id of the entity (timesheets.id). Mutate $c->query to add the WHERE fragment:

    #[TargetedCondition]
    public function isSelf(TargetedConditionContext $c): Builder
    {
        // $c->targetSqlId === "timesheets.id" (the correlated row under test)
        return $c->query->whereRaw('timesheets.user_id = ?', [$c->user->getAuthIdentifier()]);
    }

    Your predicate may reference any column of the entity's table; it is evaluated correlated to the row under test.

  • #[GlobalCondition] — the predicate is about the user or the world, not a row (e.g. "is this user an admin?", "is this tenant on the pro plan?"). Its context is a GlobalConditionContext (no targetSqlId). It may mutate $c->query like a targeted condition, or simply return a bool:

    #[GlobalCondition]
    public function isAdmin(GlobalConditionContext $c): bool
    {
        return (bool) $c->user->is_admin;   // true = holds for this user, false = doesn't
    }

Why the split matters: some checks (capability checks and getAbilitiesWithoutTarget) run with no row. In that context a targeted condition can't be evaluated, so Warrant treats it as false (and therefore not <targeted> as true). Global conditions still evaluate normally.

Values are always bound. Whatever you pass into whereRaw, whereIn, etc. becomes a bound parameter. Never interpolate a value into the SQL string — conditions run against user- and rule-supplied data.

Conditions with arguments

A condition can take arguments from the rule (in_department('sales')). The resolved arguments arrive on the context as $c->arguments, in order:

#[TargetedCondition]
public function inDepartment(TargetedConditionContext $c): Builder
{
    // in_department('sales', 'eng')  ->  $c->arguments === ['sales', 'eng']
    return $c->query->whereIn('timesheets.department_id', $c->arguments);
}

A condition that ignores arguments simply never reads $c->arguments.

Context keys

Some values a rule needs aren't known when the schema is written or when the resolver builds the rules — they're known only at the moment of the check: the current tenant, an academic year, an as-of date, an impersonated user. These are context keys. Declare each with #[ContextKey], mirroring #[Ability] — the constant's value is the key string; its name is irrelevant to Warrant:

// Required by default: no check on this resource resolves without the frame.
#[ContextKey] public const WORKSPACE = 'workspace_id';

// Opt out for a frame that only gates grants.
#[ContextKey(required: false)] public const AS_OF = 'as_of_date';

A rule references a context key with @context <key> (see Check-time context); the caller supplies the value in a context: array at check time (see Passing context to a check). The value arrives in the condition exactly like any other argument, on $c->arguments:

#[TargetedCondition]
public function inWorkspace(TargetedConditionContext $c): Builder
{
    [$workspace] = $c->arguments;   // supplied at the check via @context workspace_id
    return $c->query->where('documents.workspace_id', $workspace);
}

Every condition also receives the full effective context on $c->context, whether or not the rule passed a value via @context. Reach into it directly when a condition is inherently tied to the frame — then the rule needn't mention the key at all:

#[TargetedCondition]
public function inCurrentWorkspace(TargetedConditionContext $c): Builder
{
    // Rule is just `if in_current_workspace they can view` — no @context needed.
    return $c->query->where('documents.workspace_id', $c->context['workspace_id']);
}

Two styles, same value. @context threads a key into $c->arguments positionally (and soft-falses the condition when an optional key is missing); $c->context hands every condition the whole bag to read however it likes. Pick whichever makes your rules read the way you want.

Keys are required by default: any check on the schema throws unless the key is present in the effective context. Opt out with #[ContextKey(required: false)] only for a frame that never gates a cannot — a missing optional key silently lifts a deny (see Check-time context), and required-ness is what forecloses that. When in doubt, leave it required.

defaultContext() supplies defaults so callers may omit a key — and so param-less paths (route middleware, the selectAbilities global scope) get a frame with no context: argument. Explicit context passed at the check wins over defaults:

protected function defaultContext(): array
{
    return ['workspace_id' => app('tenant')->id];
}

The rule language

This is the heart of Warrant. Rules are 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 for this request. A rule set never describes what everyone can do; it describes what this user can do with the resource it's scoped to. So they can approve means "this user may approve every row of this resource," not "approval is open to all users."

Anatomy of a rule

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 only apply 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 have any mix of can and cannot clauses.

can and cannot

Warrant combines grants and denials with deny-overrides. For a given ability, the compiled predicate is:

( any `can` rule for it matches )  AND  ( no `cannot` rule for it matches )

Concretely:

  • A cannot is an absolute veto. they cannot delete compiles to "and not the delete rule's condition." An unconditional they cannot delete means this user can never delete any row, full stop — no can rule can bring it back.
  • An ability with no can rule is denied. Silence is not permission.
  • An unconditional they can view grants this user view of every row.
they can view                 # this user can view every row
if is_locked
they cannot update, delete    # ...but this user can never update or delete a
                              #    locked row, even if another rule grants update

Rule order does not matter — the deny-overrides combination is commutative.

Conditions and boolean logic

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_department('sales') or in_department('eng'))
  • and, or — binary operators.
  • not — negation. ! is an accepted synonym (!is_lockednot 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.

Operator precedence

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. (&& and || are not supported — use and / or.)

Wildcards

* stands for every ability the schema declares, on both sides:

if is_admin
they can *              # this user gets every ability (when they're an admin)

if is_suspended
they cannot *           # this user loses every ability (a lockout that wins)

they cannot * combined with deny-overrides is the idiomatic "kill switch."

Passing arguments to conditions

A condition can take arguments in three ways here — inline literals, named bindings, and positional bindings — all resolved before compilation. A fourth source, check-time context, is resolved later, when the check runs.

Inline literals are written directly in the rule. Supported literal types: string (single-quoted), int, float, bool, null.

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

Strings use single quotes; escape a quote or backslash with \' and \\. Lists and other complex values cannot be written inline — pass them via a binding.

Named bindings (:name) are 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 the array order is irrelevant.

WarrantRuleSet::fromSyntax('timesheets', <<<'RULES'
    if is_specific_user(:uid) they can view
    if delegated_to(:uid) they can approve
    RULES,
    ['uid' => $currentUserId],   // one value, used twice
);

Positional bindings (?) are filled left-to-right across the entire string from a flat array:

WarrantRuleSet::fromSyntax('timesheets',
    'if in_department(?, ?) they can view',
    ['sales', 'eng'],            // ? ? -> 'sales', 'eng'
);

Rules for bindings — all 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 in $parameters.
  • 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.

Check-time context (@context)

The three sources above are all resolved before a rule is compiled — literals when the rule is authored, bindings when the resolver builds it. Some values are known only when the check runs: the current tenant, an academic year, an as-of date. Warrant reaches these with a fourth argument form, @context <key>, that 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

The key must be declared on the schema with #[ContextKey] — an undeclared @context reference is a compile-time error, exactly like an unknown condition name. 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. It may sit alongside literals and bindings in one condition, and never consumes a positional ?:

if scoped_to('projects', @context project_id, :region) they can view

Because the value is pinned once per check, it behaves as an ordinary bound SQL parameter — per-row selectAbilities stays a flat list and deny-overrides is unaffected.

When the key is absent at check time:

  • If the key is required (the default), the check throws before compiling — for every check on the schema (see Context keys).
  • If it was declared required: false, the referencing condition is treated as false — the same rule Warrant applies to a targeted condition in a no-target check. That is safe on a grant (no key, no grant), but on a cannot it makes the veto lift (fail-open), which is why only a grant-only frame should be opted out of required.

@context is one of two ways a condition gets a context value; the other is reading the ambient $c->context bag directly (see Context keys), which every condition receives regardless of what the rule passes. The distinguishing behavior of @context is the automatic soft-false above — a condition that reads $c->context itself always runs and decides for itself.

Supplying the values is covered under Passing context to a check.

Whitespace, multiple rules, and 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 wordsif, they, can, cannot, and, or, not — cannot be used as an exact condition or ability name. 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_-]*: they start with a letter or underscore and may contain letters, digits, underscores, and dashes. No dots.

Formal grammar

ruleset   = clause* ( "if" expr clause+ )* ;
clause    = "they" ( "can" | "cannot" ) ability ( "," ability )* ;
ability   = IDENTIFIER | "*" ;
expr      = or ;
or        = and ( "or" and )* ;
and       = not ( "and" not )* ;
not       = ( "not" | "!" ) not | primary ;
primary   = "(" expr ")" | condition ;
condition = IDENTIFIER ( "(" ( arg ( "," arg )* )? ")" )? ;
arg       = STRING | INT | FLOAT | BOOL | NULL | NAMED_BINDING | POSITIONAL | CONTEXT_REF ;
CONTEXT_REF = "@context" IDENTIFIER ;

Syntax errors

Malformed syntax throws Warrant\RuleSyntaxTree\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 as a hard error.

Providing rules to Warrant

Rules are data. Warrant never invents them; it asks your resolver for them.

The RuleResolver

Implement one interface. Given a context (the user, the resource's schema key, the schema class, and the model class), return the WarrantRuleSet that governs this user's access to that resource.

use Warrant\RuleResolutionContext;
use Warrant\RuleResolver;
use Warrant\RuleSyntaxTree\WarrantRuleSet;

class DatabaseRuleResolver implements RuleResolver
{
    public function resolve(RuleResolutionContext $context): WarrantRuleSet
    {
        // $context->user               — the Authenticatable being checked
        // $context->schemaKey — e.g. 'timesheets'
        // $context->schema             — the schema class string
        // $context->model              — the model class string, or null

        $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(
            $context->schemaKey,
            $grants->implode("\n"),             // rules concatenate freely
        );
    }
}

The resolver is where your access-control model meets Warrant. 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

Three ways to construct a WarrantRuleSet:

From syntax (parse a string, resolving bindings inline):

WarrantRuleSet::fromSyntax('timesheets', 'if is_self they can view', $bindings = []);

From already-parsed rules — build individual WarrantRules and compose them. fromRules takes a variadic list or a single array, and accepts no bindings (the rules are already resolved):

use Warrant\RuleSyntaxTree\WarrantRule;

$own      = WarrantRule::fromSyntax('if is_self they can view, update');
$noDelete = WarrantRule::fromSyntax('they cannot delete');

WarrantRuleSet::fromRules('timesheets', $own, $noDelete);
WarrantRuleSet::fromRules('timesheets', [$own, $noDelete]); // equivalent

Directly with the parser, if you want the parsed rules without a rule set:

use Warrant\RuleSyntaxTree\Parsing\WarrantParser;

$rules = WarrantParser::parse('if is_self they can view', $bindings = []); // WarrantRule[]
$one   = WarrantParser::parseSingleRule('they cannot delete');            // WarrantRule

Building rules programmatically

When a rule's shape depends on runtime data — a list of department ids, a feature flag, values that don't belong in a string — a fluent builder is often clearer than assembling DSL text. WarrantRule::build() returns a builder that produces the same AST the parser does, so a built rule flows through the identical validation and compilation. Nothing is ever serialized to a string, so arbitrary PHP values in condition parameters survive untouched.

use Warrant\RuleSyntaxTree\WarrantRule;

$rule = WarrantRule::build()
    ->if('is_self')
    ->orIf(fn ($c) => $c->if('is_manager')->andIf('in_region'))
    ->theyCan('view', 'update')
    ->theyCannot('delete')
    ->toRule();

That builds the same rule as:

if is_self or (is_manager and in_region) they can view, update; they cannot delete

Conditions. Each connective has a plain and a negated form, mirroring Laravel's where/orWhere/whereNot:

Method DSL equivalent
if / andIf and (both are aliases; the first term's connective is ignored)
orIf or
ifNot / andIfNot and not
orIfNot or not

Each takes a condition name (with optional parameters) or a closure:

->if('in_department', ['sales', 'eng'])   // condition with parameters
->orIf(fn ($c) => $c->if('a')->orIf('b')) // closure = a parenthesized group

A closure is a parenthesized group. It receives a bare condition builder — it has the if/orIf/… methods but no theyCan/theyCannot, because a group is only ever a condition, never a whole rule.

Clauses. theyCan(...$abilities) and theyCannot(...$abilities) are variadic and additive. A rule needs at least one clause: toRule() throws if you call neither, exactly as the DSL rejects a bare if with no they can / they cannot line.

Precedence is identical to the DSLnot > and > or — so the two front-ends produce byte-for-byte identical trees. ->if('a')->andIf('b')->orIf('c') is (a and b) or c, not a and (b or c).

Composing dynamically. The builder shines when the tree is data-driven. Fold a list inside a group, or branch with when():

$rule = WarrantRule::build()
    ->if('is_self')
    ->orIf(function ($c) use ($departmentIds) {
        foreach ($departmentIds as $id) {
            $c->orIf('in_department', [$id]);
        }
    })
    ->when($includeManagers, fn ($c) => $c->orIf('is_manager'))
    ->theyCan('view')
    ->toRule();

An empty group folds to false, so it contributes nothing to an or and vetoes an and — folding an empty list is a safe no-op.

Splicing in DSL text. ifRaw() / orIfRaw() parse a DSL fragment and splice it in as one group — author the readable part as text, compose the rest structurally:

->ifRaw('is_admin or is_owner', $bindings = [])->andIf('in_region')

Dropping into a rule set. fromRules accepts builders directly (it finalizes each via toRule()), so you don't have to call toRule() yourself:

WarrantRuleSet::fromRules(
    'timesheets',
    WarrantRule::build()->if('is_self')->theyCan('view', 'update'),
    WarrantRule::build()->theyCannot('delete'),
);

Implicit rules

A schema can declare rules that are 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 obey deny-overrides exactly like resolver rules — and, like every rule, they're still evaluated against the current user via their conditions. Ideal for baseline guarantees — an admin escape hatch, or a suspension lockout:

use Warrant\RuleSyntaxTree\WarrantRule;

class TimesheetSchema extends WarrantSchema
{
    protected function implicitRules(): array
    {
        return [
            WarrantRule::fromSyntax('if is_admin they can *'),
            WarrantRule::fromSyntax('if is_suspended they cannot *'),
        ];
    }
}

Because deny-overrides is order-independent, an implicit cannot beats any resolver-supplied can.

Registering the resolver

Warrant ships no default resolver — you must configure one in config/warrant.php, plus the list of schemas Warrant should know about:

return [
    'rule_resolver' => App\Warrant\DatabaseRuleResolver::class,

    'schemas' => [
        App\Warrant\TimesheetSchema::class,
        App\Warrant\ProjectSchema::class,
    ],
];

Checking access

Once the schema, resolver, and rules are in place, you never touch the compiler directly. You ask questions through the model, query scopes, the schema's static helpers, or middleware.

On the model

Add the HasWarrantSchema trait and point it at the schema:

use Illuminate\Database\Eloquent\Model;
use Warrant\HasWarrantSchema;

class Timesheet extends Model
{
    use HasWarrantSchema;

    public function warrantSchema(): string
    {
        return App\Warrant\TimesheetSchema::class;
    }
}

That unlocks:

// Boolean checks (run as a scoped EXISTS query):
Timesheet::userHasAbilities('update', $timesheet);           // for a model instance
Timesheet::userHasAbilities('update', $timesheetId);         // for a key
Timesheet::userHasAbilities(['view', 'update'], $timesheet); // several at once
Timesheet::userHasAbilities('create');                       // no-target / capability

// The ability list for one record:
Timesheet::getUserAbilities($timesheet);                     // ['view', 'update']

// Attach abilities onto a loaded model:
$timesheet->loadAbilities();                                 // sets $timesheet->abilities

Each accepts an optional $user (defaults to auth()->user()) and, for userHasAbilities, an AbilityMatchMode.

Filtering queries

The hasAbility scope restricts a query to the rows the user may act on — the "which records?" question, answered in SQL:

// Timesheets the current user can update:
Timesheet::query()->hasAbility('update')->paginate();

// Rows they can BOTH view and approve (see match modes):
Timesheet::query()->hasAbility(['view', 'approve'], matchMode: AbilityMatchMode::ALL)->get();

// For a specific user:
Timesheet::query()->hasAbility('delete', $user)->get();

Per-row abilities

The selectAbilities scope attaches a computed abilities column — a JSON array of what the user can do to that row — so your UI can render controls without N extra checks:

$rows = Timesheet::query()->selectAbilities()->get();

$rows->first()->abilities; // ['view', 'update']

On a list endpoint you often only care about a subset (say, just update to show an Edit button). Narrowing it is a real cost saving — the attached subquery grows one branch per ability:

Timesheet::query()->selectAbilities(onlyAbilities: ['update'])->get();

Passing context to a check

Every check API takes an optional context: array — the values for any @context keys the rules reference. It threads through the model helpers, the query scopes, and the capability checks alike:

// Boolean check:
Timesheet::userHasAbilities('update', $timesheet, context: ['workspace_id' => $id]);

// Row filtering:
Timesheet::query()->hasAbility('update', context: ['workspace_id' => $id])->paginate();

// Per-row abilities, evaluated in one fixed frame:
Timesheet::query()->selectAbilities(context: ['workspace_id' => $id])->get();

Whatever you pass is merged over the schema's defaultContext(), with explicit values winning. A schema with a required context key rejects a check that ends up without it — a loud error, so a required frame is never silently skipped.

Capability (no-target) checks

Not every check is about a row. "Can this user create timesheets?" or "can they access settings?" have no target. Pass null as the target (or omit it):

Timesheet::userHasAbilities('create');                 // target defaults to null
TimesheetSchema::getUserAbilities();                   // all no-target abilities

For section-level capabilities with no model at all, define a schema with public const model = '' and only #[GlobalCondition] conditions. In a no-target check, targeted conditions are treated as false, so only global logic contributes.

Match modes

When you check several abilities at once, AbilityMatchMode decides how they combine:

  • AbilityMatchMode::ALL (default) — the row/user must satisfy every listed ability.
  • AbilityMatchMode::ANYany one is enough.
use Warrant\AbilityMatchMode;

Timesheet::query()->hasAbility(['view', 'approve'], matchMode: AbilityMatchMode::ANY)->get();

Could a user ever…? (reachability)

Every check so far asks about a concrete row (or the global capability frame). A different, cheaper question is "could this user ever update a timesheet — is it even worth showing the button, or building the section?" That's reachability: a purely structural look at the rules the resolver hands this user. It evaluates no conditions and runs no SQL — it only asks whether a grant is conceivable.

The rule of thumb is unconditionality. A rule with an if is a "maybe" (whether it fires depends on a condition we don't evaluate here); only unconditional rules make us certain. Each ability lands in one of three states:

Warrant\Reachability meaning typical UI use
NEVER no rule grants it, or an unconditional cannot forbids it hide the control entirely
MAYBE a condition decides — they might or might not show it, but check per row
ALWAYS unconditionally granted, no unconditional deny show it enabled

The decision table, resolved top to bottom for one ability:

  1. an unconditional cannotNEVER (an undodgeable deny wins);
  2. no can rule lists it → NEVER (no grant path at all);
  3. an unconditional can and no conditional cannotALWAYS;
  4. otherwise → MAYBE.

A conditional cannot is intentionally ignored: a different row/state can dodge it, so it never lowers certainty. (This mirrors the compiler's own hard edges — see How it compiles to SQL.) Because ALWAYS ignores conditional denies, it means "granted by the rules' shape," not a guarantee every row passes — the per-row check is still the source of truth.

use Warrant\Reachability;

// One ability, three-valued:
Timesheet::abilityReachability('update');            // Reachability::NEVER | MAYBE | ALWAYS

// The boolean questions:
Timesheet::userCouldEverHave('update');              // reachability !== NEVER
Timesheet::userAlwaysHas('view');                    // reachability === ALWAYS
Timesheet::userNeverHas('delete');                   // reachability === NEVER

// Whole-schema lists (over every declared ability):
Timesheet::getUserPossibleAbilities();               // ['view', 'update', 'approve']
Timesheet::getUserGuaranteedAbilities();             // ['view']
Timesheet::getUserImpossibleAbilities();             // ['delete']

Every method takes an optional $user (defaults to auth()->user()), and the boolean forms take an AbilityMatchModeALL (default) needs every listed ability to qualify, ANY needs one. There is no context: argument: @context only ever feeds condition evaluation, which reachability never does. The user is still needed, because the resolver may hand a different rule set to each user, role, or tenant.

The same three booleans and the classifier are on the schema and the Warrant facade too:

TimesheetSchema::userCouldEverHave('update', $user);
Warrant::userCouldEverHave('timesheets', 'update', $user);   // by schema key or class
// Rendering a nav without a query per link:
match (Timesheet::abilityReachability('update')) {
    Reachability::NEVER  => /* omit the Edit link */,
    Reachability::ALWAYS => /* show it, enabled */,
    Reachability::MAYBE  => /* show it; the row check decides per timesheet */,
};

Route middleware

Warrant registers a warrant route middleware. Build the middleware string with WarrantMiddleware:

use Warrant\WarrantMiddleware;

// Capability (no-target) — gate a create route by schema key:
Route::post('/timesheets', ...)->middleware(WarrantMiddleware::canCreate('timesheets'));

// Targeted — gate by a route-model-bound parameter:
Route::get('/timesheets/{timesheet}', ...)
    ->middleware(WarrantMiddleware::string('timesheet', 'view'));

// Group helper:
WarrantMiddleware::guard('timesheets', 'view', function () {
    Route::get('/timesheets', ...);
});

There are canView, canCreate, canUpdate, canDelete, canArchive, and canManage shortcuts. Under the hood the middleware resolves the target to a schema (by schema key or by the route-bound model's class) and calls userHasAbilities, aborting 403 on failure.

Every builder is dual-mode: call it with no closure to get the middleware string, or hand it a closure to wrap a route group — one method, no *Guard twin. guard is the generic form of string:

WarrantMiddleware::guard('timesheet', 'view');                       // returns the string
WarrantMiddleware::guard('timesheet', 'view', fn () => Route::get(...));  // groups the routes

Reachability guards

The reachability questions have matching guards — gate a section by whether the user could ever act, or short-circuit a route to those who provably never can:

// Only reachable if the user could ever view a timesheet — otherwise 403:
Route::get('/timesheets', ...)->middleware(WarrantMiddleware::couldEver('timesheets', 'view'));

// Only when the ability is guaranteed:
WarrantMiddleware::always('timesheets', 'create', fn () => Route::post('/timesheets', ...));

// Only when the user provably can never (e.g. an upsell page):
Route::get('/upgrade', ...)->middleware(WarrantMiddleware::never('timesheets', 'approve'));

These are target-free, so the first argument is always a schema key (or a schema/model class), never a route parameter. They're dual-mode like the rest.

Under the hood the mode and match mode live in the alias, not in the parameters — so everything after the colon is just the schema key and abilities, and an ability may safely be named any or all:

warrant.could-ever:timesheets,view          warrant.could-ever.any:timesheets,view,approve
warrant.always:timesheets,view              warrant.always.any:timesheets,view,approve
warrant.never:timesheets,view               warrant.never.any:timesheets,view,approve

(The row-check warrant: alias keeps its own grammar, where an any/all token after the schema key selects the match mode.)

How it compiles to SQL

You don't need this section to use Warrant, but it explains why the semantics are what they are.

For each requested ability, the compiler assembles one predicate from all the rules that mention it (or *):

predicate(ability) =
    ( OR of each `can` rule's if-expression )
    AND ( AND of NOT(each `cannot` rule's if-expression) )

with these hard edges:

  • An unconditional cannotAND NOT(true)1 = 0: this user can never have the ability, on any row.
  • No can rule for the ability → 1 = 0: denied to this user by default.
  • An unconditional can → an always-true 1 = 1 term: this user has the ability on every row.

Every condition leaf is wrapped as an EXISTS subquery, which makes it a strict boolean: a condition that touches a NULL column yields false, not SQL's "unknown," and negation via NOT EXISTS is exact. This is why not/cannot behave predictably — no three-valued-logic surprises leak into your authorization results. Boolean structure (and/or/not) becomes nested WHERE groups, with negation pushed to the leaves via De Morgan.

Row filtering applies these predicates to your query's WHERE; per-row ability selection runs them as correlated subqueries producing the JSON column. Because everything is one compiler, the "which rows?", "what can they do?", and "can they?" questions can never disagree.

Compilation validates every ability and condition name against the schema; an unknown name is a hard error, so a typo in a stored rule fails loudly rather than silently granting or denying.

Testing

Warrant's own suite drives real SQLite and asserts on rows and ability lists rather than SQL strings. The same approach works for your schemas: register a fake resolver that returns a fixed WarrantRuleSet, seed a table, and assert what comes back.

app()->instance(RuleResolver::class, new class implements RuleResolver {
    public function resolve(RuleResolutionContext $context): WarrantRuleSet
    {
        return WarrantRuleSet::fromSyntax($context->schemaKey, 'if is_self they can view');
    }
});

$visible = Timesheet::query()->hasAbility('view', $user)->pluck('id');
expect($visible)->toContain($ownTimesheet->id)->not->toContain($othersTimesheet->id);

API cheat sheet

Define a schemaextends Warrant\Schema\WarrantSchema

  • const model — managed Eloquent model (or '' for a capability schema)
  • const schemaKey — optional schema-key override
  • #[Ability] const X = '...' — declare an ability
  • #[ContextKey] const X = '...' — declare a check-time context key (required by default; required: false to opt out)
  • #[TargetedCondition] / #[GlobalCondition] methods — declare conditions
  • protected function implicitRules(): array — always-on rules
  • protected function defaultContext(): array — default check-time context

Build rules

  • WarrantRuleSet::fromSyntax(string $entity, string $syntax, array $bindings = [])
  • WarrantRuleSet::fromRules(string $entity, WarrantRule|WarrantRuleBuilder|array ...$rules)
  • WarrantRule::fromSyntax(string $syntax, array $bindings = [])
  • WarrantParser::parse(string $source, array $bindings = []): WarrantRule[]
  • WarrantParser::parseSingleRule(string $source, array $bindings = []): WarrantRule
  • WarrantRule::build() — fluent builder: ->if/andIf/orIf/ifNot/…, ->theyCan/theyCannot, ->toRule()

Provide rules — implement Warrant\RuleResolver

  • resolve(RuleResolutionContext $context): WarrantRuleSet
  • context: ->user, ->schemaKey, ->schema, ->model
  • register in config/warrant.phprule_resolver, schemas

Check accessuse Warrant\HasWarrantSchema on the model

  • Model::userHasAbilities($abilities, $target = null, $user = null, $matchMode = ALL, $context = []): bool
  • Model::getUserAbilities($target = null, $user = null, $context = []): array
  • ->hasAbility($abilities, $user = null, $matchMode = ALL, $context = []) — query scope
  • ->selectAbilities($user = null, $key = 'abilities', ?array $onlyAbilities = null, $context = []) — query scope
  • $model->loadAbilities($user = null, $key = 'abilities', $context = []) — attach the ability list to an instance
  • context: — values for the rules' @context keys, merged over defaultContext()
  • Warrant\AbilityMatchMode::ALL | ANY

Reachability — structural "could they ever?", no conditions evaluated, no SQL, no context:

  • Model::abilityReachability($ability, $user = null): Warrant\ReachabilityNEVER | MAYBE | ALWAYS
  • Model::userCouldEverHave($abilities, $user = null, $matchMode = ALL): bool!== NEVER
  • Model::userAlwaysHas($abilities, $user = null, $matchMode = ALL): bool=== ALWAYS
  • Model::userNeverHas($abilities, $user = null, $matchMode = ALL): bool=== NEVER
  • Model::getUserPossibleAbilities / getUserGuaranteedAbilities / getUserImpossibleAbilities($user = null): array
  • also on the schema and the Warrant facade (Warrant::userCouldEverHave($schemaKeyOrClass, …))

MiddlewareWarrant\WarrantMiddleware (all builders are dual-mode: string, or group when given a closure)

  • ::string($target, $abilities, $matchMode = ALL)
  • ::guard($target, $abilities, ?Closure $routes = null, $matchMode = ALL)
  • ::canView / canCreate / canUpdate / canDelete / canArchive / canManage($target, ?Closure)
  • ::couldEver / always / never($target, $abilities, ?Closure $routes = null, $matchMode = ALL) — reachability guards

License

MIT.