Skip to content

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

  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 or state can dodge it, so it never lowers certainty. This mirrors the compiler’s own hard edges (see How it compiles to SQL).

use Warrant\Reachability;
// One ability, three-valued:
Document::abilityReachability('update'); // Reachability::NEVER | MAYBE | ALWAYS
// The boolean questions:
Document::userCouldEverHave('update'); // reachability !== NEVER
Document::userAlwaysHas('view'); // reachability === ALWAYS
Document::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 AbilityMatchModeALL (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 class

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 */,
};

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.