Schema API
Reference for Warrant\Schema\WarrantSchema and the attributes and context
objects that go with it. Conceptual coverage is in Schemas
and Conditions.
A schema is pure definition — it declares vocabulary and configuration and
holds no user. Every user-scoped operation (checks, ability listing, query
filtering, denial diagnosis, reachability) lives on the engine; see the
Checking API. The only user-facing entry point on the
schema itself is the static guard() shortcut.
WarrantSchema (abstract)
Section titled “WarrantSchema (abstract)”Constants
Section titled “Constants”public const model = ''; // class-string of the managed Model; '' = no modelpublic const key = ''; // a virtualTable's identifying column; '' = nonekey names the column a virtualTable()’s rows are identified
by, which is what lets the built-in row key address them. A model answers this
itself through getKeyName(), so declaring both is rejected.
Static guard shortcut
Section titled “Static guard shortcut”public static function guard(?Authenticatable $user = null): WarrantGuardForSchema;// === Warrant::forSchema(static::class, $user)Every concrete schema inherits it: DocumentSchema::guard($user)->can('view', $document).
See the Checking API for the returned guard’s methods.
Reflection
Section titled “Reflection”public static function schemaKey(): string; // the config key; needs a booted apppublic static function abilityNames(): array; // declaration order (NOT sorted)public static function abilityDefinitions(): array; // AbilityDefinition[] { name, requiredContext }public static function getAbilityDefinition(string $abilityKey): ?AbilityDefinition;public static function conditionKeys(): array; // sortedpublic static function rowConditionKeys(): array; // sortedpublic static function globalConditionKeys(): array; // sortedpublic static function requiredContextKeys(): array; // schema-wide required keys (#[RequiredContext])Overridable hooks
Section titled “Overridable hooks”public static function virtualTable(): ?Builder; // default null; rows come from model's tablepublic function implicitRules(): array|WarrantRuleSet; // default []; merged into every rule setprotected function defaultContext(): array; // default []; merged UNDER explicit context
// Declared on your schema when needed; not inherited — see below.public function matchKey(RowConditionContext $c, ...): ?Builder;matchKey
Section titled “matchKey”How this schema’s rows are addressed. Every row-bound reference goes through it —
a can(... for <schema>(<args>)) or check(... for <schema>(<args>)) hop, and a
targeted check from PHP — with the caller’s arguments bound positionally after the
context, exactly as a condition’s are.
Declare it only when the rows are addressed by something other than their key —
a natural key, or several columns where no single one is unique. Omit it and the
engine addresses them by their key column, which is what the single argument of
documents(@context id) means: a model’s getKeyName(), or a virtual table’s
const key.
It is not declared on WarrantSchema, deliberately: PHP forbids an override
from adding required parameters, so an inherited signature would make a key of
several parts impossible to write. Declare the parameters your key actually
takes:
public function matchKey(RowConditionContext $c, mixed $tenant, mixed $slug): ?Builder{ return $c->query ->where($c->row('tenant_slug'), '=', $tenant) ->where($c->row('slug'), '=', $slug);}- Arity comes from the parameters. Those without a default are required, and a
handle supplying too few is rejected when the rule is validated. A variadic tail
is never required, so a variadic key accepts any count — including none, which
is what makes
schema()legal (and it stays distinct from bareschema, which addresses no row at all). - Type the parameters loosely. A
@columnor@sqlargument arrives as anIlluminate\Database\Query\Expression, not a scalar, so astringparameter would reject the very references a hop correlates with. Usemixed. - Returning
nullanswers unknown, which neither grants nor lifts a deny. The default does this for a null key, because an absent@contextvalue or a model with no key yet names no row — and anexistscannot report unknown once its subquery is built. - A key must identify at most one row. Nothing enforces it; a key matching
several turns an
existsfrom “this row grants it” into “some row grants it”. - It is dispatched like a row condition — same context object, same Eloquent
wrapper, same alias handling via
$c->row()— but it is not part of the schema’s vocabulary. No rule can name it, and declaring#[RowCondition]on it is an error.
virtualTable
Section titled “virtualTable”The query this schema’s rows come from, when they are not a model’s table — a
database view defined in the schema instead of in DDL. Null, the default, means
the rows are model’s table.
public static function virtualTable(): ?Builder{ return DB::table('teams') ->crossJoin('calendar_days') ->select(['teams.id as team_id', 'calendar_days.day']);}A schema draws its rows from one source or the other, never both: naming a
model and defining a virtualTable() is an error on first resolution. So a
virtual-table schema is key-addressed only, and gives up everything the model was
buying beyond the rows themselves:
- no Eloquent scopes to spend from a condition, and
$c->queryis a plain query builder; - no hydrated
$c->model, andWarrantDenialContext::$targetis null; - no primary key of its own. Declare
const key = '<column>'and the built-in row key addresses rows by it, and$c->row()resolves to it with no argument. Declare neither that nor amatchKey()and the schema cannot be asked about one row at all — a targeted check and a row-bound reference are both refused up front. It is still filtered and still carries per-row ability columns, since both correlate against rows the outer query already produced; - no model to reach it from, so
Model::userHasAbility()and route-model binding do not apply. Start fromWarrant::forSchema(...)->query()instead.
It takes no user and no check-time context, deliberately: a virtual table says what its rows are, and who may touch them is what the rules are for. Filtering by the current user here would move an access decision out of the rule language, where neither the rule text nor reachability analysis can see it.
Filtering, per-row ability columns and hops all work unchanged — the compiler selects from the query as a subquery aliased to the schema’s key:
exists (select * from ( … virtualTable … ) as shift_days where …)Denial-message hooks
Section titled “Denial-message hooks”Schema-level fallbacks that supply a message when authorize() denies and the
responsible rule carried no withDenialMessage(). Override in your schema; each
returns string|Throwable|null (return null to fall through). See
Denial messages.
public function forbiddenDenialMessage(WarrantDenialContext $c): string|Throwable|null; // a matching `cannot` denied, but carried no messagepublic function ungrantedDenialMessage(WarrantUngrantedContext $c): string|Throwable|null; // nothing granted the abilityMessage-source precedence (first non-null wins): (1) the matching cannot rule’s
withDenialMessage(); (2) forbiddenDenialMessage(); (3) ungrantedDenialMessage();
(4) a generic 403.
Reachability
Section titled “Reachability”Structural analysis of the resolved rule set — evaluates no conditions, runs no
SQL, takes no context:. It lives on the engine, not the schema: see
Checking API → Reachability for the full
surface (Warrant::reachabilityOf, couldEverHave, alwaysHas, neverHas,
possibleAbilities, …) and Reachability for concepts.
Attributes
Section titled “Attributes”#[Ability]
Section titled “#[Ability]”Marks a class constant as an ability. The constant’s value is the ability name; its name is ignored (discovery is by attribute).
#[Ability] public const VIEW = 'view';An optional requiredContext names context keys that must be present whenever
this ability is checked. A yes/no check (can / authorize / @can) throws
if a key is missing; enumeration (abilities / selectUserAbilities) skips the
ability instead.
#[Ability(requiredContext: ['workspace_id'])] public const PUBLISH = 'publish';DeclaresAbility
Section titled “DeclaresAbility”The interface #[Ability] implements, and the one discovery actually looks for:
any attribute implementing it declares an ability. Write one when the required
context follows from something you would rather say once than restate as a
literal list on every constant.
The interface asks only for the required context. An ability’s name is the
constant’s value, so it is never the attribute’s to answer. An attribute class is
not an attribute by inheritance, so your implementation carries its own
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)].
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]final class TenantAbility implements DeclaresAbility{ public function __construct(private bool $scopedToBranch = false) {}
public function requiredContext(): array { return $this->scopedToBranch ? ['tenant_id', 'branch_id'] : ['tenant_id']; }}
#[TenantAbility] public const EDIT = 'edit';#[TenantAbility(scopedToBranch: true)] public const AUDIT = 'audit';A constant carries at most one ability attribute; two is an error, since an ability has one set of required context.
#[RowCondition] / #[GlobalCondition]
Section titled “#[RowCondition] / #[GlobalCondition]”Mark a public method as a condition. Optional key overrides the snake-cased method
name; passing '' throws.
#[RowCondition] // key = snake_case(method)#[RowCondition('is_owner')] // explicit key#[GlobalCondition]The method’s first parameter is the context object, typed to match:
RowConditionContext or GlobalConditionContext. Any parameters after it receive
the condition’s DSL arguments positionally (parameter #2 → argument[0], and so
on); a variadic tail collects the rest, and a parameter with a default is optional.
A condition may only add where clauses to $c->query (including whereExists,
whereIn, whereRaw), and must add at least one. Emitting a join, groupBy,
having, aggregate, or union throws at compile time — use a correlated
whereExists()/whereNotExists() subquery to reach another table. Returning the
query untouched throws too, since it would silently mean “match every row”; return
true to mean that. A condition may instead return a bool, evaluated in PHP: a
#[GlobalCondition] always may, and a #[RowCondition] may whenever it was handed
the row itself as $c->model. It must still emit SQL when $c->model is null.
A condition may also return null, answering unknown — see
Answering unknown. An unknown grants
nothing and cannot lift a cannot. A condition answering null must add no where
clause; doing both throws, because PHP cannot distinguish a deliberate null from
a missing return.
#[RequiredContext]
Section titled “#[RequiredContext]”Marks a class constant’s value as a context key that is required on every
check against the schema. Any check whose effective context (explicit or from
defaultContext()) omits the key throws up front.
#[RequiredContext] public const WORKSPACE = 'workspace_id';Context keys do not need declaring to be used — a rule may reference any
@context <key> and a condition may read $c->context['<key>'] freely. This
attribute is only about making a key mandatory schema-wide; for a key mandatory
only when a particular ability is checked, use #[Ability(requiredContext: [...])].
See Context.
Condition context objects
Section titled “Condition context objects”GlobalConditionContext (readonly)
Section titled “GlobalConditionContext (readonly)”public function __construct( public Authenticatable $user, public Builder $query, // Illuminate query builder public array $arguments = [], public array $context = [],);RowConditionContext (readonly)
Section titled “RowConditionContext (readonly)”Same as above, plus the target row’s table and keyColumn, a row() helper that
qualifies a column against the target table (always present for a row condition),
and model — the loaded target row when the check named one:
public function __construct( public Authenticatable $user, public Builder $query, public string $table, // e.g. "documents" public string $keyColumn, // e.g. "id" public array $arguments = [], public array $context = [], public ?Model $model = null, // the loaded target row, or null);
public function row(?string $column = null): string; // row() => "documents.id"; row('user_id') => "documents.user_id"model is set only when the check named one specific, hydrated row
(can('view', $document)); it is null when filtering a query, listing per-row
abilities, or when the row was named by key or is unsaved or deleted. A condition
handed it may answer in PHP by returning a bool — see
Answering in PHP when you already hold the row.
Enums & helpers
Section titled “Enums & helpers”AbilityMatchMode
Section titled “AbilityMatchMode”AbilityMatchMode::ANY; // 'any' — any one ability is enoughAbilityMatchMode::ALL; // 'all' — every listed ability requiredUsed by the query scopes, the lower-level query/reachability methods, and the
middleware. The facade/guard check helpers express the mode through the method name
instead (can vs canAny).
Reachability
Section titled “Reachability”Pure enum (not backed) returned by the reachability API.
Reachability::NEVER; // no rule shape can ever grant itReachability::MAYBE; // grantable, but subject to conditions at check timeReachability::ALWAYS; // granted by the rules' shape (NOT a per-row guarantee)Decision per ability, top to bottom: (1) an unconditional cannot → NEVER;
(2) no can rule lists it → NEVER; (3) an unconditional can and no
conditional cannot → ALWAYS; (4) otherwise → MAYBE. A conditional
cannot is intentionally ignored — ALWAYS means “granted by the rules’ shape”,
not a guarantee for every row.
StandardAbilities
Section titled “StandardAbilities”StandardAbilities::VIEW; // 'view'StandardAbilities::CREATE; // 'create'StandardAbilities::UPDATE; // 'update'StandardAbilities::DELETE; // 'delete'StandardAbilities::ARCHIVE; // 'archive'The Warrant facade / WarrantManager
Section titled “The Warrant facade / WarrantManager”The facade’s full check and reachability surface is documented in the Checking API. For schema resolution it exposes the registry:
Warrant::registry(): SchemaRegistry;SchemaRegistry normalizes any accepted reference — a model class or instance, a
schema key, a schema class or instance, or null — to a coordinate. Each
coordinate has an OrNull resolver (returns null for a null/unregistered
reference) and an OrFail resolver (throws OutOfBoundsException instead; a
$passThroughNull flag lets a null reference pass back as null while a non-null
still throws):
Warrant::registry()->resolveSchemaClassOrNull(Model|WarrantSchema|string|null $ref): ?string;Warrant::registry()->resolveSchemaClassOrFail(Model|WarrantSchema|string|null $ref, bool $passThroughNull = false): ?string;Warrant::registry()->resolveModelOrNull(...): ?string;Warrant::registry()->resolveModelOrFail(...): ?string;Warrant::registry()->resolveSchemaKeyOrNull(...): ?string;Warrant::registry()->resolveSchemaKeyOrFail(...): ?string;Warrant::registry()->registeredSchemas(): array;A WarrantSchema (class or instance) resolves to itself, but must be registered —
an unregistered schema has no schema key, so nothing can name it in rule syntax or
in a RuleResolutionContext. A model reference resolves through the model’s own
HasWarrantSchema::warrantSchema(). A bare string is treated as a literal schema
key and is returned unchanged by the resolveSchemaKey* pair, so rule syntax still
parses and writes without a registry; it is resolveSchemaClass* that rejects an
unregistered key.
Two declarations describe the model↔schema link, and both are authoritative in one
direction: the schema’s const model, and the model’s warrantSchema(). The
registry cross-checks that they name each other the first time it resolves a
schema, and throws (LogicException) if they disagree, if the model does not use
the trait, or if the registered class is not a WarrantSchema. These checks are
deferred rather than run at boot because each one requires loading a class.
To list a user’s no-target abilities for a schema, use
Warrant::abilities(Document::class, $context, $user) — see the
Checking API.
