Skip to content

Custom Fields 4.0: first-class relationships and a modernization major #210

Description

@ManukMinasyan

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

  • Component interface signature break; delete the compatibility branch in FieldComponentFactory
  • Contract renames (ValueResolvers, CustomsFieldsMigrators) and internal interface collapse
  • Fix the navigation-group config key mismatch; remove dead config
  • Drop bin/custom-fields-upgrade and v1-era upgrade steps (framework stays)
  • strict_types and final-class consistency sweep
  • Split the three 650+ line classes (FieldSchema, FrontendVisibilityService, VisibilityComponent)
  • Raise PHPStan from level 5 toward 7
  • Remove down() from migrations; resolve the 3 todo tests

Phase 1: substrate

  • Definitions table and model (in the swap registry)
  • Links table and model (the edge ledger, in the swap registry)
  • Definition services (create, unpair, delete; tenant-stamped)
  • Actor resolver contract
  • LinkWriter: diff apply, canonicalization, one-to-one replace, target validation, events
  • Write-path fork in UsesCustomFields
  • Read path serves id arrays from links
  • Filament components on link storage; sortable and searchable flip
  • Cardinality validation layer
  • Entity deletion behavior
  • Record-links migration step + purge step in custom-fields:upgrade

Phase 2: UI and UX

  • Flavor registry foundation (polished and native over one logic layer)
  • Relationship creation flow
  • Record chips and picker
  • Field-management polish pass
  • Browser coverage in both flavors in CI

Phase 3: lookup_type retirement

  • Sweep every lookup_type consumer onto definitions
  • Drop the column; collapse allow_multiple/max_values for record fields
  • Rename the record-select config keys
  • Remove reflection into Filament internals (Utils::invokeMethodByReflection)

Phase 4: reference host adoption

  • Published key-type migrations, model subclasses, swap-registry bindings
  • Actor resolver binding; timeline listener on link events (same release as the bump)
  • API, search, and import surfaces onto link storage
  • Migration rehearsal on production-scale data, both directions

Phase 5: release

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions