Skip to content

Custom Fields 4.0 [4.x] - #216

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

ManukMinasyan wants to merge 132 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. 132 commits on feat/4.0, cut from 3.x. 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.

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

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. The final head passes CI on all seven legs; locally 1374 tests pass in both flavors on SQLite, and the suites also pass on Postgres 17 and MySQL 8.4. The Relaticle host consumed the branch end to end (relaticle/relaticle#597), including a browser walk of every new screen.

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.

make() on FormComponentInterface, InfolistComponentInterface,
TableColumnInterface, and TableFilterInterface gains ?Model $record;
TableFilterInterface also gains ?string $through. Breaking the
interfaces once, now, avoids a second break in the same major once
plan 4.2 needs $through for filter query rewrapping.

Deletes the instanceof AbstractFormComponent compatibility branch in
FieldComponentFactory, which existed only to bridge the old
narrower FormComponentInterface contract.
…erfaces

ValueResolvers becomes ValueResolverInterface for naming consistency; it
stays an interface since three classes implement it. CustomsFieldsMigrators,
EntityManagerInterface, and EntityConfigurationInterface are removed: each
had exactly one implementation and was never documented as an extension
point, so callers now depend on the concrete class directly
(CustomFieldsMigrator, EntityManager, EntityConfigurator), all final.

Also fixes a dead config key: management.navigation_group was never read,
Utils::isResourceNavigationGroupEnabled() reads navigation_group_enabled.
Keep the key that's actually wired up and drop the orphan.
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.
Selecting a record can wait on the server's answer about who holds it, so
the screen-reader announcement was made against the count before the write
and claimed a record was selected even when it was sitting in the
confirmation instead.
CI's fresh resolve reached larastan 3.11 and Filament 5.7, where a
view-string property default on the configurator fails without a
booted app to resolve the package namespace; the view is now set in
setUp() like the sibling components do. The lock matches CI so the
local gate reports the same findings.
Filament 5.7 leaves ViewComponent::$view uninitialized until a view is
assigned, so reading it as the native fallback throws on the type
picker and the record picker.
The order ran off the raw foreign key, so a row whose related record is
soft-deleted or excluded by the relation body sorted by a value its cell never
shows. The key subquery is now the relation's own existence query with its
constraints merged, exactly as whereHas() builds one, and rows with no admitted
related record sort last in both directions instead of wherever the engine puts
NULL.
CI's larastan validates literal view names against an app that cannot
resolve the package namespace, so the literal fails there while the
local gate passes. Resolving through ViewFlavor keeps the configurator
consistent with the other forked surfaces.
…er one

The record column resolved its through path with a raw getAttribute, so an
unsupported path rendered blank instead of saying why; it now asks the resolver
like the state, sort, search and filter paths do. Its link-backed search
survives a through path: the column's own search query is re-run against the
related model rather than replaced with a json_value LIKE. And because a column
that renders from the record never reaches a formatter, the visibility
condition is answered where the cell is built, so a conditionally hidden record
field stays hidden on the direct path too.
Three defects the phase 4 surfaces exposed.

A form that emptied a field never dehydrated it, so the writer never heard
about the change. For a value row that made no difference, since an absent key
is written as null anyway, but a relationship reads an absent key as "the
payload said nothing about those edges", so taking the last chip off a field
left its link active. The guard now asks whether the field's own conditions
show it rather than whether its state is filled, which is the question a form
is really answering; Filament already withholds a hidden component on its own.
An emptied multi-choice value normalises to null so a cleared column keeps the
shape every other type uses.

The create actions filled the field form with the entity type, and a filled
schema hydrates that state instead of its own defaults, so every default in
this form was lost and the record configuration opened with no target and no
cardinality. The entity type reaches the schema as an argument instead, and
the form fills from its defaults again.

A link's two ends named their morph relation after the column prefix rather
than the method, so an eager load initialised a relation nothing reads and
both ends stayed null while lazy access worked. Same fix createdBy took.
Three defects in the record picker, all of them things the user reads or
signs off on.

The overflow trigger ran a pluralized key through __(), so it printed the
raw "{1} :count more|[2,*] :count more" instead of a count. Both forms are
now chosen server-side and the client picks between them, which is also how
the screen-reader announcement stops ending in a bare number.

The steal confirmation was a per-component latch: after one confirmed move
every later conflicting record was taken silently, and every later payload
carried replace: true, including payloads the confirmed record had already
left. It is now the confirmed record's own id, checked against the payload
before the map form is sent.

A host writing its own source onto the ledger got the untranslated lang key
where the sentence should say where the link came from.
The grid enumerated the type registry while the native select rendered its
resolved options, so ->options() or ->disableOptionWhen() narrowed one
flavor and not the other: a consumer who restricted the types on offer
still got all of them in the polished grid.

The pair lookup takes a fixed number of queries, not one; the docblock and
the test now say so and pin the count at two field counts. The record type
key gets a constant so plan 4.4 can widen the check that reads it.
The flavor registry buys two presentations over one logic layer, and until now
only one of them was ever executed: every test read the polished views because
that is the shipped default. A leg that never runs cannot fail, so the native
flavor could break and nothing would say so.

The flavor is now a run dimension. TestCase reads CUSTOM_FIELDS_UI_FLAVOR and
sets custom-fields.ui.flavor from it, so the whole suite executes twice, and CI
gains one leg (Laravel 13, sqlite, native) beside the database matrix rather
than doubling all six. A flavor no base combination carries adds a leg instead
of converting one, so the polished coverage is unchanged.

Tests that read markup only one presentation draws ask rendersPolished() and
assert the other flavor's equivalent instead of skipping: the stock table still
draws its rows, badges and reorder handles, the stock picker still exposes its
listbox and its create-new, and the stock chips still read their overflow as a
sentence. Three blocks pin a flavor rather than branch, because their subject is
one presentation's own view or component (the cardinality sentence, the polished
frame render, the pair query count, which is measured off one render path).
Record is the one-way link users have always known, and folding it into the
relationship concept changed its face for every host that reads "Record" as
that link. The new relationship type carries pairing, so the two share one
substrate and part on presentation.

A field type says which it is with supportsPairing(), never with its key, and
a slot says which type renders it, so the migration keeps producing record
fields while the management form can now produce either.
A 200-value vocabulary was 200 clicks through the add-option button. The
options repeater now carries a hint action: paste one name per line and the
rows land in the editor.

The names go through a parser that trims, drops blanks and keeps a name once,
compared case-insensitively both within the paste and against the rows already
there. That is the rule the repeater validates with, so a paste can never
produce a list the editor's own duplicate rule then rejects. It reads at most
100 lines, which protects the Livewire payload a large repeater round-trips
rather than the database, and the notification says what landed and what did
not.

Rows are appended the way the repeater's own add action appends them: a
generated key, then the child schema fills the name. Nothing writes an option
model, so the tenant stamping and the sort_order ordering the repeater
relationship carries keep working. The blank row defaultItems(1) opens on is
dropped with the paste, or the form would fail required on a row nobody typed.

Names only: the color column is conditionally visible, so a two-column paste
format would read wrong on most tenants, and a pasted row leaves its category
unset, which is the honest unknown.
A record field is a target and a yes or no about holding more than one, which
is what it asked for in 3.x and what a host reading "Record" still expects.
Cardinality answers the toggle underneath, so narrowing still confirms what it
will unlink, and the configurator now belongs to the relationship type alone.

The two frames are mutually exclusive, so the type can still change inside an
open create form and the right one follows it.
A record field keeps the select, the plain column and the plain entry it had
in 3.x, in either flavor, and gains only what the ledger gave it: sorting and
searching. Chips, the inline move confirmation and provenance move to the
paired picker, which is the type they were designed for.

The confirmation now names the record it was given for. It used to be a
payload-wide flag that a failed round trip rehydrated from the first id, so on
a multi-value field it could answer for a record nobody confirmed, or fall off
the one that was. The guard reads it per candidate, so every other holder in
the same payload is still reported.
A host reading the upgrade guide needs two answers: nothing about their record
fields changed except the ledger they gained, and the new type is for the link
both entities show. The upgrade log now names the type it keeps, so a run that
migrates a record field never reads as a rename.
A select option is free text, so every report that has to know whether a deal
closed reads the label and breaks on the first rename. Status is that select
with one thing added: each of its options means a workflow state.

It ships as its own type rather than a setting on Select because the meaning is
what the person picking the type is choosing. Everything a value touches, the
input, the badge column, the entry and the filter, is the Select type's, so a
Status field looks like a Select wherever a value is shown, and its storage is
a single option id like any single-choice field.
The dehydration gate asked isVisibleForValidation(), which is deliberately
fail-safe for validation: it answers true when the server cannot reproduce the
expression the client renders, so a field it cannot read is validated anyway.
Read as permission to write, that same answer is fail-destructive. A record
with a model-attribute condition the server cannot evaluate would dehydrate an
empty state and close its links.

The reproduction now returns null for an expression it cannot rebuild, and the
two callers resolve it in the direction that cannot lose anything: validation
runs, dehydration holds back. A clear travels only on a field the server can
prove the form showed, or on one whose state is filled.

toSafeArray() keeps an empty array as an empty array. Normalising it to null
changed the shape a cleared multi-choice field reads back as, left two shapes
in one column across legacy rows, and the dehydration fix never needed it.

The stock attribute table draws one badge per row, so the native branch says
so rather than passing on a string it never renders, and the pair query count
is read in both flavors after the first render that has a pair to resolve.
The options editor now reads the field type's own capability instead of a
feature flag: a Status field always shows the category column, and a Select
never does. FIELD_OPTION_CATEGORIES is gone with it, because a host that does
not want categorised options withholds the Status type from the type registry
it already controls, and a flag that hid a column on a type built around that
column only ever produced an editor that could not say what it was for.

Its placeholder now says Uncategorised, so a Status option left alone reads as
unknown rather than as a state.

Two things ride along. Option colours are offered on Status as they are on
Select, since the two draw the same badge. And the options repeater opens on no
rows: since defaults were restored on the create form, its blank row made a
required name live, so creating a tags input field, the one visible repeater
exempt from the required rule, failed on a row nobody had typed.
The allow-multiple toggle asks how many records this field holds, which is one
end of a cardinality. Writing many_to_one or many_to_many back answered for
both, so a save that only renamed a one_to_one field freed the end the move
confirmation is read from, unlinking nothing and warning about nothing.

The toggle now moves its own side and leaves the other where it is, so
one_to_one pairs with one_to_many and many_to_one with many_to_many, and
narrowing still asks before it closes the edges that no longer fit.
The confirmation names one record, so the re-rendered picker has to come back
holding that record and no other. The provenance note beside it asked hosts to
eager load one relation where the guard now needs both.
The migrator accepted a category on any single-choice field, which was the old
rule from before the type existed. It now accepts one exactly where the type
says its options are states, and the message names the field so a preset that
puts a category on a plain Select says why it was refused.

The docs page is written around the type: what to reach for and when, how to
seed one, and how a Select that turned out to be a workflow becomes a Status
field, which is a type change on the row and nothing else. It says not to
re-declare the options in that same update, because the migrator replaces an
updated field's options wholesale and every stored value points at the ids it
would drop.
The 4.0 section had grown across thirty commits, so it repeated itself and
buried the ordering a 3.x host actually needs. It now reads top to bottom:
requirements, the key type a ULID host sets before migrating, the ordered
commands, what users see, what breaks in code, the MySQL caveat, a checklist.

The command order is what the code enforces, not what the old text implied:
custom-fields:upgrade needs the two relationship tables, and the lookup_type
drop refuses until that command has read the column, so the tables are created
by path first and a plain migrate is the fallback that costs one red run.

A relationships page pulls definitions, cardinality, symmetry, history and
provenance into one place, and bulk paste picks up the docs it never had.
Two stale claims go: a config published before 4.0 no longer keeps a flag off,
and a bad flavor is reported rather than thrown in a console process.
The relationships page said readers skip a trashed end. The reader returns the
id either way; what skips it is the chip, column and entry resolving the record
through the model's own query, so say that instead.
… gaps

Status is not encryptable since its categories drive reporting and must
stay queryable; the field-type table was missing relationship and status
rows; a select field could reach save with zero options; and the option
colours allowlist used the unregistered multi_select key, so multi-select
fields never got the colour toggle.
The updating observer blocked every type change on a system-defined field,
which also blocked the documented select-to-status recipe on seeded fields,
the exact fields a host converts. Storage-compatible changes (same
FieldDataType, e.g. select to status) are now allowed; everything else,
and name/code changes, stay rejected.
The query builder docblocks pinned their generic to CustomField, so a host
subclass's query()->get() failed a Collection<int, HostCustomField> return
type under PHPStan. static resolves to the subclass, matching the pattern
already used on CustomFieldSection.
The flat management shell was a bare two-column flex that never stacked, so a
390px viewport left the attribute table 142px wide with its row menu off
screen. The entity rail now scrolls horizontally above the table below md.

Two smaller reads from the same walk: the pairing line in the attribute table
was truncated with no way to read the rest, and the configurator printed a host
resource label verbatim, so a host whose label is written for Filament's
sentence use got a card heading reading "opportunity".
custom_field_links.sort_order drives the display order and is written on every
save, but the chips offered no way to change it, so the order was fixed at
insertion time. Each chip now carries an up and a down button that reorder the
picker's own list and dehydrate in the new order.

Dragging would need the trigger to stop being a button, so the affordance is
two buttons a keyboard reaches. The move lives in the shared state object, so
both flavors and the plain record field get it from one place.
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.

1 participant