Core concepts
Warrant keeps three things separate, and that separation of concerns is what gives you Warrant superpowers 💪🏽:
- Schema — the vocabulary (abilities + conditions) for one resource.
- Rules — the policy, written as plain strings that reference that vocabulary.
- Resolver — the glue that hands Warrant the rules for the current request.
Everything below is one of those three or something built on top of them.
Ability
Section titled “Ability”An ability is a single action you can check for — view, update, approve.
Abilities are the only things a rule can grant or deny; you declare whatever verbs
your domain needs as #[Ability] constants on a schema.
#[Ability] public const VIEW = 'view'; // on DocumentSchemaOnce declared, a rule can name it — they can view. There’s no fixed list; see
Abilities.
Condition
Section titled “Condition”A condition is a named test a rule may put after if — is_self,
manages_team, is_admin. Each is a method on the schema that emits SQL (or,
for a global condition, returns a bool), so conditions are the bridge between
the rule language and your database:
#[RowCondition] // constrains which rows matchpublic function isSelf(RowConditionContext $c): Builder{ return $c->query->where('documents.user_id', $c->user->getKey());}
#[GlobalCondition] // about the user / the worldpublic function isAdmin(GlobalConditionContext $c): bool{ return $c->user->is_admin;}Conditions can take arguments straight from the rule string — here 'sales'
binds to the condition’s first parameter after the context object:
if in_team('sales') they can viewSee Conditions, row vs. global, and arguments.
A rule is one line of policy: an optional if <condition expression>, then
they can or they cannot, then the abilities it affects.
if is_self or manages_team they can view, updateThroughout the language, “they” is the current user — the one your resolver was asked about. A rule never says what everyone can do, only what this user can do with the resource it’s scoped to. Rules are plain strings — data, not code — so they can live in a table, in config, on a JWT claim.
You construct a single rule two ways. Parse one from the DSL:
WarrantRule::fromSyntax('if is_self or manages_team they can view, update');Or build it fluently with the rule builder — the same rule, composed in PHP:
WarrantRule::build() ->if('is_self')->orIf('manages_team') ->theyCan('view', 'update') ->toRule();See the rule language for the full syntax.
Rule set
Section titled “Rule set”A rule set (WarrantRuleSet) is the collection of rules that apply to one
resource for one user — what your resolver returns and what Warrant compiles. There
are three ways to construct one.
Parse a whole multi-rule string with fromSyntax:
WarrantRuleSet::fromSyntax(' if is_self or manages_team they can view, update if is_locked and not is_admin they cannot update if is_admin they can *', 'documents');Compose it from already-built WarrantRule objects with fromRules:
WarrantRuleSet::fromRules('documents', WarrantRule::fromSyntax('if is_self they can view'), WarrantRule::build()->if('is_admin')->theyCan('view', 'update')->toRule(),);Or build the whole set fluently, where each $rule() call appends a rule:
WarrantRuleSet::build('documents', function ($rule) { $rule()->if('is_self')->orIf('manages_team')->theyCan('view', 'update'); $rule()->if('is_admin')->theyCan('*');});See the Rule-building API for all three.
Rule resolver
Section titled “Rule resolver”The resolver is the one class you write that, at request time, hands Warrant the rule set for the current user and resource. This is where “rules are data” pays off:
class DatabaseRuleResolver implements RuleResolver{ public function resolve(RuleResolutionContext $context): WarrantRuleSet { $rules = DB::table('role_rules') ->where('role_id', $context->user->role_id) ->where('resource', $context->schemaKey) // e.g. 'documents' ->pluck('rule');
return WarrantRuleSet::fromSyntax($rules->implode("\n"), $context->schemaKey); }}Warrant owns no tables and has no opinion about where rules live — it only asks your
resolver for a WarrantRuleSet. You can also add implicit rules
that always apply. See Providing rules.
Schema
Section titled “Schema”A schema is the vocabulary for one resource — the abilities that exist and the conditions a rule may test — in a single PHP class tied to a model:
class DocumentSchema extends WarrantSchema{ public const model = Document::class;
#[Ability] public const VIEW = 'view'; #[Ability] public const UPDATE = 'update';
#[RowCondition] public function isSelf(RowConditionContext $c): Builder { return $c->query->where('documents.user_id', $c->user->getKey()); }
#[GlobalCondition] public function isAdmin(GlobalConditionContext $c): bool { return $c->user->is_admin; }}A schema is not a policy: it decides nothing, it only declares the words your rules may use, and Warrant validates every rule against it at compile time. A schema with no model answers only no-target checks. See Schemas.
Grants and denials
Section titled “Grants and denials”For each ability, Warrant ORs the can rules and subtracts the cannot rules.
The one combining rule: a cannot always beats a can, and an ability with no
can is denied by default.
Everyone may view, but never a locked row — the cannot wins:
they can viewif is_lockedthey cannot viewOrder never matters. See Grants and denials.
Check-time context
Section titled “Check-time context”Some values a condition needs aren’t fixed when your resolver builds the rules — they’re only settled when you ask the actual question, “can this user access this?” Think an active tenant, an academic year, or an as-of date that the caller chooses per check. Those come in as context keys: named values you pass to the check, which a rule (or a condition) can then use.
Context keys need no declaration to be used — but you can mark one required on the schema, so a check throws if the value is missing:
#[RequiredContext] public const WORKSPACE = 'workspace_id';Then supply it when you check — the value lives on the request, not in the rule:
Document::query()->userHasAbility('view', context: ['workspace_id' => 42])->get();A condition reads it one of two ways. Thread it in as an argument from the rule with
@context:
if in_workspace(@context workspace_id) they can view…or, if the condition is inherently tied to the frame, skip the rule and read the
ambient bag directly with $c->context['workspace_id'] — then the rule needn’t
mention the key at all. Keys can be required or optional, which matters for how a
missing value behaves; see Check-time context.
Checking access
Section titled “Checking access”Once your model uses the HasWarrantSchema trait, you ask about the current user’s
access through the Warrant facade (or the $user->warrant() /
DocumentSchema::guard($user) guards):
// A single boolean value representing whether or not the current user can// view this documentWarrant::can('view', $document);
// The throwing sibling — aborts with a 403 (and the rule's denial message)// if the user can't view itWarrant::authorize('view', $document);
// A scope that filters a list of documents to just the ones the user may viewDocument::query() ->userHasAbility('view') ->paginate();
// selectUserAbilities adds a json column to every row that looks something like// ["view", "update"] so you know what the user can 'do' to every documentDocument::query() ->selectUserAbilities() ->get();These abilities also resolve through Laravel’s Gate — $user->can('view', $document),
Gate::authorize, @can, and the can: route middleware all work.
See Checking access and the Checking API.
Route middleware
Section titled “Route middleware”The same rules can guard a route before your controller runs — targeted on a route-model-bound record, or a no-target check with no row:
// middleware to guard your routesWarrantMiddleware::guard('document', 'view', function () { Route::get('/documents/{document}', [DocumentController::class, 'index']);});See Route middleware.
Reachability
Section titled “Reachability”Distinct from “can they act on this row right now?” is “could they ever?” — a structural question used to hide UI that’s impossible for a user, without a query per link:
Warrant::reachabilityOf(Document::class, 'update'); // Reachability::NEVER | MAYBE | ALWAYSSee Reachability.
It all compiles to SQL
Section titled “It all compiles to SQL”The idea that ties the rest together: Warrant never evaluates rules in PHP. A rule
set compiles to one SQL predicate per ability — if is_self they can view becomes
roughly:
select * from documents where documents.user_id = 42That’s why the boolean check, the list filter, and the per-row abilities can’t disagree — there’s one source of truth. (The real output is a little more careful than this; see How it compiles to SQL.)
