Schema Registry
Contract for resolving a model's field schema, and the two consumer seams that derive API allow-lists and list-view columns from it.
Overview
App\Services\SchemaRegistry resolves the full field schema for any model, merging core fields declared through HasSchema::defineSchema() with custom fields loaded from the custom_fields table. It is the authoritative description of what a field is — its type, label, and whether it may be filtered, sorted, grouped, exported, or searched.
The registry is a singleton with two-tier caching: an L1 in-memory map for the request lifecycle and an L2 tagged cache entry (1 hour) that survives across requests.
Historically each entity restated its fields in up to four places — defineSchema(), the API controller's $allowedFilters/$allowedSorts, the ColumnRegistry, and document merge-field metadata. Two of those seams now derive from this registry instead.
Public surface
| Method | Purpose |
|---|---|
resolve() |
Resolve the merged core + custom field map for a model |
for() |
Alias of resolve() |
resolveDocumentType() |
Resolve merge-field metadata for a document type |
invalidate() |
Drop the cached schema for one model |
invalidateAll() |
Flush every cached schema |
fingerprintFor() |
Stable digest of one model's resolved schema |
fingerprint() |
Stable digest of the entire schema surface |
forgetFingerprint() |
Drop the memoised + cached surface digest |
SchemaRegistry caches model schemas only. Document-type merge-field metadata is
derived from resolver code rather than stored data, so it is memoised per request by
DocumentResolverRegistry and has no persistent tier to invalidate.
Schema fingerprints
fingerprint() returns an xxh128 digest over the canonicalised JSON of every
FieldDefinition payload in the catalogue (keys sorted, fields sorted, models sorted), so
it moves when — and only when — the published schema surface changes. It is exposed as:
meta.schema_fingerprintonGET /api/v1/schemaschema.schema_fingerprintandschema.model_fingerprintonGET /api/v1/schema/{model}schema_fingerprint/model_fingerprintin theget-model-schemaMCP tool payload and theschema://modelsandschema://models/{model}resourcesmcp.schema_fingerprintfrom thewhoamiMCP tool
Agents compare a stored value against the current one to detect drift, exactly as they do
with mcp.tools_fingerprint. The digest is cached under the same schema-registry cache
tag as the payloads it summarises and is dropped by invalidate(), invalidateAll(), and
SchemaModelCatalog::forget(), so it can never go stale relative to them.
FieldDefinition's public shape — the exact key set the digest hashes — is pinned by
tests/Feature/Architecture/FieldDefinitionShapeRatchetTest.php.
Accepted definition shape
Models implement App\Contracts\HasSchema and declare fields through the SchemaBuilder fluent API. Each declaration produces an immutable App\ValueObjects\FieldDefinition.
resolve() returns array<string, FieldDefinition> keyed by field name. Every definition carries a source:
core— declared indefineSchema()computed— declared via$builder->computed(), derived rather than storedcustom— merged from an activeCustomFieldrow for the model's module
Consumers that mean "the columns this model actually declares" must filter on source === 'core'; both seams below do.
Filterability and sortability are opt-in
FieldBuilder::$filterable and $sortable default to false. A field is exposed to allow-list derivation only where its schema says so explicitly:
$builder->string('reference')->label('Reference')->searchable()->filterable();
$builder->currency('total')->label('Total')->sortable();
These flags previously defaulted to true, which made every declared field both filterable and sortable regardless of intent. Deriving allow-lists from that default would have opened sorting on arbitrary unindexed columns, so the default was inverted and the explicit declarations became meaningful.
The inversion made the flags under-report rather than over-report: a controller that still declares literal $allowedFilters may accept a filter its model's schema does not advertise. That direction is safe — nothing silently gained a filter — but it does mean App\Services\Api\OperationDescriber (and therefore /api/v1/schema, the MCP model-schema tools, and the conformance probes) can document fewer queryable fields than an endpoint truly accepts. Closing that gap is a per-model exercise: give the field the explicit ->filterable()/->sortable() the endpoint already honours, which also makes the controller safe to migrate to $schemaModel byte-identically.
Every remaining under-report has since been closed. 66 flags across 27 models were corrected so that no endpoint filters or sorts on a field its own schema calls unfilterable or unsortable, and the polymorphic pairs those endpoints scope by (addressable_*, attachable_*, source_*, origin_*) are now declared rather than absent. /api/v1/schema therefore advertises the query surface each endpoint actually honours.
Declaring a field with filterable(false)->sortable(false) is meaningful in its own right — it records "the schema describes this column, and no endpoint exposes it to Ransack" instead of leaving the column undeclared.
Consumer seam: Ransack allow-list derivation
App\Services\Api\SchemaAllowList turns a model's schema into the filter and sort allow-lists an API controller enforces. A controller opts in by declaring $schemaModel; $allowedFilters and $allowedSorts are then left empty and FiltersQueries resolves the lists lazily, memoised per instance.
A model's schema is a superset spanning every surface that consumes it, while an endpoint exposes a curated subset. The controller therefore expresses policy, not capability:
| Property | Meaning |
|---|---|
$schemaModel |
Model whose schema derives the allow-lists; null keeps the literal arrays |
$filterExclusions |
Schema-filterable fields this endpoint withholds |
$sortExclusions |
Schema-sortable fields this endpoint withholds |
$additionalFilters |
Fields exposed beyond the schema — a gap to close in defineSchema() |
$additionalSorts |
As above, for sorting |
Custom fields are deliberately excluded from derivation: they reach Ransack through the controller's $customFieldModule and the cf. prefix.
Migrated controllers: AccountController, CatalogueItemController, RentalController, InvoiceController, PurchaseOrderController, AddressController, AssetRetirementController, EquipmentTestResultController, RentalProjectController, ScanSessionController, ScannableIdentifierController, WarehouseController, WarehouseTransferController.
tests/Feature/Api/SchemaAllowListParityTest.php holds each migrated controller's pre-migration literal arrays verbatim and asserts derivation reproduces them, so a schema edit that would widen or narrow a live endpoint fails there rather than in production.
Consumer seam: ColumnRegistry core-field projection
App\Views\ColumnRegistry::projectedFields() builds list-view columns from the same definitions. Label, type, sortable, and filterable come from the FieldDefinition; an optional decorator applies the view-only concerns the schema deliberately does not describe — renderers, links, widths, cost gating, export paths.
Columns with no schema field behind them (computed rollups, aggregate counts, action columns) stay in columns(). Custom fields continue to arrive through mergeCustomFields() under the cf. prefix. A projection that matches neither a declared field nor a declared relation throws InvalidArgumentException.
Migrated registries: AccountColumnRegistry, CatalogueItemColumnRegistry, RentalColumnRegistry, InvoiceColumnRegistry, ImportBatchColumnRegistry, AssetColumnRegistry (relation displays only).
tests/Feature/Architecture/SchemaDivergenceTest.php fails on any registry column with no backing schema field, against a recorded exception list of pre-existing divergences. The list is the migration backlog for the remaining entities.
Column order is declared in columns()
Column order is user-visible — it drives the column picker, the export column order, and the default view for registries that do not override defaultColumns(). projectedFields() is a metadata map, not an ordering, so columns() is the single ordered list: a Column instance declares a view-only column inline, and a plain string places the projection of that name at that position.
protected function columns(): array
{
return [
Column::favourite(), // structural, not a model field
'name', // projected from the schema
Column::make('email')->filterable(), // view-only rollup
'created_at',
];
}
Projections not named in columns() keep their projectedFields() order and are appended after the literal columns. Naming a string with no matching projection throws. tests/Feature/Views/ColumnProjectionOrderTest.php pins the exact key order of every adopted registry.
Relation-display projection
List views usually show a related record's display attribute rather than the raw foreign key — the store name on a stock level, the account name on an rental row. The schema already describes that on the *_id field, so no second declaration is needed:
// Asset::defineSchema()
$builder->relation('warehouse_id')->label('Warehouse')
->relation('store', 'belongsTo', Warehouse::class, 'name')
->required()->filterable()->sortable();
A projectedFields() key naming the relation (warehouse) rather than the field (warehouse_id) projects that joined display column, taking its label from the owning field and its export path from the declared display attribute (warehouse.name).
protected function projectedFields(): array
{
return [
'store' => fn (Column $column): Column => $column->filterable(),
'catalogue_item' => fn (Column $column): Column => $column->filterable(),
];
}
Relation displays are not sortable or filterable by default: ordering or filtering one needs a join the Ransack layer does not express, so a decorator opts in only where the registry's query builder supports it. SchemaDivergenceTest treats a column keyed after a declared relation as schema-backed, so these columns no longer count as divergences.
Schema aliases
A column key is public. Saved custom views store it in their column, sort, and filter lists, so a registry cannot rename a column to match a schema field name without breaking every view that references the old key. ColumnRegistry::schemaAliases() declares the mapping instead:
protected function projectedFields(): array
{
return ['is_bookable' => null];
}
protected function schemaAliases(): array
{
return ['is_bookable' => 'bookable'];
}
The projected column keeps the key is_bookable, takes its label, type, sortable, and filterable from the bookable field, and carries bookable as its Column::field() — the attribute the DataTable filters, sorts, renders, and exports through, and the name ViewResolver translates a stored filter to before Ransack sees it. An alias may also name a declared relation rather than a field, projecting the joined display under a legacy key.
SchemaDivergenceTest resolves a column key through the alias map before checking it, so an aliased key counts as schema-backed.
Identity, collisions, and lookup behavior
Field names are unique per model; a custom field sharing a core field's name overwrites it in the resolved map, since custom fields merge last.
Lookup is forgiving: resolve() on a class that does not implement HasSchema returns custom fields only, or an empty array when the model has none.
Cache invalidation is explicit — invalidate() on custom-field writes, invalidateAll() on bulk changes. Schema edits in code require a cache clear to take effect, because defineSchema() output is cached under a version-stamped key that only changes when FieldDefinition's serialized shape does.
Worked example
use App\Services\Api\SchemaAllowList;
use App\Services\SchemaRegistry;
$fields = app(SchemaRegistry::class)->resolve(Invoice::class);
$fields['number']->filterable; // true
$fields['number']->label; // 'Number'
$allowList = app(SchemaAllowList::class);
$allowList->filters(Invoice::class, exclusions: ['is_locked']);
$allowList->sorts(Invoice::class);
Opting a controller into derivation:
class InvoiceController extends Controller
{
/** @var class-string */
protected ?string $schemaModel = Invoice::class;
/** @var list<string> */
protected array $filterExclusions = ['is_locked'];
}
Projecting a registry column from the schema:
protected function projectedFields(): array
{
return [
'number' => fn (Column $column): Column => $column
->primary('number')
->href('invoices.show'),
'status' => fn (Column $column): Column => $column->statusColors($statusColors),
'total' => null,
];
}