Skip to content

Custom Fields 4.0 [4.x] - #216

Draft
ManukMinasyan wants to merge 257 commits into
4.xfrom
feat/4.0
Draft

ManukMinasyan wants to merge 257 commits into
4.xfrom
feat/4.0

Conversation

@ManukMinasyan

@ManukMinasyan ManukMinasyan commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Custom Fields 4.0: the relationship substrate, three new field types, and the modernization sweep that only a major allows. 223 commits on feat/4.0, cut from 3.x and merged back up to it. Specs and plans live in relaticle/relaticle#597 (docs/superpowers/specs/2026-09-03-cf4-p*, docs/superpowers/plans/2026-09-03-cf4-p* and the two 2026-09-06 amendments). Tracking: #210.

What ships

  • Relationship substrate: custom_field_relationships (definitions with a stable code, two entity ends, cardinality, up to two field slots, symmetry) and custom_field_links (a temporal edge ledger: one row per link, closed on unlink, actor and source stamped, tenant stamped by the writer). LinkWriter diffs a payload against active links inside the save transaction, validates targets, enforces cardinality with a definition-row lock plus a partial unique index (Postgres, SQLite; MySQL gets the lock and a documented recipe), and dispatches RelationshipLinkCreated/Closed after commit. LinkReader, RecordLinkQuery (sortable, searchable, filterable link fields), CardinalityGuard/CardinalityRule with friendly errors, LinkActorResolverInterface for hosts.
  • Three field types, never combined: record keeps its 3.x face (target entity, allow multiple, a plain select) on the ledger; relationship is new (paired fields with an auto-synced inverse, cardinality, symmetric option, a sentence-style configurator, chip picker with inline replace confirmation and provenance); status is new (single choice whose options carry unstarted/started/completed/cancelled, optionsInCategory()).
  • Table surfaces: through-relation columns, filters and sorting (CustomFields::table()->forModel(...)->through('relation'), to-one only, closes Possibility to display custom fields on relation table #53); bulk paste for choice options; the UI flavor registry (ui.flavor polished or native, per-surface overrides, both flavors in CI).
  • Upgrade path: custom-fields:upgrade gains the record-links migration step and an opt-in purge; the lookup_type drop refuses to run while a legacy record field has no definition; database.key_type for ULID/UUID hosts.
  • Modernization: component interfaces gain ?Model $record (filters also ?string $through); *Interface naming and single-implementation interfaces collapsed; v1 upgrade tooling removed; every class final outside the documented seams (new Extending page); PHPStan level 6; CI runs Laravel 12 and 13 on SQLite, Postgres and MySQL plus a native-flavor leg; every feature flag explicit with a package default for unlisted ones.

Merged up from 3.x

feat/4.0 branched before 3.x shipped another 59 commits, so adopting 4.0 would have regressed released work. 3.x is merged in (53156dcc). It brings the restore guard that refuses to bring back a record whose unique value another record now holds, value normalization on every write path, E.164 phone storage, the link field's domain variant, the match-any fix for multi-value table filters, and the builder resolution filters.

Resolution notes worth a reviewer's eye:

  • ResolvesFields rejoins the three builders, so getFields() and getSections() are public API again.
  • SettingsMerger::merge() replaces array_merge() inside the extracted updateField(). The recursive merge stops a partial submit from clobbering a nested settings key such as visibility.
  • UniqueCustomFieldValue keeps 3.x's heldValues() grandfathering and 4.0's guard against an unresolvable entity type.
  • validation_rules is annotated Collection<array-key, mixed>. It holds both a list of rule objects and string-keyed constraints, so neither int keys nor string values were ever true.

Also in this branch

  • field_form config: presentation opens the create and edit form as the 4.0 slide over or a centered modal, and settings narrows which optional settings it asks about. A setting left out is not removed: an edit merges over what the field stores, and a new field takes its data object default.
  • An immutable now() no longer fatals. LinkWriter, UpdateRelationshipDefinition and CustomFieldLink::close() take CarbonInterface. A host calling Date::use(CarbonImmutable::class), which Laravel has recommended for years, broke every link write on a TypeError.

Breaking changes

Listed in full in docs/content/1.getting-started/3.upgrade-guide.md (rewritten for 4.0 with an ordered runbook and a checklist).

Two arrived with the merge and are now recorded there (70010d57): UniqueCustomFieldValueTaken is now UniqueCustomFieldValueTakenException, and values normalize on every write path, so a phone stores as E.164 and a link without its scheme. No 3.x release carries the normalization, v3.11.0 being the latest, so every application meets it at this major.

Review and verification

Every plan task went through an implementer, a deterministic gate (pint, rector, phpstan, 100% type coverage, targeted and full Pest), one review pass, and one fix round. Locally 1544 tests pass with pint, rector, phpstan level 6 and 100% type coverage clean. The Relaticle host consumed the branch end to end (relaticle/relaticle#597) and its CI is green on every check.

Not done here on purpose

No tag, no release, no changelog commit (the release workflow generates notes; a draft lives with the maintainer), no licensing text. deploy-docs.yml still lists only 3.x, 2.x, 1.x.

Hosts must reach the latest 3.x release before jumping to 4.0, so the
2.x/3.x data-migration steps and the standalone bin script no longer
need to ship. The UpgradeStep framework and its two generic steps
(validate-schema, clear-caches) stay so plan 3.1 can register the
record-links migration steps on it.
…te step

Drops the last v3-only wording from ValidateSchemaStep and the command
summary, restores the missing custom-fields:upgrade line in the v3
checklist, rejects unknown --skip values with a clear error instead of
silently printing Step 1/0, and pins the tests to the exact step
headers and success-only output lines.
Mechanical half of the phase 1.1 housekeeping sweep:

- declare(strict_types=1) in every file that was missing it under
  src, tests, database, and config (23 files, plus the migration stub),
  not just the 8 named in the plan.
- Applied rector's three pre-existing findings (encapsed strings to
  sprintf, or-if-continue split) and re-ran pint.
- Typed the remaining untyped closure parameters (DateConstraintField,
  FieldForm, CleanupOrphanedValuesCommand, MultiChoiceEntry) to reach
  100% type coverage, using the real Filament/Eloquent types rather
  than mixed.
- Migrations are up-only: removed down() from create_custom_fields_table
  and relax_custom_fields_unique_key, and from the two tests/database
  fixture migrations that had one with no rollback test exercising it.
  RelaxCustomFieldsUniqueKeyMigrationTest lost its down()-specific cases;
  added an up()-only test that recreates the narrow key and asserts up()
  swaps it for the wide one, so that code path stays covered. Updated
  the upgrade guide's rollback note to match. Removed the orphaned
  "Database Configuration" header block from config/custom-fields.php.
- Renamed Contracts\ValidationCapability to ValidationCapabilityInterface,
  the last contract without the package's *Interface suffix; updated
  every implementation and reference, and the upgrade guide.
- Revived tests/Architecture.php as tests/ArchitectureTest.php so Pest
  actually collects it (26 rules now run as part of the suite instead of
  zero). Rewrote it against the real Pest 4 arch API:
  - Fixed real conventions that were pointed at the wrong target or a
    removed API: field type definitions implementing
    FieldTypeDefinitionInterface (was scoped at a namespace that doesn't
    exist), form components implementing FormComponentInterface (was
    checking a nonexistent interface), Livewire components extending
    Component (was flagging trait-only Concerns files), and tenant
    scoping on the CustomField* models (now asserts the #[ScopedBy]
    attribute instead of a nonexistent Filament::tenant() usage check).
  - Fixed the code a new rule exposed (every HasLabel enum routes
    getLabel() through __()): ImportDateFormat and ImportNumberFormat had hardcoded
    getLabel() strings instead of routing through __(), unlike every
    other HasLabel enum in the package; added the missing lang keys.
  - Deleted rules that relied on Pest APIs that don't exist in v4
    (toHaveMethodsMatching, toHaveReturnTypeDeclarations,
    toHaveParameterTypeDeclarations, toHaveProperNamespaceStructure,
    toHaveDocumentedPublicMethods, toHaveDocumentedComplexMethods,
    toHaveProperty) and had no honest replacement: return/parameter
    type checks are already enforced precisely by the type-coverage
    gate; the docblock rules would also contradict this package's own
    documented style (docblocks carry generics only).
  - Deleted rules that were tautologies passing for the wrong reason:
    "Services use dependency injection properly" checked for a class
    literally named "new"; "No password or secret data in logs" checked
    for classes literally named "password"/"secret"/etc. Neither could
    ever fail.
  - Deleted rules aimed at namespaces that don't exist in this package
    (Http\Controllers, Http\Requests) and would
    only ever pass vacuously or throw a reflection error.
  - Deleted "Services follow naming convention": most classes under
    Services don't use the Service suffix (Resolver, Extractor, Cache,
    Preloader are the norm), so the rule's premise was false.
  - Deleted "Feature tests use RefreshDatabase": RefreshDatabase is
    bound globally in tests/Pest.php, not per test file, so the check
    never matched how the suite is actually wired.
- UpgradeCommand: removed the one "what" comment ("// Summary stats");
  no others were present.
- Fixed two bugs in UpgradeCommand's --skip parsing while already in
  that file for the comment cleanup: a trailing comma produced an empty
  element rejected as an unknown step, and a repeated value undercounted
  the displayed total step count. Added regression tests for both.
Extension requests are answered by adding a seam, not by opening an internal,
so the classes nobody is meant to subclass are final at the major. The open set
is the one the extension-points page documents, and an architecture rule now
holds the two in sync.

Finalizing CustomFieldSettingsData let PHPStan prove that NumberComponent read
$settings->min and ->max, which the data object has never carried; min and max
come from the validation capabilities, so the dead chain is gone.
The final sweep needs a page to point at, otherwise closing a class reads as
"you cannot do this" instead of "do it through here". Every seam is listed with
a worked example, and the architecture rule's ignore list is this page's
contents, so the two cannot drift apart silently.
Nine flags were off only because nobody had listed them, which is not a
decision. Each one is now written down as on or off with the reason, and the
four that only add a control to the field editor (validation rules, description
position, section visibility, section width) ship on: none of them changes how
an existing field stores, validates, or renders its values.

The ones that would (multi-value, uniqueness, host model columns as condition
sources, hiding columns that are visible today, tenancy) stay off, because that
call belongs to the application.

The test environment sets its own feature block, so the shipped defaults had no
coverage at all; two tests now read the config file itself, one for the values
and one to fail when a new enum case is left implicit.
The two infolist visibility tests were unrunnable because the Post fixture has
no infolist: a resource without one renders its view page from the form schema,
where a conditional field is hidden by JS and therefore still present in the
schema. A Comment fixture resource with an infolist gives that path a page, and
the tests now prove entries are added and removed server-side per record.

The select-options test asserted an option name in the modal HTML after calling
the action, so it never saw the repeater state where the options actually live.
It now reads the mounted edit form and asserts the stored options, in order.
The arch suite carried three rules that could not fail: an exception
constructor check the base class always satisfies, a jobs rule over a namespace
holding one trait, and a tenant-scope check that any ScopedBy attribute passed,
including one naming the wrong scope. The tenant rule now reads the attribute
arguments, and the two vacuous ones are gone.

Pest arch layers only see classes under a PSR-4 prefix, so the strict-types
rule never reached the tests, the config, the stubs, or a migration. A plain
test walks those files and checks the first statement.

array_filter() dropped a "0" step name, so `--skip=0` skipped nothing and
reported nothing instead of failing as an unknown step.
The features block explained what half the flags do, which the enum names
already say. The reason belongs on the four that changed at the major and on
every flag held off, so that is where the comments are now.

The extension-points page claimed ActivableScope as a seam. Both scopes are
instantiated where they are applied, so a subclass has nowhere to register; it
is open only because the package's own scope extends it.
FieldSchema is a fluent builder, so extracting classes cannot shrink it:
a delegating stub costs the same lines as the setter it wraps, and the
per-type factories are one-line constructor calls. Grouping the setters
and their getters into traits keeps every public method's name and
signature on FieldSchema (hosts and the docs call it) while giving the
relationship phases a place to add configuration without regrowing a
650-line class.
FrontendVisibilityService mixed two jobs: deciding which conditions can be
evaluated client-side, and emitting the JavaScript for one condition. The
emitter is the half every phase-3 operator lands in, so it now has its own
class, with the JS literal escaping split off beside it. The service keeps
its public API and injects the generator.
VisibilityComponent carried the whole conditional-visibility editor: the
mode/logic fieldset, the repeater row, and every entity, field, operator
and option lookup behind it. The row schema and the pickers it reads now
live in their own classes, so the component is the fieldset again. The row
is built from make()/makeForSection() rather than the constructor, because
it needs the entity type and scope section that only those two set.
The section-width flip is not a no-op after all: the migrator persists a width
passed by a preset migration even while the flag is off, so such a section
starts rendering at that width. Both the config and the upgrade guide now say
so.

The extension-points page claimed every seam while omitting the migration base
class every preset migration extends, offered a field type without the form
component that a rendered form requires, and called all service providers
non-final when two of them are final. CustomFieldsPlugin is configuration, not
a seam: nothing in the package or the docs subclasses it.
Nothing subclasses them in the package, the docs, or the host, and the
extension-points page already classed them as wiring rather than seams.
The two excluded paths held the last two production regressions, so they
now go through the same analysis as the rest of src. Typing the swap
registry factories closed most of what surfaced: everything downstream of
them was a bare Model.

sectionDeleted() assigned to a computed property, which PHP 8.2 reports as
a deprecated dynamic property and which silently shadows the Livewire
computed cache for the rest of the request. It now busts the cache the way
every other component in the package does.

widthMap was a second, stale copy of CustomFieldWidth::getSpanValue() that
no PHP, Blade, or JS read, and whose declared string keys PHP had already
cast to ints.
Level 6 wants a value type on every iterable and generics on every
collection, relation, and scope. Most of it is docblocks over types the
code already had; three spots needed a real change:

entityTypes() rebuilt the alias/label map that EntityCollection::toOptions()
already produces, and returned an EntityCollection typed as holding
configuration objects while it actually held strings.

collect() cannot infer its templates from a form-state value, so the three
call sites that fed it one now wrap with Arr::wrap(), which is what
Collection did with the value anyway.

FormBuilder::values() returns schema components, and Collection is invariant
in its value, so the return is annotated covariant.
Level 7 does not land: five public extension hooks store a nullable
Closure whose own parameter is nullable, and PHPStan cannot prove that
assignment against an identically typed property, so the level would need
either a suppression or a pointless branch in every setter.
Larastan types the view() helper as taking a view-string, and it resolves
that by asking a bootstrapped application whether the view exists. A
package has no application to boot, so the custom-fields:: namespace is
never registered and every render() in src/Livewire fails analysis under
the dependency set CI resolves. The factory takes a plain string and
resolves the same view.
Laravel 13 made the Scope interface generic, so level 6 demands
@implements Scope<Model>; Laravel 12 carries the template on apply()
only and rejects the same tag as generics.notGeneric. CI runs both
legs, so the tag stays and the 12.x report is ignored the same way the
two existing version-dependent Filament stub entries are. Also merges
the stacked docblock on the lookup-order helper so its rationale is not
orphaned from the tags.
DB_CONNECTION now drives the test connection (default sqlite in-memory),
reading DB_HOST/DB_PORT/DB_DATABASE/DB_USERNAME/DB_PASSWORD for pgsql and
mysql. The CI matrix gains a driver dimension with postgres:17 and
mysql:8.4 service containers alongside sqlite; --parallel stays sqlite-only
since postgres and mysql workers raced against one shared database.

Getting a clean run on all three drivers surfaced real bugs masked by
sqlite's laxity: two fixture migrations lacked FK ordering (post_tag ran
before its parent tables), a dead migration referenced a table that was
never created, a duplicate-code check ordered by a column outside its
GROUP BY (rejected by postgres, tolerated by sqlite/mysql), and a test
compared a json column with '=', which postgres's json type does not
support.
- Reword the contributing guide's phpstan ignore rule: no baseline, and an
  ignore is accepted only for a finding that differs between CI dependency
  legs, named in a comment.
- Drop the narrating comment from AbstractComponentFactoryClosureTest and
  assert on the thrown message so the test cannot pass via an unrelated
  InvalidArgumentException path.
- Fix the formComponent() class-reference example in field-types.md: it
  documented a raw Filament class, which the factory rejects because a
  Field needs a name and does not implement FormComponentInterface.
- Delete the dead TeamFactory fixture and the User fixture's teams()
  relation and getTenants() body, both of which referenced a
  Tests\Models\Team class that does not exist.
ModelAttributeDiscoveryService excluded columns of type json but not
jsonb, so a Postgres jsonb column without an array/json cast leaked into
condition-attribute discovery. Add jsonb to the excluded type list and a
Postgres-only test, skipped on other drivers since jsonb has no sqlite or
mysql equivalent.
The setup commands authenticated as the OS user over the socket while
the pest line beneath them used root over TCP, so neither ran as
written. The parallel note now says what CI actually does (no leg uses
--parallel) and warns that composer test does. The MySQL DDL-leak caveat
described a leak the suite does not have. Matrix legs no longer cancel
each other on a single failure, so a driver-specific fault is reported
against its own leg.
A select option is free text today, so every consumer that reasons about a
status guesses from the label and breaks on rename or translation.
OptionCategory names the four workflow states; completed and cancelled are
terminal, which is what separates a close from a step forward.

The settings cast needs no extra transformer: laravel-data ships EnumCast and
EnumTransformer as global casts for BackedEnum, and DataEloquentCast::set()
runs the payload through Data::from(), so an unknown string throws
CannotCastEnum instead of being stored.
ManukMinasyan and others added 29 commits October 5, 2026 00:46
…ade guide

Two 4.0 changes reached hosts without the guide naming them.

`UniqueCustomFieldValueTaken` gained the `Exception` suffix every exception in
the package carries, so a host catching it on a restore path needs the new name.

Values normalize on every write path now: a phone stores as E.164 and a link
without its scheme. The work sits on 3.x but no 3.x release carries it, v3.11.0
being the latest, so every application meets it for the first time at this
major. It reaches exports, API responses and anything rendering a link into
mail, where a scheme-less URL is not one a client will linkify.
feat(link): keep the scheme of a url link [3.x]
…pm-3db0bf3888

chore(deps): bump @nuxt/ui from 4.11.1 to 4.11.2 in /docs in the npm group
…6ccebd94

chore(deps-dev): bump postcss-nesting from 14.0.1 to 14.0.2 in the npm group
…pm-security-8e18227acb

chore(deps): bump the npm-security group across 1 directory with 4 updates
chore(ci): carry the 3.x dependency cleanup into 4.0
fix(link): normalizer follow-ups and 3.12 upgrade notes [3.x]
…eref

fix(custom-fields): guard null-dereferences on deactivated/disabled c…
docs: describe how a value is stored, normalized and compared [3.x]
A domain-variant link that stacked http schemes with whitespace between them re-entered normalize() once per scheme, so a 2,048 character value took about 250 passes and 47 ms. A separator that trim() leaves in place, such as a non-breaking space, came back unparsed. The unwrap now skips the whitespace between schemes, so every stacked form resolves in two passes.
…emes

fix(link): unwrap stacked schemes split by whitespace in one pass [3.x]
main-line hosts now depend on the 3.13 value handling: equivalentValues() on
the field types, the url link that keeps its scheme, and the unique rule that
compares normalized spellings. The upgrade guide keeps its 4.0 runbook and
points at UPGRADING.md for the v3 minors.
phpstan 2.3.0 reads the relation closure's parameter from with()'s signature
instead of leaving a mixed one unchecked, so the custom builder closures no
longer matched a Builder<Model>.
feat: system-only field types and remove link_variant [4.x]

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants