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. |
|
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. |
|
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. |
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. |
|
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:
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. |
|
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. |
|
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 boolean → TrueFalseFormatter. 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. |
|
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. |
|
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 (price → IntlNumberFormatter). 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: null → null, 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. |
|
| 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. | DateTimeInterface → Date/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::Enum → EnumFormatter ; 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 __toString → PropertyType::String → StringFormatter ; objets génériques sans __toString → Unknown. |
|---|---|
| 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 phpType → Stringable) — 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 Unknown → NotAvailableFormatter. |
|---|---|
| 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 array → PropertyType::Array → NotAvailableFormatter ; 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. |
|
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. |
|
Association to-one — cas de lecture Proposed
| Existing. | isAssociation → PropertyType::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. |