Skip to content

MVP 1 — Full CRUD on simple entities

1. Dead code & deprecated methods Done
Existing.Phantom PropertyInterface — only an @param annotation in EntityMetadata; no such class, the real property types (FieldMetadata/AssociationMetadata) inherit from PropertyMetadata. Deprecated getTypeOfField()/getTypeOfAssociation() in EntityMetadata — pure wrappers of getPropertyType()->value, sole caller TemplateResolver:44, where $property->type->value is strictly equivalent. Note: getTypeOfField() in EntityMetadataBuilder is the Doctrine ClassMetadata method — untouched. No released version: the "backward compatibility" rationale is void.
Expected.Dead code gone, zero behaviour change, all-check green.
Plan.
  1. EntityMetadata: fix the phantom docblock (PropertyInterface[]PropertyMetadata[]; the real collections are FieldMetadata/AssociationMetadata, both extending PropertyMetadata).
  2. EntityMetadata: drop getTypeOfField(), getTypeOfAssociation() and getPropertyType(). Code survey: no consumer of "type by name" — the type is consumed at build (EntityMetadataBuilderFormatterResolver::resolve() to pick the formatter) and at resolution (TemplateResolver, which already holds $property); runtime rendering uses the precomputed $property->formatter class-string (k_formatted_value); templates never touch the type. The "nature × type → formatter/widget" logic this API prefigured (link around an association value, several links for a collection) is deferred to the Collect & Computed foundation — a formatter concern, not a type-string lookup. (The Collect & Computed foundation later replaces $property->type->value at TemplateResolver:44 with the templateKey deduction, computed at build time from the facts — the type field disappears.)
  3. TemplateResolver:44: replace the wrapper call with $property->type->value (strictly equivalent; the field/association split already relies on instanceof at TemplateResolver:41).
  4. Grep src/ and tests/ for PropertyInterface|getTypeOfField|getTypeOfAssociation|getPropertyType → zero remaining (Doctrine's ClassMetadata::getTypeOfField() in EntityMetadataBuilder is untouched).
2. Cache wiring (metadata + templates) Done
Existing.TemplateRegistry::all() (double return, the second unreachable) and EntityMetadataRegistry::all() (commented-out cache) inject and wire a CacheInterface that is never used; real per-request cost (Twig resolveTemplate() per pattern behind k_template, metadata rebuilt several times per request).
Expected.Caches never active in dev, always active in prod; identical behaviour in dev; all-check green.
Plan.
  1. Make EntityMetadata a pure data object: capture fqcn (from classMetadata->getName()) and identifier (from classMetadata->getIdentifier()) in EntityMetadataBuilder and pass them as constructor values; delete getValue() and isEmbedded() (no callers — runtime value reading already lives in the Twig extensions via PropertyAccessor, do not reimplement); getFqcn()/getIdentifier() return the stored values; remove the ClassMetadata property.
  2. Gate: a bool $cacheEnabled computed once from !kernel.debug (each registry receives kernel.debug as a constructor argument via services.php and negates it — deliberately avoids the symfony/expression-language hard dependency that a container expr() would force); given to both registries.
  3. Metadata cache: EntityMetadataRegistry::all() returns cache.get('karross.metadata', build) when enabled, fresh build when not (replacing the double-return / commented paths).
  4. Template cache: TemplateRegistry::all() — same pattern on the resolver result, replacing the unreachable second return.
  5. New integration test: with a debug kernel the registries rebuild every call; with a prod-like (debug=false) kernel the builder is invoked once and the cached result deep-equals a cold rebuild; the gate toggles correctly.
3. Complexity measure in CI Done
Existing.No automated complexity measure; the PHPStan baseline (139) mostly blames over-fed arrays.
Expected.Deterministic AST-based cyclomatic measure over src/, baseline + threshold, run in CI and locally via make.
Plan.
  1. Install ast-metrics in the Docker image: fetch the pinned release binary (version + SHA256 in the Dockerfile) into /usr/local/bin; confirm the version targets PHP 8.5. Pinned v0.43.0 (supports PHP ≤ 8.5, single static binary, works offline).
  2. Config .ast-metrics.yaml (ast-metrics init then trimmed), rulesets complexity + volume over ./src with the REC-confirmed thresholds: max_cyclomatic: 10, max_loc: 30, max_logical_loc: 20. Note: max_nesting only exists in ast-metrics' golang ruleset — there is no PHP nesting rule, so the plan drops it.
  3. Freeze the current state: ast-metrics baseline src, commit the snapshot (mirrors phpstan-baseline.neon); only new violations fail.
  4. Local flow: add make qaast-metrics lint (exit 1 on any regression beyond the baseline); include it in all-check so local and CI share the same gate.
  5. CI: add a make qa step in ci.yml after phpstan — regressions beyond the baseline block the pipeline.
  6. Docs: document make qa and the baseline-regeneration rule (after cleanup sprints) in docs/src/contribute.md.
Realized. Validation was revealing: in v0.43 the two by-method volume rules only scan top-level functions (file.Stmts.StmtFunction) — PHP class methods live in StmtClass[].StmtFunction — so max_loc_by_method / max_logical_loc_by_method never fire on class-based code (zero violation at threshold 1). The logical-lines rule (max_logical_loc) reports PHP accurately. max_loc (physical lines) was tried then dropped: docblocks inflated it (49 physical lines for 27 code lines — noise). Final gate: max_cyclomatic: 10 + max_logical_loc: 20, by-method keys kept declared (inert on classes); baseline freezes 11 pre-existing violations (6 cyclomatic + 5 logical-lines).
4. Browser CSS compatibility gate in CI Done
Existing.Zero CSS today, no defined browser support.
Expected.A CI gate failing on any CSS unsupported by the target browsers.
Prerequisites.Decide the supported browser set.
Plan.
  1. Pin the npm tooling: a minimal package.json with stylelint + stylelint-plugin-use-baseline (Node is already in the container for Playwright). Exact version pins, no committed lock — mirrors the composer philosophy; node_modules lives on the host via the volume, like vendor/.
  2. .stylelintrc.mjs: plugin/use-baseline policy set to widely first (most compatible — REC choice), relaxed to newly later only if a theme feature justifies it.
  3. Fix the canonical asset path src/Resources/public/css/karross.css (Symfony bundle convention, host-replaceable) — the gate lints the bundle's own single CSS file; delivery (compression, cache, hashing) stays out of bundle scope.
  4. make css-check: stylelint gate, exit 1 on any unsupported CSS at the target level (warnings treated as errors via --max-warnings 0); wired into the CI pipeline.
  5. Docs: contribute.md — the make css-check command and the Baseline policy.
5. Reorganize Metadata folder — Collect/Computed Done
Existing.The Metadata namespace mixes analysis machinery (PropertyTypeDetector, EntityMetadataBuilder) and output read-models (EntityMetadata, PropertyMetadata, FieldMetadata, AssociationMetadata, registry). The DTO PropertyTypeInfo (no consumer) sits in Karross\Formatters, unused.
Expected.Two clean, non-crossing namespaces:
  • Metadata\Collect: machinery only — PropertyTypeDetector (type detection), EntityMetadataBuilder (orchestrator that constructs the read-models).
  • Metadata\Computed: read-models — EntityMetadata, PropertyMetadata, FieldMetadata, AssociationMetadata, FieldLabel, EntityMetadataRegistry (the access layer).
Dependency rule (corrected): the pure read-models (Computed) never import Collect machinery — they are inert data. The only seam is the registry (Computed) consuming the builder (Collect), while the builder constructs the read-models (so Collect → Computed exists by construction). PropertyType stays at the Karross\Metadata root (shared vocabulary between both sides and the Formatters) — it is meant to disappear with the "Collect & Computed" refactor. The DTO PropertyTypeInfo (no consumer) is deleted.
Prerequisites.None.
Plan.
  1. Create src/Metadata/Collect/ and src/Metadata/Computed/.
  2. Move the machinery (PropertyTypeDetector, EntityMetadataBuilder) into Collect/.
  3. Move the read-models (EntityMetadata, PropertyMetadata, FieldMetadata, AssociationMetadata, FieldLabel) into Computed/.
  4. Move EntityMetadataRegistry into Computed/ (the access layer over the read-models).
  5. Leave PropertyType at the Karross\Metadata root — shared vocabulary, destined to disappear with the "Collect & Computed" refactor.
  6. Delete PropertyTypeInfo (DTO with no consumer).
  7. Update every import: services.php, EntityMetadataRegistry, EntityMetadata (creates FieldLabel), Twig extensions, Routes, Pages, Actions, tests.
  8. Docs: architecture context and docs/src note the rule — Computed read-models stay inert (no Collect import); the seam is the registry → builder.
6. Collect & Computed Done
Existing.Collection was reductive: PropertyTypeDetector::detect() forced every property into one case of a bundle-made semantic enum (PropertyType — string, integer, date…), interpreted before any use from the PHP type, the Doctrine type and the PHPDoc. Every consumer hooked on that single case: the formatter through a one-type-one-formatter mapping (FormatterResolver::resolve()), the template keys through _type_{propertyType}. The raw facts (length, precision, scale, nullable…) were lost — the PropertyTypeInfo DTO meant to carry them had no consumer. The enum also mixed nature (string, integer, date…) and structure (single/multiple values), though the structure is already known to Doctrine (isSingleValuedAssociation()/isCollectionValuedAssociation()) and the templates already tell a field from an association.
Expected.Collection gathers the facts without interpreting them; every useful deduction (formatter, resolved renderer templates, tomorrow the widget) is computed at build time, in a single pass over the Doctrine ClassMetadata, each by its own usage mechanism, each replaceable by the host config. The PropertyType enum, the PropertyTypeInfo DTO and the metadata type field disappear: no forced semantic vocabulary, no nature/structure distinction in an enum. No consumer re-derives semantics at render time — the renderer composes candidates from its own vocabulary and only resolves the physical file existence (resolveTemplate()).
Plan.
  1. Dependencies (REC decision 2026-09-13): doctrine/orm and doctrine/dbal move to require (the bundle targets Symfony + Doctrine ORM — the host already provides them). The builder types against Doctrine\ORM\Mapping\ClassMetadata behind a single runtime assertion at the top of its loop (getAllMetadata() returns the persistence interface; the real instance is the ORM one — guaranteed since doctrine/orm is required).
  2. Collector: PropertyTypeDetector becomes Metadata\Collect\ComputedMetadataBuilder (ex-EntityMetadataBuilder; the ex-PropertyCollector statics merged in at review 2026-09-16 — no dedicated service without proven need). All deductions are private methods: resolvePhpType() (named type, first non-null member of a union), resolveReflectionProperty() (embedded navigation identity.firstname), resolveFormatter() (config override above the resolver), resolveSlug() (EntityShortnameException on collisions), resolveActions(), resolveActionTemplates(). One projection pass: buildAssociations() + buildFields() emit the frozen read-models; the raw sources are consumed and discarded — never stored.
  3. Computed read-models (all readonly): PropertyMetadata is the abstract base (name, fqcn, formatter, formatterOptions, templates, entitySlug) with isField()/isAssociation() derived by instanceof. FieldMetadata is a pure marker — no field-specific state; the column facts of the original plan (length, precision, scale, enumType, unsigned, fixed, nullable, id/version/generated) had zero render consumers and were dropped in review. AssociationMetadata carries the resolved formatter and the target entity's identifier column names (read by UrlBuilderExtension::getUrl()); identifier was first hoisted into the base then moved back to the association only (review 2026-09-17 — a field has no identifier, the base must not carry an empty [] for half its cases). Cardinality is not stored: isCollectionValuedAssociation() crosses the renderer seam as a transient bool $isToMany; the Cardinality enum and the conflict flag were removed (no runtime consumers).
  4. FormatterResolver: deterministic chain — the PHP type wins when it exists, except the datetime family refined by the Doctrine type (date/time/datetime; the mutable/immutable variant is dropped); enumTypeEnumFormatter; UnitEnum and __toString classes handled; NotAvailableFormatter is the universal fallback — the build never refuses a pair. Host config (entityPropertyFormatter/entityPropertyFormatterOptions) wins above the facts.
  5. Template resolution — two seams replace TemplateResolver/TemplateRegistry/TemplateRegistryExtension (k_template)/karross.templates, all deleted: Metadata\Collect\PropertyTemplateResolverInterface (per-property cells) and Metadata\Collect\EntityTemplateResolverInterface (entity pages) hand the renderer layer only raw facts (PHP type, FieldMapping, cardinality, slug, embedded-field presence); the renderer-specific vocabulary lives in the implementing layer and is never stored on the read-models. The Twig implementations resolve at build time the maps carried by the read-models — PropertyMetadata::$templates (action → resolved template) and EntityMetadata::$templates (action → role → resolved: index → items/no_items → item). The property vocabulary is a fixed contract table (Twig\PropertyTemplateResolver::DOCTRINE_HIERARCHIE, decided upstream — date/time → the datetime umbrella, enum → enum/string, guid/ascii_string → string, blob → blob/text/string, integer/smallint/bigint → int/number…); candidate patterns root at @Karross/{action}/… derived from the action value. EntityTemplateResolver implements the page hierarchy ({action}_entity_{slug}, the _embedded variant, generics).
  6. Rendering reads the read-models: index.html.twig/items.html.twig render the entity page templates from EntityMetadata; item.html.twig renders each cell through property->templates[action]; TwigResponder resolves via EntityMetadataRegistry::getBySlug($slug) (added) — fixing the pre-existing bug where items.html.twig and items_embedded.html.twig hard-coded item.html.twig and made the item_entity_{slug} tier inert for plain and embedded entities alike.
  7. Enum and stray surface: PropertyType and PropertyTypeInfo deleted; KarrossExtension::prepend() removed (the Twig override path is now wired through the bundle paths, host override always wins).
  8. Fill rules, as built: the PHP declaration wins when it exists; without a PHP type, Doctrine decides; unions take the first non-null member; datetime is the single Doctrine-refined family; conflicts are not flagged — PHP wins (the "conflict flag" proposal was dropped for lack of consumers); the host config overrides everything; the PHPDoc is never used.
  9. Tests & gates: MetadataCollectTest (merge rules, resolved property/entity templates, getBySlug, unhandled → NotAvailableFormatter), a TemplateOverride kernel with fixtures (field_type_datetime, items_entity_article) + the e2e IndexTemplateOverrideTest (host override wins per type), the doctrine_unhandled config, CacheWiringTest adapted. Realized.: all-check green — 40 tests / 238 assertions, PHPStan baseline 92, ast-metrics 10. Docs: customization/templates.md gained the cell vocabulary table and the "For pages" section.
7. Formatter resolvers — chain of responsibility with a Boolean link Done
Existing.FormatterResolver::resolve() centralizes the formatter decision in a single match (PHP type wins, the datetime family is refined by Doctrine, enum, __toString, Doctrine fallback). Boolean is one case among others: phpType 'bool'TrueFalseFormatter, doctrineType booleanTrueFalseFormatter. Every new type case fattens the match.
Expected.The type decision is broken down into responsibility links: each type family gets its own resolver under Formatters\Resolvers, exposing accept() (is this case mine?) and resolve() (the formatter, with no failure risk). FormatterResolver queries the registered links (tagged_iterator('karross.formatter.resolver'), same mechanism as the formatters); the first accepting link wins, otherwise the current logic applies, with NotAvailableFormatter as the last resort. Rendering behaviour stays unchanged.
The first link, BooleanFormatterResolver, covers every way of declaring a boolean (PHP bool, nullable or not; Doctrine boolean) and resolves to TrueFalseFormatter. It is the template for the following families. Configuration override (entityPropertyFormatter) remains priority.
Prerequisites.Collect & Computed (facts phpType/fieldMapping flowing through the build).
Plan.
  1. Create src/Formatters/Resolvers/FormatterResolverInterface: accept(?string $phpType, ?FieldMapping $fieldMapping = null): bool and resolve(...): class-string<ValueFormatterInterface>.
  2. Create the first link src/Formatters/Resolvers/BooleanFormatterResolver: accept() = 'bool' === phpType or doctrineType boolean; resolve()TrueFalseFormatter::class.
  3. Refactor FormatterResolver: new constructor parameter iterable $resolvers (tagged links); resolve() iterates — first accept() win → resolve(), otherwise the current private logic minus the two boolean cases, NotAvailableFormatter as the last resort. get() untouched.
  4. Wire the DI in src/Config/services.php: tag karross.formatter.resolver on the link and arg('$resolvers', tagged_iterator('karross.formatter.resolver')) on the resolver. Formatters are declared with explicit set() (no folder load()) → BooleanFormatterResolver must also be set() for the tag to apply.
  5. Test functionally only (no unit tests): MetadataCollectTest guards the real formatter map (non-nullable bool + nullable premiumTrueFalseFormatter, residual for the other types); E2E covers the three states of the nullable premium: true/false/nulltrue/false/empty by default, Oui/Non/empty via the configured YesNoFormatter.
  6. Realized. all-check green — 43 tests / 282 assertions, PHPStan 84, ast-metrics 11. Functional-only coverage (review guidance): MetadataCollectTest keeps the real map; E2E exercises the ?bool premium in its three states, out of the box and via the configured YesNoFormatter, while published keeps the default true/false. Pitfall fixed: a tagged link needs an explicit set(). The null contract (formatters pass it through) is fixed; the Twig-side rendering mechanism stays a separate rework lead.
8. Integer — integer formatting rule Done
Existing.FormatterResolver fallback matches 'float' PHP (line 71) and Doctrine decimal/float (line 112) → IntlNumberFormatter. The 'int' PHP type falls through to the default branch → resolvePhpClass()NotAvailableFormatter (no __toString on integers). The boolean match ('bool') was removed by ticket 7, but 'int' still gets no number formatting.
Expected.An IntegerFormatterResolver link placed after BooleanFormatterResolver in the chain: accepts 'int' PHP type (including nullable ?int) and any non-boolean, non-enum Doctrine integer type (smallint, integer, bigint); resolves to IntlNumberFormatter::class. The fallback 'float' match remains for the decimal family. Zero behaviour change for existing code — integers that were silently formatted via the fallback now go through an explicit link.
Prerequisites.Formatter resolvers — chain of responsibility with a Boolean link.
Plan.
  1. Create src/Formatters/Resolvers/IntegerFormatterResolver (tag karross.formatter.resolver). accept(): refuse enumType non-null; accept 'int' PHP (nullable ?int is a value modifier, not a family change); refuse 'bool'; refuse 'string'/'float'; accept null/no PHP type + Doctrine smallint/integer/bigint. resolve(): return IntlNumberFormatter::class.
  2. Create tests/Integration/Formatters/IntegerFormatterResolverTest: accepted cases (int pure, ?int, int on integer, string on integer, no type on integer); refused cases (bool, string, float, enumType, string on boolean, no type on boolean).
  3. Register the service in src/Config/services.php: ->set(IntegerFormatterResolver::class).
  4. Update FormatterResolverTest::chainCases(): add integer cases; remove the 'int' case from the fallback test.
  5. Verify: make all-fix then make all-check. No new errors, no baseline regeneration.
9. Float — float formatting rule & scale Done
Existing.IntlNumberFormatter serves decimals through the residual fallback of FormatterResolver: the PHP match 'float' (line 71) and the Doctrine match decimal/float (line 112). The column scale is never exploited at render time.
Expected.Standalone FloatFormatterResolver link placed after Integer in the chain, with the same exclusivity guards as Integer but for the fractional family. resolve() returns IntlNumberFormatter::class. The scale carried by the Doctrine FieldMapping is forwarded to the formatter via formatterOptions: the number of displayed decimals follows the column precision, not an arbitrary default. A price declared scale: 2 displays 19,9; an amount declared scale: 0 displays 42. Host config can override with formatter_options (minimum_fraction_digits, maximum_fraction_digits).
Prerequisites.Collect & Computed + Formatter resolvers — chain of responsibility with a Boolean link.
Plan.1. Create FloatFormatterResolver (src/Formatters/Resolvers/FloatFormatterResolver.php, tag karross.formatter.resolver). accept(): refuse non-null enumType; accept 'float' PHP pure (including ?float — nullable is a value modifier, not a family change); refuse any union containing float; refuse non-float PHP other than null/string; accept null/string PHP + Doctrine decimal/float. resolve(): return IntlNumberFormatter::class. 2. Wire scale in ComputedMetadataBuilder::buildFields(): when the resolved formatter is IntlNumberFormatter and host config provides no formatter_options, add 'maximum_fraction_digits' => $fieldMapping->scale. Host config remains priority. 3. Support minimum_fraction_digits via formatter_options: add minimumFractionDigits to FormattingContext, forward it in IntlNumberFormatter, extract it in PropertyAccessorExtension. Add the node in Configuration.php. 4. Update FormatterResolverTest::chainCases(): add float cases, clean fallback. 5. Seed data: enrich to 5 articles covering all formatter families (boolean true/false/null, integer 0–158, decimal with scale 2, datetime/date, enum DRAFT/PUBLISHED/ARCHIVED, tags empty/populated). 6. Documentation: update docs/src/customization/formatting-values.md with formatter_options table and trailing-zeros example.
Realized.all-check green — 72 tests / 392 assertions. Chain cleaned: 'float' and decimal/float removed from FormatterResolver fallback. FormatterResolverTest::chainCases() updated. NumberFormatterConfigTest validates scale auto-wire, config override, and minimum_fraction_digits. MetadataCollectTest updated (priceIntlNumberFormatter). Unit tests removed per project rule. Validation of formatter_options is handled by Symfony Configuration (no custom guard needed). Baselines: PHPStan 83 (1 entry updated for docblock change), ast-metrics 11 (same violations, metrics shifted).
10. String — string formatting rule & ucfirst option Done
Existing.StringFormatter is the simplest formatter: nullnull, otherwise (string) $value. No constructor dependency, no consumption of FormattingContext. String resolution lives in three scattered places inside FormatterResolver::resolveFallback(): phpType='string' (line 75), class with __toString() (line 99), Doctrine string/ascii_string/guid/text (line 115). Boolean, Integer and Float each have their own dedicated resolver; the string family is the last scalar type remaining in the fallback. The ucfirst option exists in FormattingContext and is consumed by ValueTranslator for booleans and enums, but StringFormatter never reads it — ucfirst has no effect on strings.
Expected.An explicit StringFormatterResolver that extracts string resolution from the fallback, as Boolean, Integer and Float do for their families. StringFormatter consumes FormattingContext to apply ucfirst when the option is enabled. By default, no added behaviour — the formatter simply casts and passes null through, as before. No max_length in the formatter (truncation is a template/presentation concern, not a value transformation).
Prerequisites.Formatter resolvers — chain of responsibility (Boolean, Integer, Float).
Plan.
  1. Create StringFormatterResolver (src/Formatters/Resolvers/StringFormatterResolver.php, tag karross.formatter.resolver). accept(): reject non-null enumType; accept 'string' PHP pure (including ?string — nullable is a value modifier, not a family change); reject any union containing string; reject any PHP type other than null/string; accept null/string PHP + Doctrine string/text/ascii_string/guid. resolve(): return StringFormatter::class. The __toString() case stays in FormatterResolver::resolvePhpClass(), not in the resolver.
  2. Enhance StringFormatter: add a constructor dependency FormattingContext $context. When $context->ucfirst is true, apply mb_strtoupper(mb_substr($value, 0, 1)) . mb_substr($value, 1) after the cast. By default (ucfirst = false), no behaviour change.
  3. Clean up FormatterResolver::resolveFallback(): remove the 'string' => StringFormatter::class case (line 75) and the Types::STRING, Types::ASCII_STRING, Types::GUID, Types::TEXT entries from resolveDoctrineType() (line 115). The __toString() case stays in resolvePhpClass().
  4. Register the service in src/Config/services.php: ->set(StringFormatterResolver::class) after FloatFormatterResolver.
  5. Update unit tests: add StringFormatterResolver to the chain in FormatterResolverTest::setUp(). Add string cases to chainCases(): phpType='string' × 5 doctrineTypes (string, text, ascii_string, guid, no mapping) + phpType=null × 4 doctrineTypes (string, text, ascii_string, guid). Add rejection cases: non-null enumType with phpType='string'. Clean up 'string' cases from the fallback test.
Realized.all-check green — 130 tests / 450 assertions. Chain cleaned: 'string' and string/ascii_string/guid/text removed from FormatterResolver fallback. StringFormatterResolver registered in the chain after Float. StringFormatter consumes FormattingContext for ucfirst (opt-in, mb_strtoupper(mb_substr())). Unit tests: 10 string cases added to chainCases() (phpType × doctrineType + null phpType) + 2 enumType rejection cases. Ast-metrics baseline updated (FormatterResolver cyclomatic complexity 28→26). The (string) cast remains as a defensive safety net; __toString() stays in resolvePhpClass().
DateTime — cas de lecture Proposed
Existing.DateTimeInterfaceDate/Time/DateTime via le raffinement Doctrine ; gestion du timezone partielle.
Expected.Sous-type raffiné par Doctrine (date/time/datetime/datetimetz) porté par le PropertyMetadata ; timezone appliqué au rendu ; cas nullable et non typé ; formatteurs existants réutilisés ; futurs pickers date/time/datetime.
Prerequisites.Collect & Computed.
Analysis.PHP ne peut pas distinguer date/time/datetime — Doctrine raffinne sans conflit. Les maillons Date/Time/DateTime sont minces : les formatteurs existants suffisent pour l'affichage MVP-1.
Enum — cas de lecture Proposed
Existing.Classe enum PHP → PropertyType::EnumEnumFormatter ; cases et labels résolus via la traduction de la valeur, sans enumType Doctrine.
Expected.Le trait enumType Doctrine fournit la classe d'enum exacte → ::cases() pour les labels et les options ; non typé avec enum type Doctrine géré ; futur widget select alimenté par les mêmes cases.
Prerequisites.Collect & Computed.
Analysis.Seul Doctrine fournit les cases — PHP seul ne peut pas les inventer. Le maillon Enum lit le enumType porté par le PropertyMetadata ; affichage et futur widget partagent la même liste de cases, de la même source.
Stringable — cas de lecture Proposed
Existing.Objets avec __toStringPropertyType::StringStringFormatter ; objets génériques sans __toStringUnknown.
Expected.La branche Stringable rendue explicite (interface Stringable ou __toString) ; objets non stringables → NotAvailable sauf si un type Doctrine les mappe ; futur widget text read-only.
Prerequisites.Collect & Computed.
Analysis.Le cas « objet stringable » est une branche nommée du collecteur (fait phpTypeStringable) — le maillon choisit le formatteur de string, sûr pour tout objet __toString.
DateInterval / BcMath\Number — cas de lecture Proposed
Existing.Non mappés — dateinterval et number tombent dans UnknownNotAvailableFormatter.
Expected.Doctrine dateinterval (PHP \DateInterval) → affichage durée ; number (PHP 8.5 \BcMath\Number) → rendu BcMath ; nouveaux formatteurs d'affichage MVP-1.
Prerequisites.Collect & Computed.
Analysis.Deux types PHP 8.5 natifs portés par des types Doctrine dédiés ; leurs maillons enregistrent les formatteurs durée et nombre dans la chaîne.
Array / structured — cas de lecture Proposed
Existing.PHP arrayPropertyType::ArrayNotAvailableFormatter ; Doctrine simple_array et json s'effondrent en Unknown ou String.
Expected.Doctrine simple_array → liste de chips ; json/jsonb → pretty-printer JSON ; type d'item deviné via Doctrine quand possible ; futurs widgets renvoyés en MVP-2.
Prerequisites.Collect & Computed.
Analysis.Les types structurés (non scalaires) n'ont pas de conteneur PHP contraignant fort — Doctrine porte la distinction. Widgets MVP-2.
SHOW page Proposed
Existing.Le contrôleur Show (src/Actions/Show.php) est un stub : il récupère l'entité via Doctrine puis appelle dd(). Pas de templates, pas de résolution de templates pour l'action SHOW, pas de handling par le responder. Le TwigResponder résout uniquement la clé ['index'] de entityMetadata->templates — SHOW n'est jamais rendu. EntityTemplateResolver et PropertyTemplateResolver ne produisent de patterns que pour l'action INDEX.
Expected.Le contrôleur Show récupère l'entité et délègue au pipeline de responders, qui rend une page de détail : toutes les propriétés affichées avec leurs valeurs formatées, les associations en liens. La résolution de templates fonctionne pour l'action SHOW avec les mêmes cascades que INDEX (par entité, par nom de propriété, par type Doctrine, fallback générique).
Prerequisites.Collect & Computed.
Plan.
  1. Contrôleur Show : supprimer le dd(), récupérer le slug via EntityMetadataRegistry::getBySlug(), construire un ActionContext, passer l'entité au ResponderManager comme le fait Index.
  2. TwigResponder : adapter la résolution de template pour supporter les actions autres que INDEX — lire la bonne clé d'action dans entityMetadata->templates[$action] (au lieu de la clé hardcodée ['index']).
  3. EntityTemplateResolver : ajouter les patterns pour l'action SHOW — rôles index (page wrapper), items (pas de table ici, mais le contenu détail), no_items (jamais pour SHOW mais cohérence), item (une propriété). Patterns : {action}_entity_{slug}, {action}.html.twig.
  4. PropertyTemplateResolver : ajouter les patterns pour SHOW — même vocabulaire que INDEX (field.html.twig, association.html.twig) avec les slots d'override par nom et par type.
  5. Templates SHOW : créer templates/show/show.html.twig (page wrapper), templates/show/field.html.twig (cellule scalaire), templates/show/association.html.twig (cellule lien). Le template show itère entityMetadata.getProperties() et affiche chaque propriété avec son label et sa valeur formatée.
  6. Tests : un test d'intégration vérifie que l'action Show rend une réponse HTTP 200 avec le contenu de l'entité. Un test E2E vérifie la navigation depuis l'index vers le show via un lien.
Identifier as navigable link Proposed
Existing.En index, la colonne identifiant (typiquement id) est rendue comme un FieldMetadata ordinaire via field.html.twig — texte brut, pas de lien. La fonction getUrl() de UrlBuilderExtension ne fonctionne qu'avec les AssociationMetadata (elle navigue via l'association pour extraire l'entité liée). L'identifiant de l'entité elle-même (EntityMetadata::getIdentifier()) n'est jamais consommé par un template.
Expected.En index, la valeur de la colonne identifiant est un lien clivable vers la page show de la même entité. Le rendu utilise le même routeur que les associations — le pattern /{prefix}/{slug}/{identifiers} existe déjà. Pas de behavior change pour les autres colonnes.
Prerequisites.SHOW page (le lien doit mener quelque part).
Plan.
  1. UrlBuilderExtension : ajouter une méthode getEntityUrl(string $action, EntityMetadata $entityMetadata, $entity): string qui construit l'URL de l'entité elle-même — elle lit $entityMetadata->getIdentifier(), extrait les valeurs via PropertyAccessor, et génère la route via RouteGenerator::routeName() + UrlGeneratorInterface::generate(). La méthode existante getUrl() reste pour les associations.
  2. PropertyTemplateResolver : dans la résolution de patterns pour INDEX, ajouter un slot prioritaire pour l'identifiant — quand $property->name est dans $entityMetadata->getIdentifier(), le pattern field_id_entity_{slug} (ou field_identifier_entity_{slug}) passe avant le pattern générique field.html.twig.
  3. Template identifier : créer templates/index/field_identifier.html.twig qui rend <a href="{{ getEntityUrl('show', entityMetadata, item) }}">{{ k_formatted_value(item, property) }}</a>. Ce template est le slot par défaut pour l'identifiant — surchargeable par l'hôte comme tous les autres.
  4. Tests : un test E2E vérifie que la colonne identifiant dans l'index est un lien et que le clic mène à la page show.
Association to-one — cas de lecture Proposed
Existing.isAssociationPropertyType::Single ; le template item rend un lien vers l'action show.
Expected.Associations to-one (ManyToOne/OneToOne) résolues depuis la cardinalité portée par le PropertyMetadata ; rendu en lien ; futur widget select renvoyé en MVP-2.
Prerequisites.Collect & Computed.
Analysis.Structure, pas nature : Doctrine isSingleValuedAssociation() répond déjà ; la case Single disparaît du vocabulaire de type.
Association to-many — cas de lecture Proposed
Existing.isAssociation + indice collection → PropertyType::Collection ; le template item rend une liste de liens.
Expected.Associations to-many (OneToMany/ManyToMany) résolues depuis la cardinalité portée par le PropertyMetadata ; rendu liste-de-liens ; futur multi-select renvoyé en MVP-2.
Prerequisites.Collect & Computed.
Analysis.Idem Association to-one — la distinction un-versus-plusieurs vient de Doctrine, pas d'un enum de type.
Untyped properties & unions — transversal Proposed
Existing.Chaîne de fallback dans PropertyTypeDetector : PHP → Doctrine (le PHPDoc est écarté — source non fiable). Les types union prennent le premier membre non-null.
Expected.Propriété non typée traitée comme chemin de premier ordre (Doctrine seul, puis NotAvailable + override config) ; unions → premier membre non-null ; nullable intégré comme modificateur dans le tableau de cas de chaque autre ticket.
Prerequisites.Tous les tickets de cas ci-dessus (pose les règles transversales par-dessus chaque maillon).
Analysis.Pas un type mais des modificateurs transversaux — la colonne « cas » de chaque ticket les liste déjà ; ce ticket consolide l'ordre de fallback et le nullable dans le collecteur.
WidgetResolver — form type mapping (chain of responsibility) Proposed
Existing.Les faits portés par PropertyMetadata (socle Collect & Computed) alimentent l'affichage/le formatage ; rien ne les mappe vers des types de form Symfony.
Expected.Un WidgetResolver en miroir de la chaîne formatter : chaque maillon lit les faits portés par PropertyMetadata → type de form + options (scale → step, length → maxlength, enum → select des cases, not-null → required) ; override config gagne ; mince pour les scalaires MVP-1, widgets riches en MVP-2.
Prerequisites.Collect & Computed (le socle) + au minimum les tickets de cas scalaires dont la lane CRUD a besoin (Boolean, Integer, Float, String).
Analysis.Contrepartie runtime-impacting de la chaîne formatter : les mêmes faits pilotent les types de form à l'écriture et leurs contraintes. Même pattern chaîne de responsabilité, vocabulaire propre. Décision REC à trancher : symfony/form non installé — service fourni par l'hôte ou dépendance du bundle. Enabler des tickets CRUD ci-dessous.
Generic create/update forms Proposed
Existing.Index/show + home actions only; Action enum holds create/update/delete as TODO.
Expected.Create and update with CSRF, validation, redirect after post, flash, flush/cascade; simple scalars first, embedded/relations deferred to MVP 2.
Prerequisites.WidgetResolver — form type mapping (chain of responsibility).
Analysis.The Action enum keeps CREATE/UPDATE/forms commented as TODO and the classes exist as empty stubs (Create, CreateForm, Update, EditForm — all __invoke(): void); routes only cover index/show + home (RouteLoader via RouteGenerator) and Action::httpMethods() is GET-only. Build on the mapping ticket: create/update GET form + POST submit, CSRF, validation, redirect-after-POST, flash, flush/cascade; simple scalars first, embedded/relations deferred to MVP-2. The symfony/form dependency is decided in the mapping ticket.
Delete action Proposed
Existing.No mutation actions.
Expected.Delete with CSRF protection, cascade-safe, redirect + flash.
Prerequisites.Generic create/update forms — shares its pipeline.
Analysis.Delete exists as an empty stub, the enum case is commented, no POST route exists and httpMethods() is GET-only. Build on the forms pipeline (7): POST delete with CSRF, cascade-safe, redirect + flash.
Minimal design decision Proposed
Existing.Zero CSS, zero tokens, zero assets; the templates (templates/base.html.twig, home.html.twig, index/*) are plain unstyled markup.
Expected.CSS strategy (design tokens + k-* classes), zero JS until the forms, dark mode?, fonts, asset location, host replacability.
Prerequisites.Browser CSS compatibility gate in CI — guards before any CSS.
Analysis.Decide with REC: vanilla CSS + custom-property tokens over the existing k-* classes (cap from the conversations), zero JS until the forms, dark mode via prefers-color-scheme?, system font stack, asset location, host replaceability (full CSS replacement vs token override), WCAG target (AA). The CI gates of 4 land before any CSS is written. Delivery stays bundle-scoped: ship the CSS ready-to-use (minified) at the canonical path fixed in 4, version it through the Symfony Asset component (asset() in the Twig templates inherits the host's version strategy — no build chain, no Encore); compression and cache headers are the host's responsibility (documented in 12, not built here).
Minimal theme — index/show + responsive Proposed
Existing.No CSS anywhere; the index/show/home templates are plain markup.
Expected.Token system, single CSS file, system fonts, validated by the Browser CSS compatibility gate in CI + the responsive budget.
Prerequisites.Minimal design decision.
Analysis.Style the existing index/show markup with the tokens of 9: one CSS file, system fonts, responsive behavior validated by the gate and the responsive budget of 4. Nothing exists today — greenfield styling. The file ships minified as decided in 9, referenced via asset().
CRUD forms styling Proposed
Existing.No styling; the forms are not yet implemented (7) and the theme is not built (10).
Expected.Thin styling layer over the stable markup of Generic create/update forms and the theme of Minimal theme — index/show + responsive.
Prerequisites.Generic create/update forms + Minimal theme — index/show + responsive.
Analysis.Adds a thin layer on top of the stable form markup (7) and the theme (10): field layout, buttons, focus-visible, error states, WCAG-AA surfaces; no new CSS system, only components. Landed once both prerequisites support the markup.
Reference documentation Proposed
Existing.The online docs cover usage (how-to) but offer no systematic reference of the whole configuration surface.
Expected.A reference section in the online docs (à la Sonata) listing every MVP-1 configuration option — routes, formatters, type overrides, template override points — curated or generated, scope to decide.
Analysis.The docs cover usage (presentation/, customization/) but have no systematic reference of the whole configuration surface. Build a reference section (à la Sonata) listing every MVP-1 option — routes, formatters, type overrides, template override points. Include a Delivery strategy section (bundle-scoped): canonical CSS asset path, Asset-component versioning, and the host-side note that compression / cache headers are the app's responsibility. Decide with REC: handwritten vs generated from the code/config; compiled progressively, complete once the MVP-1 config surface is stable.