Schemas
A schema is a Warrant\Schema\WarrantSchema subclass, one per resource. It
declares the abilities that exist, the conditions a rule may test and the
rule templates a rule may expand, and binds them to
a model. A schema is vocabulary, not policy — it decides nothing.
use Warrant\Schema\WarrantSchema;
class DocumentSchema extends WarrantSchema{ public const model = Document::class; // the Eloquent model this governs}The model constant
Section titled “The model constant”const model binds the schema to an Eloquent model class, and the model names the
schema back through the HasWarrantSchema trait. Warrant checks
that the two agree the first time it resolves the schema, and throws if they don’t.
A schema and its model must name each other, so one model has exactly one schema. A base schema may still be extended, but each concrete schema needs its own model.
The schema key
Section titled “The schema key”The schema key identifies the resource in rules, lookups, and middleware. It is
not declared on the schema: it is the key the schema is registered under in
config/warrant.php, which is its single source of truth. Read it back with:
public static function schemaKey(): string; // 'documents'Because the key lives in the config index, that method is a lookup and needs a booted application.
Abilities
Section titled “Abilities”Abilities are the verbs a rule can grant or deny. Declare each as a class
constant marked #[Ability]. The constant’s value is the ability name used
in rules; the constant’s name is irrelevant to Warrant (discovery is by
attribute, not by naming).
use Warrant\Schema\Ability;
#[Ability] public const VIEW = 'view';#[Ability] public const APPROVE = 'approve';DocumentSchema::abilityNames(); // ['view', 'approve', ...]A rule that names an ability the schema doesn’t declare is rejected — see Errors & exceptions for the two distinct “unknown ability” messages (one at rule-set validation, one at check time).
Standard abilities
Section titled “Standard abilities”Warrant ships Warrant\Schema\StandardAbilities with common names if you want a shared
vocabulary:
StandardAbilities::VIEW; // 'view'StandardAbilities::CREATE; // 'create'StandardAbilities::UPDATE; // 'update'StandardAbilities::DELETE; // 'delete'StandardAbilities::ARCHIVE; // 'archive'Conditions
Section titled “Conditions”Conditions are the predicates a rule may test. Each is a public method marked
#[RowCondition] or #[GlobalCondition]. They’re covered in depth in
Conditions.
Context keys
Section titled “Context keys”For values known only at check time (the current tenant, an as-of date), a rule
references @context <key> and conditions read $c->context — no declaration
needed. To require a key, mark it #[RequiredContext] (schema-wide) or
#[Ability(requiredContext: [...])] (per ability). See
Check-time context.
Schemas with no model
Section titled “Schemas with no model”A schema may govern a “section” with no model at all — for gating things like
settings that only ever answer no-target checks:
class SettingsSchema extends WarrantSchema{ public const model = ''; // no model
#[Ability] public const MANAGE = 'manage';
// Only global conditions make sense here — row conditions // are treated as false in a no-target check. #[GlobalCondition] public function isAdmin(GlobalConditionContext $c): bool { return (bool) $c->user->is_admin; }}Targeted checks against a schema with no rows throw; use no-target checks instead.
Schemas whose rows come from a query
Section titled “Schemas whose rows come from a query”A schema’s rows need not be a table. Return a query from virtualTable() and it
becomes the schema’s rows — a database view defined in the schema instead of in
DDL, which is what you want when the query can’t be frozen into a migration:
class ShiftDaySchema extends WarrantSchema{ #[Ability] public const VIEW = 'view'; #[Ability] public const ASSIGN = 'assign';
public static function virtualTable(): ?Builder { return DB::table('teams') ->crossJoin('calendar_days') ->leftJoin('shifts', fn ($join) => $join ->on('shifts.team_id', '=', 'teams.id') ->on('shifts.starts_on', '=', 'calendar_days.day')) ->groupBy('teams.id', 'calendar_days.day') ->select([ 'teams.id as team_id', 'calendar_days.day', DB::raw('count(shifts.id) as shift_count'), ]); }
// A product-shaped view has no single identifying column, so the schema // says how a row is addressed. A view over one spine table usually does // have one — declare `const key = 'team_id'` instead and the built-in key // handles it, with no matchKey() needed. public function matchKey(RowConditionContext $c, mixed $teamId, mixed $day): ?Builder { return $c->query ->where($c->row('team_id'), '=', $teamId) ->where($c->row('day'), '=', $day); }
// A column the query computes, read as if it were stored. #[RowCondition] public function isUnderstaffed(RowConditionContext $c): Builder { return $c->query->where($c->row('shift_count'), '<', 2); }}Rules over it read exactly as they do over a table, and so do hops into it. What changes is that there is no model to reach it from, so you start from the guard:
$guard = Warrant::forSchema(ShiftDaySchema::class);
$rows = $guard->filterQuery($guard->query(), 'view')->get();$guard->can('assign', [$team->id, '2026-09-14']);A schema draws its rows from a model or a virtual table, never both — and a
virtual table gives up everything the model was buying beyond the rows: no
Eloquent scopes, no hydrated $c->model, and no key of its own, so it declares
one (const key) or says how a row is found (matchKey()). Declare neither and
it can be filtered but not asked about a single row. The full list is in the
schema API reference.
Overridable hooks
Section titled “Overridable hooks”| Hook | Purpose |
|---|---|
public static function virtualTable(): ?Builder |
The query this schema’s rows come from, instead of a model’s table. Default null. See above. |
public function matchKey(RowConditionContext $c, ...): ?Builder |
How a row is addressed. Declare it — it is not inherited — when the rows are not addressed by their key. See Schema API. |
public function implicitRules(): array|WarrantRuleSet |
Rules always merged into every rule set — an admin escape hatch, a suspension lockout. See Resolvers. |
protected function defaultContext(): array |
Default check-time context, merged under explicit values. See Check-time context. |
Registering the schema
Section titled “Registering the schema”Every schema must be listed in config/warrant.php, keyed by its schema key.
Unlisted schemas are unknown to checks, lookups, and middleware:
'schemas' => [ 'documents' => App\Warrant\DocumentSchema::class, 'settings' => App\Warrant\SettingsSchema::class,],The array key is the schema key — the identifier that appears in your rule
strings and in the RuleResolutionContext handed to your resolver. Treat it like a
database identifier: renaming one changes the meaning of every stored rule that
references it.
Listing a schema does not load it. The index is a plain string-to-string map, so registering hundreds of schemas costs one array; a schema class and its model are loaded the first time that schema is used.
