Skip to content
Laravel Warrant is in beta and still being tested — expect API changes between releases. Report an issue.

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
}

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 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 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).

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 are the predicates a rule may test. Each is a public method marked #[RowCondition] or #[GlobalCondition]. They’re covered in depth in Conditions.

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.

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.

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.

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.

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.