Custom Fields 4.0 [4.x] - #216
Draft
ManukMinasyan wants to merge 132 commits into
Draft
ManukMinasyan wants to merge 132 commits into
ManukMinasyan wants to merge 132 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 from3.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
custom_field_relationships(definitions with a stablecode, two entity ends, cardinality, up to two field slots, symmetry) andcustom_field_links(a temporal edge ledger: one row per link, closed on unlink, actor and source stamped, tenant stamped by the writer).LinkWriterdiffs 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 dispatchesRelationshipLinkCreated/Closedafter commit.LinkReader,RecordLinkQuery(sortable, searchable, filterable link fields),CardinalityGuard/CardinalityRulewith friendly errors,LinkActorResolverInterfacefor hosts.recordkeeps its 3.x face (target entity, allow multiple, a plain select) on the ledger;relationshipis new (paired fields with an auto-synced inverse, cardinality, symmetric option, a sentence-style configurator, chip picker with inline replace confirmation and provenance);statusis new (single choice whose options carryunstarted/started/completed/cancelled,optionsInCategory()).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.flavorpolished or native, per-surface overrides, both flavors in CI).custom-fields:upgradegains the record-links migration step and an opt-in purge; thelookup_typedrop refuses to run while a legacy record field has no definition;database.key_typefor ULID/UUID hosts.?Model $record(filters also?string $through);*Interfacenaming 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.ymlstill lists only3.x,2.x,1.x.