Reachability
Every check so far asks about a concrete row (or a global no-target check). 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”Reachability lives on the same authorization
engine as every other check.
On the Warrant facade the first argument names the schema — a schema/model class
or a schema key:
use Warrant\Reachability;
// One ability, three-valued:Warrant::reachabilityOf(Document::class, 'update'); // Reachability::NEVER | MAYBE | ALWAYS
// The boolean questions:Warrant::couldEverHave(Document::class, 'update'); // reachability !== NEVERWarrant::alwaysHas(Document::class, 'view'); // reachability === ALWAYSWarrant::neverHas(Document::class, 'delete'); // reachability === NEVER
// Whole-schema lists (over every declared ability):Warrant::possibleAbilities(Document::class); // ['view', 'update', 'approve']Warrant::guaranteedAbilities(Document::class); // ['view']Warrant::impossibleAbilities(Document::class); // ['delete']Every method takes an optional $user (defaults to auth()->user()). To ask
about several abilities at once, the boolean forms have *Any variants —
couldEverHave/alwaysHas/neverHas require every listed ability to qualify,
while couldEverHaveAny/alwaysHasAny/neverHasAny require any one.
The same helpers are also reachable through the two bound guards, where the schema is already fixed — drop the first argument:
DocumentSchema::guard($user)->couldEverHave('update'); // schema-bound guard$user->warrant()->couldEverHave(Document::class, 'update'); // user-bound guardRendering 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 (Warrant::reachabilityOf(Document::class, '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\Middleware\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.
