Why
4.0 is a full major, not a single feature. Relationships are the flagship, and the release also carries the modernization and cleanup work that only a major allows: interface breaks, contract renames, dead-tooling removal, stricter static analysis, and the retirement of legacy storage concepts.
On the flagship: record links today live as id arrays inside json_value. That storage caps what record fields can do: no sorting, weak filtering, no history, and dangling ids when a linked record is force-deleted. 4.0 replaces it with a typed edge ledger and makes relationships a first-class concept.
Origin: relaticle/relaticle#469.
What 4.0 ships
Relationship definitions. A new custom_field_relationships table holds each relationship's semantics: a stable machine-readable code, the two entity types, cardinality (one-to-one, one-to-many, many-to-one, many-to-many), and 0 to 2 field slots. Two slots give paired fields with an auto-synced inverse and per-side naming. One slot is today's one-way Record field on the new storage. Zero slots is a headless edge type for host applications to build on.
Edge ledger. custom_field_links stores one row per link, readable from both sides. There is no sync engine, so inverse drift is structurally impossible. Rows are temporal (active_from / active_until; unlink closes, never deletes) and carry the actor who made the change, so link history is queryable. Every row is tenant-stamped by the writer, and link targets are validated to exist within the tenant before an edge is stored.
Cardinality, enforced. A validation layer gives friendly errors. Underneath it, a partial unique index blocks duplicate active edges, and one-end exclusivity is serialized through a row lock on the relationship definition, uniformly on every database driver.
Deletion, fixed. Force-deleting a record sweeps its edges via indexed reverse lookups. The dangling-id problem dies with the json storage.
Record fields level up. Sortable and searchable table columns, indexed filters, ordered links, and symmetric relationships (one field, canonical edge ordering).
UI. A relationship creation flow that reads as a sentence, record chips with an instant-search picker and inline create, and a polish pass over the field-management screens. A flavor registry lets hosts choose the polished views or stock Filament views over the same logic layer.
Unchanged. The payload contract. custom_fields => [code => [ids]] keeps working through create and update on every path, and reads keep returning ordered id arrays. Both new models join the model-swap registry so hosts can subclass them like the existing four.
A cleaner package. Beyond relationships, 4.0 modernizes the codebase: consistent component interfaces, standard contract naming, no reflection into framework internals, strict types everywhere, the large classes split, a higher static-analysis bar, and the v1-era tooling removed. The full list lives in the roadmap's Phase 0 and Phase 3 below.
Breaking changes
json_value no longer stores record links; a migration step moves them into link rows.
custom_fields.lookup_type retires; the relationship definition owns both ends.
allow_multiple and max_values collapse into definition cardinality for record fields.
- Component interfaces (
FormComponentInterface, InfolistComponentInterface, TableColumnInterface, TableFilterInterface) gain a record parameter on make(); the compatibility branch is removed.
- Internal contracts renamed to standard interface names; single-implementation internal interfaces collapsed.
- The v1-era upgrade script (
bin/custom-fields-upgrade) is removed; upgrade to the latest 3.x before jumping to 4.0.
- PHP floor raised to 8.3, Filament to v5.
- New config surface for the UI flavor registry.
- The full itemized list lands in the upgrade guide.
Upgrade path
The existing custom-fields:upgrade command gains a record-links step with dry-run: it converts every record-type field into a definition and its stored arrays into link rows. Old value rows are kept until an explicit purge step runs after you have verified the migration, so rollback stays trivial at every point. A schema-validation gate fails the command until the step has run. Rehearsed against production-scale data, in both set and clear directions, before 4.0.0 is tagged.
Roadmap
Phases run strictly in order. Phase 0 holds only substrate-independent cleanups; cleanups that depend on the new storage (the lookup_type retirement, the reflection-helper removal) wait for Phase 3.
Phase 0: pre-substrate housekeeping
Phase 1: substrate
Phase 2: UI and UX
Phase 3: lookup_type retirement
Phase 4: reference host adoption
Phase 5: release
Why
4.0 is a full major, not a single feature. Relationships are the flagship, and the release also carries the modernization and cleanup work that only a major allows: interface breaks, contract renames, dead-tooling removal, stricter static analysis, and the retirement of legacy storage concepts.
On the flagship: record links today live as id arrays inside
json_value. That storage caps what record fields can do: no sorting, weak filtering, no history, and dangling ids when a linked record is force-deleted. 4.0 replaces it with a typed edge ledger and makes relationships a first-class concept.Origin: relaticle/relaticle#469.
What 4.0 ships
Relationship definitions. A new
custom_field_relationshipstable holds each relationship's semantics: a stable machine-readablecode, the two entity types, cardinality (one-to-one, one-to-many, many-to-one, many-to-many), and 0 to 2 field slots. Two slots give paired fields with an auto-synced inverse and per-side naming. One slot is today's one-way Record field on the new storage. Zero slots is a headless edge type for host applications to build on.Edge ledger.
custom_field_linksstores one row per link, readable from both sides. There is no sync engine, so inverse drift is structurally impossible. Rows are temporal (active_from/active_until; unlink closes, never deletes) and carry the actor who made the change, so link history is queryable. Every row is tenant-stamped by the writer, and link targets are validated to exist within the tenant before an edge is stored.Cardinality, enforced. A validation layer gives friendly errors. Underneath it, a partial unique index blocks duplicate active edges, and one-end exclusivity is serialized through a row lock on the relationship definition, uniformly on every database driver.
Deletion, fixed. Force-deleting a record sweeps its edges via indexed reverse lookups. The dangling-id problem dies with the json storage.
Record fields level up. Sortable and searchable table columns, indexed filters, ordered links, and symmetric relationships (one field, canonical edge ordering).
UI. A relationship creation flow that reads as a sentence, record chips with an instant-search picker and inline create, and a polish pass over the field-management screens. A flavor registry lets hosts choose the polished views or stock Filament views over the same logic layer.
Unchanged. The payload contract.
custom_fields => [code => [ids]]keeps working through create and update on every path, and reads keep returning ordered id arrays. Both new models join the model-swap registry so hosts can subclass them like the existing four.A cleaner package. Beyond relationships, 4.0 modernizes the codebase: consistent component interfaces, standard contract naming, no reflection into framework internals, strict types everywhere, the large classes split, a higher static-analysis bar, and the v1-era tooling removed. The full list lives in the roadmap's Phase 0 and Phase 3 below.
Breaking changes
json_valueno longer stores record links; a migration step moves them into link rows.custom_fields.lookup_typeretires; the relationship definition owns both ends.allow_multipleandmax_valuescollapse into definition cardinality for record fields.FormComponentInterface,InfolistComponentInterface,TableColumnInterface,TableFilterInterface) gain a record parameter onmake(); the compatibility branch is removed.bin/custom-fields-upgrade) is removed; upgrade to the latest 3.x before jumping to 4.0.Upgrade path
The existing
custom-fields:upgradecommand gains a record-links step with dry-run: it converts every record-type field into a definition and its stored arrays into link rows. Old value rows are kept until an explicit purge step runs after you have verified the migration, so rollback stays trivial at every point. A schema-validation gate fails the command until the step has run. Rehearsed against production-scale data, in both set and clear directions, before 4.0.0 is tagged.Roadmap
Phases run strictly in order. Phase 0 holds only substrate-independent cleanups; cleanups that depend on the new storage (the
lookup_typeretirement, the reflection-helper removal) wait for Phase 3.Phase 0: pre-substrate housekeeping
FieldComponentFactoryValueResolvers,CustomsFieldsMigrators) and internal interface collapsebin/custom-fields-upgradeand v1-era upgrade steps (framework stays)strict_typesand final-class consistency sweepFieldSchema,FrontendVisibilityService,VisibilityComponent)down()from migrations; resolve the 3 todo testsPhase 1: substrate
UsesCustomFieldscustom-fields:upgradePhase 2: UI and UX
Phase 3: lookup_type retirement
lookup_typeconsumer onto definitionsallow_multiple/max_valuesfor record fieldsUtils::invokeMethodByReflection)Phase 4: reference host adoption
Phase 5: release