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 document — 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 three states
Section titled “The three states”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, with no unconditional deny. | Show it, enabled. |
The decision table, resolved top to bottom for one ability:
- an unconditional
cannot→NEVER(an undodgeable deny wins); - no
canrule lists it →NEVER(no grant path at all); - an unconditional
canand no conditionalcannot→ALWAYS; - otherwise →
MAYBE.
A conditional cannot is intentionally ignored: a different row or state can
dodge it, so it never lowers certainty. This mirrors the compiler’s own hard edges
(see How it compiles to SQL).
Asking the question
Section titled “Asking the question”use Warrant\Reachability;
// One ability, three-valued:Document::abilityReachability('update'); // Reachability::NEVER | MAYBE | ALWAYS
// The boolean questions:Document::userCouldEverHave('update'); // reachability !== NEVERDocument::userAlwaysHas('view'); // reachability === ALWAYSDocument::userNeverHas('delete'); // reachability === NEVER
// Whole-schema lists (over every declared ability):Document::getUserPossibleAbilities(); // ['view', 'update', 'approve']Document::getUserGuaranteedAbilities(); // ['view']Document::getUserImpossibleAbilities(); // ['delete']Every method takes an optional $user (defaults to auth()->user()), and the
boolean forms take an AbilityMatchMode —
ALL (default) needs every listed ability to qualify, ANY needs one.
The same helpers live on the schema and the Warrant facade too:
DocumentSchema::userCouldEverHave('update', $user);Warrant::userCouldEverHave('documents', 'update', $user); // by schema key or classRendering UI without a query per link
Section titled “Rendering UI without a query per link”Reachability is built for exactly this — deciding what to render before you ever touch a row:
use Warrant\Reachability;
match (Document::abilityReachability('update')) { Reachability::NEVER => /* omit the Edit link entirely */, Reachability::ALWAYS => /* show it, enabled */, Reachability::MAYBE => /* show it; the per-row check decides per document */,};Gating routes by reachability
Section titled “Gating routes by reachability”The same questions have matching route middleware guards — gate a section by whether the user could ever act, or short-circuit a route to those who provably never can:
use Warrant\WarrantMiddleware;
// Only reachable if the user could ever view a document — otherwise 403:Route::get('/documents', ...)->middleware(WarrantMiddleware::couldEver('documents', 'view'));
// Only when the ability is guaranteed:WarrantMiddleware::always('documents', 'create', fn () => Route::post('/documents', ...));
// Only when the user provably never can (e.g. an upsell page):Route::get('/upgrade', ...)->middleware(WarrantMiddleware::never('documents', 'approve'));These guards are target-free: the first argument is always a schema key (or a schema/model class), never a route-bound parameter — reachability has no row to bind. See Route middleware for the alias grammar and the Middleware API for signatures.
