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

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.

public const model = ''; // class-string of the managed Model; '' = no model
public const key = ''; // a virtualTable's identifying column; '' = none

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

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.

public static function schemaKey(): string; // the config key; needs a booted app
public 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; // sorted
public static function rowConditionKeys(): array; // sorted
public static function globalConditionKeys(): array; // sorted
public static function requiredContextKeys(): array; // schema-wide required keys (#[RequiredContext])
public static function virtualTable(): ?Builder; // default null; rows come from model's table
public function implicitRules(): array|WarrantRuleSet; // default []; merged into every rule set
protected function defaultContext(): array; // default []; merged UNDER explicit context
// Declared on your schema when needed; not inherited — see below.
public function matchKey(RowConditionContext $c, ...): ?Builder;

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 bare schema, which addresses no row at all).
  • Type the parameters loosely. A @column or @sql argument arrives as an Illuminate\Database\Query\Expression, not a scalar, so a string parameter would reject the very references a hop correlates with. Use mixed.
  • Returning null answers unknown, which neither grants nor lifts a deny. The default does this for a null key, because an absent @context value or a model with no key yet names no row — and an exists cannot report unknown once its subquery is built.
  • A key must identify at most one row. Nothing enforces it; a key matching several turns an exists from “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.

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->query is a plain query builder;
  • no hydrated $c->model, and WarrantDenialContext::$target is 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 a matchKey() 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 from Warrant::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 …)

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 message
public function ungrantedDenialMessage(WarrantUngrantedContext $c): string|Throwable|null; // nothing granted the ability

Message-source precedence (first non-null wins): (1) the matching cannot rule’s withDenialMessage(); (2) forbiddenDenialMessage(); (3) ungrantedDenialMessage(); (4) a generic 403.

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.

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';

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.

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.

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.

public function __construct(
public Authenticatable $user,
public Builder $query, // Illuminate query builder
public array $arguments = [],
public array $context = [],
);

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.

AbilityMatchMode::ANY; // 'any' — any one ability is enough
AbilityMatchMode::ALL; // 'all' — every listed ability required

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

Pure enum (not backed) returned by the reachability API.

Reachability::NEVER; // no rule shape can ever grant it
Reachability::MAYBE; // grantable, but subject to conditions at check time
Reachability::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::VIEW; // 'view'
StandardAbilities::CREATE; // 'create'
StandardAbilities::UPDATE; // 'update'
StandardAbilities::DELETE; // 'delete'
StandardAbilities::ARCHIVE; // 'archive'

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.