Repository navigation
Custom Fields 4.0 [4.x] - #216
Draft
ManukMinasyan wants to merge 257 commits into
Draft
ManukMinasyan wants to merge 257 commits into
ManukMinasyan wants to merge 257 commits into
Conversation
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.
…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
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. 223 commits on
feat/4.0, cut from3.xand 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
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.Merged up from 3.x
feat/4.0branched before3.xshipped another 59 commits, so adopting 4.0 would have regressed released work.3.xis 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:
ResolvesFieldsrejoins the three builders, sogetFields()andgetSections()are public API again.SettingsMerger::merge()replacesarray_merge()inside the extractedupdateField(). The recursive merge stops a partial submit from clobbering a nested settings key such asvisibility.UniqueCustomFieldValuekeeps 3.x'sheldValues()grandfathering and 4.0's guard against an unresolvable entity type.validation_rulesis annotatedCollection<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_formconfig:presentationopens the create and edit form as the 4.0 slide over or a centered modal, andsettingsnarrows 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.now()no longer fatals.LinkWriter,UpdateRelationshipDefinitionandCustomFieldLink::close()takeCarbonInterface. A host callingDate::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):UniqueCustomFieldValueTakenis nowUniqueCustomFieldValueTakenException, 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.ymlstill lists only3.x,2.x,1.x.