corentin_wakdo/docs/merise/mld.md
Imugiii b8cb3ef68d docs(merise): commit P1 conception v0.1 (dictionary, MCD, MCT, MLT, MLD) + UML
Baseline of the P1 conception work produced over sessions 5-7 (was
uncommitted in the working tree). 11-entity model, French naming.
Superseded next by the prod-like revision (English, ~16 entities) per
the 2026-06-04 decision session - this commit preserves the baseline
in history before that rewrite.
2026-06-04 10:19:25 +00:00

22 KiB

Modele Logique des Donnees (MLD) - Wakdo

Phase Merise : P1 - Conception, etape 5 (apres MCD, MCT, MLT) Statut : v0.1 Date : 2026-05-28 Branche : feat/p1-conception Auteur methodologie : BYAN


1. Objet du document

Le MLD transcrit le MCD en schema relationnel formel : 1 entite -> 1 table, chaque association traduite selon sa cardinalite, contraintes referentielles materialisees, index dimensionnes pour les acces frequents.

C'est l'etape qui transforme la modelisation conceptuelle en specification implementable. Le DDL SQL (db/migrations/0001_init_schema.sql) sera derive directement de ce document a P2.

Source : dictionary.md (types et contraintes par attribut), mcd.md (entites + cardinalites + decisions reportees), mct.md (operations + entites manipulees), mlt.md (regles de gestion + transitions + protection concurrence).

Cibles :

  • MariaDB 11.4 LTS (cf. docker-compose.yml service wakdo-db)
  • Engine InnoDB (ACID, FKs, row-level locking, CHECK depuis 10.2.1)
  • Charset utf8mb4 collation utf8mb4_unicode_ci

2. Conventions de notation

Notation relationnelle

TABLE_NAME (col1, col2, #col_fk, [col_optionnelle])

  PK : col1
  UK : col2
  FK : col_fk -> AUTRE_TABLE(id)
Symbole Signification
col Colonne NOT NULL
[col] Colonne nullable
#col Colonne FK (sans le diese : non-FK)

Cette notation reste proche de l'usage Merise francais (UNIRIS, ouvrages Nanci/Espinasse) : la cle primaire est soulignee dans les documents classiques, ici on prefixe par PK pour la portabilite ASCII.

Types

Les types SQL exacts sont definis dans dictionary.md section 2 (Conventions generales) et reprecises dans chaque section de cette MLD. Conventions retenues :

  • INT UNSIGNED AUTO_INCREMENT pour toutes les PK techniques
  • INT UNSIGNED pour tous les montants en centimes (anti-FLOAT cf. dictionary note 1)
  • VARCHAR(<n>) avec longueur calibree selon dictionary note 7
  • ENUM(...) pour les valeurs metier stables (cf. dictionary note 2)
  • DATETIME pour les timestamps (pas TIMESTAMP qui ferait du fuseau auto-implicite)

3. Regles de traduction MCD -> MLD

Les regles classiques de passage MCD -> MLD appliquees :

3.1 Entite -> Table

Chaque entite du MCD devient une table. L'identifiant conceptuel id devient PK technique INT UNSIGNED AUTO_INCREMENT. Les attributs gardent leurs noms et types.

3.2 Association (1,1) - (1,N) -> FK simple

L'entite cote (1,1) porte la FK vers l'entite cote (0,N) ou (1,N). Exemple :

CATEGORIE (1,1) <--regroupe--> (0,N) PRODUIT

devient

CATEGORIE (id, libelle, ...)            -- pas de FK
PRODUIT   (id, #categorie_id, ...)      -- FK vers CATEGORIE

3.3 Association (0,N) - (0,N) ou (1,N) - (1,N) -> Table de jointure

L'association devient sa propre table avec PK composite des deux FKs. Exemple :

MENU (1,N) <--compose--> (0,N) PRODUIT (via MENU_PRODUIT)

devient

MENU_PRODUIT (#menu_id, #produit_id, role, position)
  PK composite : (menu_id, produit_id)

3.4 Association porteuse d'attributs -> Table associative

Si une association MCD porte des attributs propres (role, position sur compose), elle devient table meme si elle pourrait theoriquement etre une FK. Cas applique a MENU_PRODUIT et ROLE_PERMISSION.

3.5 Polymorphisme -> 2 FKs nullables + discriminateur

LIGNE_COMMANDE -> (PRODUIT ou MENU) traduit en 2 colonnes FK nullable + 1 colonne discriminateur :

LIGNE_COMMANDE (id, #commande_id, type_item, [#produit_id], [#menu_id], ...)
  CHECK ((type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL)
      OR (type_item='menu'    AND menu_id IS NOT NULL    AND produit_id IS NULL))

Cf. docs/notes/polymorphic-fk-snapshots.md pour la justification.

3.6 Audit (event sourcing) -> Table dediee

COMMANDE_EVENT est une table append-only, traduction directe de l'entite MCD 3.7. Aucune denormalisation user_id sur commande (cf. dictionary note 10).


4. Schema relationnel formel

Les 11 tables qui composent le schema Wakdo, ordonnees par dependance (les tables sans FK d'abord, puis les tables qui dependent d'elles).

4.1 categorie

categorie (id, libelle, slug, image_path, ordre, est_actif, created_at, updated_at)

  PK : id
  UK : libelle
  UK : slug

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • libelle VARCHAR(80) NOT NULL
  • slug VARCHAR(60) NOT NULL
  • image_path VARCHAR(255) NULL (cf. dictionary note 11)
  • ordre SMALLINT UNSIGNED NOT NULL DEFAULT 0
  • est_actif TINYINT(1) NOT NULL DEFAULT 1
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

Aucune FK. Table racine du sous-domaine Catalogue.

4.2 produit

produit (id, #categorie_id, libelle, [description], prix_ttc_cents, [image_path],
         est_disponible, ordre, created_at, updated_at)

  PK : id
  FK : categorie_id -> categorie(id) ON DELETE RESTRICT
  IDX : (categorie_id, est_disponible, ordre)

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • categorie_id INT UNSIGNED NOT NULL
  • libelle VARCHAR(120) NOT NULL
  • description TEXT NULL
  • prix_ttc_cents INT UNSIGNED NOT NULL CHECK (prix_ttc_cents > 0)
  • image_path VARCHAR(255) NULL
  • est_disponible TINYINT(1) NOT NULL DEFAULT 1
  • ordre SMALLINT UNSIGNED NOT NULL DEFAULT 0
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

ON DELETE RESTRICT sur categorie_id : impossible de supprimer une categorie qui contient des produits (protection metier, evite les orphelins).

4.3 menu

menu (id, #categorie_id, libelle, [description], prix_ttc_cents, [image_path],
      est_disponible, ordre, created_at, updated_at)

  PK : id
  FK : categorie_id -> categorie(id) ON DELETE RESTRICT
  IDX : (categorie_id, est_disponible, ordre)

Types : identiques a produit (meme structure, semantique distincte cf. dictionary note 3).

4.4 menu_produit (table associative)

menu_produit (#menu_id, #produit_id, role, position)

  PK : (menu_id, produit_id)
  FK : menu_id    -> menu(id)    ON DELETE CASCADE
  FK : produit_id -> produit(id) ON DELETE RESTRICT
  IDX : (menu_id, position)

Types :

  • menu_id INT UNSIGNED NOT NULL
  • produit_id INT UNSIGNED NOT NULL
  • role ENUM('burger','accompagnement','boisson','sauce','dessert') NOT NULL
  • position SMALLINT UNSIGNED NOT NULL DEFAULT 0

ON DELETE CASCADE sur menu_id : si un menu est supprime, ses compositions le sont aussi. ON DELETE RESTRICT sur produit_id : impossible de supprimer un produit utilise dans un menu (protection integrite menu).

Pas d'updated_at (table de jointure, cf. dictionary note 5 : les jointures sont supprimees+recreees, pas modifiees).

4.5 commande

commande (id, numero, source, mode_consommation, statut,
          total_ht_cents, total_tva_cents, total_ttc_cents, tva_taux_pourmille,
          [paye_a], created_at, updated_at)

  PK : id
  UK : numero
  IDX : (source, created_at)
  IDX : (statut, created_at)
  IDX : created_at
  CHECK : source != 'drive' OR mode_consommation = 'drive'
  CHECK : total_ttc_cents = total_ht_cents + total_tva_cents

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • numero VARCHAR(20) NOT NULL
  • source ENUM('kiosk','comptoir','drive') NOT NULL
  • mode_consommation ENUM('sur_place','a_emporter','drive') NOT NULL
  • statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NOT NULL DEFAULT 'pending_payment'
  • total_ht_cents INT UNSIGNED NOT NULL CHECK (total_ht_cents >= 0)
  • total_tva_cents INT UNSIGNED NOT NULL CHECK (total_tva_cents >= 0)
  • total_ttc_cents INT UNSIGNED NOT NULL CHECK (total_ttc_cents > 0)
  • tva_taux_pourmille SMALLINT UNSIGNED NOT NULL
  • paye_a DATETIME NULL (NULL avant paiement, timestamp du passage en paid)
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

CHECK croise source/mode_consommation (cf. dictionary note 8) : empeche les combinaisons invalides au niveau SGBD plutot que de se reposer uniquement sur le code applicatif.

CHECK montants : invariant TTC = HT + TVA verifie en base (defense-in-depth contre les bugs de calcul applicatif).

Aucune FK directe vers user : la tracabilite passe par commande_event (cf. 4.7).

4.6 ligne_commande

ligne_commande (id, #commande_id, type_item, [#produit_id], [#menu_id],
                libelle_snapshot, prix_unitaire_ttc_cents_snapshot, quantite, created_at)

  PK : id
  FK : commande_id -> commande(id) ON DELETE CASCADE
  FK : produit_id  -> produit(id)  ON DELETE RESTRICT
  FK : menu_id     -> menu(id)     ON DELETE RESTRICT
  IDX : commande_id
  CHECK : (type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL)
       OR (type_item='menu'    AND menu_id    IS NOT NULL AND produit_id IS NULL)

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • commande_id INT UNSIGNED NOT NULL
  • type_item ENUM('produit','menu') NOT NULL
  • produit_id INT UNSIGNED NULL
  • menu_id INT UNSIGNED NULL
  • libelle_snapshot VARCHAR(120) NOT NULL
  • prix_unitaire_ttc_cents_snapshot INT UNSIGNED NOT NULL CHECK (prix_unitaire_ttc_cents_snapshot > 0)
  • quantite SMALLINT UNSIGNED NOT NULL DEFAULT 1 CHECK (quantite > 0)
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

ON DELETE CASCADE sur commande_id : si la commande disparait, ses lignes aussi. ON DELETE RESTRICT sur produit_id et menu_id : impossible de supprimer un produit/menu reference par une commande historique (preserve les references meme si on snapshote).

CHECK polymorphisme : exclusivite mutuelle produit_id / menu_id selon type_item (cf. dictionary note 6).

4.7 commande_event

commande_event (id, #commande_id, event_type, [from_statut], to_statut,
                [#user_id], [payload], created_at)

  PK : id
  FK : commande_id -> commande(id) ON DELETE CASCADE
  FK : user_id     -> user(id)     ON DELETE SET NULL
  IDX : (commande_id, created_at)
  IDX : (user_id, created_at)
  IDX : (event_type, created_at)

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • commande_id INT UNSIGNED NOT NULL
  • event_type ENUM('CREATED','PAID','PREPARING_STARTED','READY','DELIVERED','CANCELLED') NOT NULL
  • from_statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NULL
  • to_statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NOT NULL
  • user_id INT UNSIGNED NULL
  • payload JSON NULL
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

ON DELETE CASCADE sur commande_id : si la commande est purgee, son journal disparait avec elle. ON DELETE SET NULL sur user_id : si un equipier est supprime, les events restent (l'audit reste consultable, l'attribution individuelle est perdue).

Pas d'updated_at : table append-only. Aucun UPDATE applicatif autorise (cf. mlt.md RG-T10).

Pas de CHECK croise from_statut/to_statut : la verification de la machine a etats est applicative (mlt section 12), un CHECK SQL serait trop rigide (event_type peut prendre des valeurs non encore prevues).

4.8 user

user (id, email, password_hash, nom, prenom, #role_id, est_actif, [last_login_at],
      created_at, updated_at)

  PK : id
  UK : email
  FK : role_id -> role(id) ON DELETE RESTRICT
  IDX : (est_actif, role_id)

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • email VARCHAR(254) NOT NULL (RFC 5321)
  • password_hash VARCHAR(255) NOT NULL (argon2id, cf. .env PASSWORD_ALGO)
  • nom VARCHAR(60) NOT NULL
  • prenom VARCHAR(60) NOT NULL
  • role_id INT UNSIGNED NOT NULL
  • est_actif TINYINT(1) NOT NULL DEFAULT 1
  • last_login_at DATETIME NULL
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

ON DELETE RESTRICT sur role_id : impossible de supprimer un role qui a encore des users (passer par est_actif = 0 sur le role avant de supprimer).

4.9 role

role (id, code, libelle, [description], est_actif, created_at, updated_at)

  PK : id
  UK : code

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • code VARCHAR(40) NOT NULL
  • libelle VARCHAR(80) NOT NULL
  • description TEXT NULL
  • est_actif TINYINT(1) NOT NULL DEFAULT 1
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

Aucune FK. Table racine du sous-domaine RBAC.

4.10 permission

permission (id, code, libelle, [description], created_at)

  PK : id
  UK : code

Types :

  • id INT UNSIGNED AUTO_INCREMENT
  • code VARCHAR(60) NOT NULL (format <resource>.<action>)
  • libelle VARCHAR(120) NOT NULL
  • description TEXT NULL
  • created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

Pas d'updated_at : les permissions sont declarees en migration et ne sont pas modifiees via UI (cf. RBAC statique cote permissions, dictionary 3.10 et MCD 4.3).

4.11 role_permission (table associative)

role_permission (#role_id, #permission_id)

  PK : (role_id, permission_id)
  FK : role_id       -> role(id)       ON DELETE CASCADE
  FK : permission_id -> permission(id) ON DELETE CASCADE
  IDX : permission_id  (acces inverse "quels roles ont cette permission ?")

Types :

  • role_id INT UNSIGNED NOT NULL
  • permission_id INT UNSIGNED NOT NULL

ON DELETE CASCADE des deux cotes : suppression d'un role ou d'une permission supprime ses mappings.

Pas de timestamps (table de jointure pure, cf. dictionary note 5).


5. Recapitulatif des contraintes referentielles

FK Reference ON DELETE Justification
produit.categorie_id categorie(id) RESTRICT Pas d'orphelin produit
menu.categorie_id categorie(id) RESTRICT Idem
menu_produit.menu_id menu(id) CASCADE Composition disparait avec le menu
menu_produit.produit_id produit(id) RESTRICT Pas de cascade : un produit reference dans un menu ne peut pas etre supprime sans amender la composition
commande.-- (aucune FK vers user) - Tracabilite via commande_event
ligne_commande.commande_id commande(id) CASCADE Lignes disparaissent avec la commande
ligne_commande.produit_id produit(id) RESTRICT Preserve l'integrite historique
ligne_commande.menu_id menu(id) RESTRICT Idem
commande_event.commande_id commande(id) CASCADE Journal disparait avec la commande
commande_event.user_id user(id) SET NULL Audit conserve, attribution individuelle perdue
user.role_id role(id) RESTRICT Pas d'user sans role
role_permission.role_id role(id) CASCADE Mapping disparait avec le role
role_permission.permission_id permission(id) CASCADE Mapping disparait avec la permission

Cles :

  • CASCADE : la donnee dependante n'a pas de sens hors de son parent (lignes / events / mappings)
  • RESTRICT : suppression du parent bloquee tant que des references existent (catalogue, role)
  • SET NULL : preserve la donnee enfant en perdant le lien (audit event sans attribution)

6. Index complementaires

Au-dela des PK / UK / FK qui creent des index automatiquement, indexes ajoutes pour les requetes frequentes identifiees au MCT/MLT :

Table Index Justification (operation MCT)
produit (categorie_id, est_disponible, ordre) Chargement catalogue kiosk (op 1) : filtre par categorie + disponible + tri par ordre
menu (categorie_id, est_disponible, ordre) Idem produit
menu_produit (menu_id, position) Chargement composition d'un menu
commande (source, created_at) Stats "par canal" + tri chronologique
commande (statut, created_at) Files d'attente preparation/accueil (ops 6, 9)
commande created_at Stats agregations live
ligne_commande commande_id Recuperation des lignes d'une commande
commande_event (commande_id, created_at) Historique d'une commande
commande_event (user_id, created_at) Actions d'un equipier sur une periode
commande_event (event_type, created_at) Stats "combien de cancellations cette semaine ?"
user (est_actif, role_id) Login + permissions check (op 23)
role_permission permission_id Acces inverse "quels roles ont cette permission ?"

Index NON ajoutes (volontaire) :

  • commande.numero : UK suffit, pas de range query attendue dessus
  • commande.mode_consommation : faible cardinalite (3 valeurs), un index n'est pas rentable, full scan acceptable
  • commande.paye_a : NULL pour la majorite des lignes (commande encore en cours), index peu utile

7. Contraintes CHECK (MariaDB 10.2+)

Verification au niveau SGBD pour les invariants critiques. Defense-in-depth contre les bugs applicatifs.

Table CHECK Pourquoi
produit prix_ttc_cents > 0 Prix nul ou negatif = bug
menu prix_ttc_cents > 0 Idem
commande total_ht_cents >= 0 Plancher autorise (commande vide transitoire ?)
commande total_tva_cents >= 0 Idem
commande total_ttc_cents > 0 TTC nul = bug
commande total_ttc_cents = total_ht_cents + total_tva_cents Invariant arithmetique
commande source != 'drive' OR mode_consommation = 'drive' Contrainte croisee (dictionary note 8, mlt RG-T09)
ligne_commande prix_unitaire_ttc_cents_snapshot > 0 Snapshot prix non nul
ligne_commande quantite > 0 Quantite non nulle
ligne_commande (type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL) OR (type_item='menu' AND menu_id IS NOT NULL AND produit_id IS NULL) Polymorphisme exclusif (dictionary note 6)

8. Cross-validation entites MCD -> tables MLD

Entite MCD Table MLD Notes
categorie (3.1) categorie (4.1) 1:1
produit (3.2) produit (4.2) 1:1
menu (3.3) menu (4.3) 1:1
menu_produit (3.4) menu_produit (4.4) Entite associative -> table de jointure avec PK composite
commande (3.5) commande (4.5) 1:1, attribut source ajoute (decision 2026-05-28)
ligne_commande (3.6) ligne_commande (4.6) 1:1, polymorphisme materialise par 2 FKs nullables + CHECK
commande_event (3.7) commande_event (4.7) 1:1, table append-only, decision 2026-05-28
user (3.8) user (4.8) 1:1
role (3.9) role (4.9) 1:1
permission (3.10) permission (4.10) 1:1
role_permission (3.11) role_permission (4.11) Entite associative -> table de jointure avec PK composite

Conclusion : 11/11 entites tracees. Aucune entite MCD ne reste sans table, aucune table MLD ne sort du modele conceptuel.


9. Estimation volumes et taille

Table Volume 6 mois Taille moyenne ligne Taille totale
categorie ~10 200 octets < 1 Ko
produit ~70 400 octets ~30 Ko
menu ~15 400 octets ~6 Ko
menu_produit ~80 30 octets ~2 Ko
commande ~30k 300 octets ~9 Mo
ligne_commande ~150k 200 octets ~30 Mo
commande_event ~180k 200 octets ~36 Mo
user ~20 500 octets ~10 Ko
role ~5 200 octets ~1 Ko
permission ~40 300 octets ~12 Ko
role_permission ~80 30 octets ~2 Ko

Total : ~75 Mo sur 6 mois. Largement gerable par MariaDB sur le conteneur Wakdo (volume wakdo_db_data named volume, cf. docker-compose.yml).

Les indexes ajoutent typiquement 30-50% du volume des tables, soit ~30 Mo supplementaires. Estimation totale : ~100-110 Mo / 6 mois.


10. Decisions reportees au DDL et a P2

Les decisions suivantes sont laissees au DDL (db/migrations/0001_init_schema.sql) ou aux phases ulterieures, parce qu'elles concernent l'implementation et pas la modelisation logique :

  1. Triggers ou colonnes generees : service_day (cf. PROJECT_CONTEXT section 2) pourrait etre une GENERATED ALWAYS AS (DATE_SUB(created_at, INTERVAL 4 HOUR + 30 MINUTE)) virtuelle pour eviter le calcul applicatif. A evaluer en P3 si les stats sont penibles.
  2. Partitionnement : commande_event pourrait etre partitionne par mois si le volume depasse les estimations. Pas pour MVP.
  3. Foreign Key index : MariaDB cree automatiquement un index sur la FK lors de la declaration, sauf si un index utilisable existe deja. A verifier explicitement dans le DDL.
  4. Collation : utf8mb4_unicode_ci retenu (sensible diacritiques et casse pour les recherches). Si besoin de tri locale francais strict, passer en utf8mb4_fr_0900_ai_ci (MySQL 8) ou rester en unicode_ci.
  5. Engine : InnoDB par defaut (ACID + FKs). Pas de MEMORY ni Archive.
  6. Charset emoji : utf8mb4 (4 octets / char max) couvre les emojis au cas ou ils apparaitraient dans description produit ou payload JSON.

11. A faire au prochain sprint (DDL + Seed)

  1. DDL (db/migrations/0001_init_schema.sql) : transcrire ce MLD en CREATE TABLE executables, dans l'ordre des dependances (categorie -> produit/menu -> menu_produit -> commande -> ligne_commande/commande_event ; permission -> role -> role_permission ; user en dernier).

  2. Seed (db/seeds/0001_demo_data.sql) : INSERT pour :

    • 9 categories + 53 produits + 13 menus a partir des JSON sources (docs/merise/_sources/), prix normalises en centimes
    • 1 admin par defaut + roles (admin, manager, equipier-comptoir, equipier-drive)
    • Permissions declarees (CRUD produit/menu/categorie/user/role + commande operationnelles)
    • Quelques commandes d'exemple pour les demos
  3. Export fallback JSON (scripts/export-fallback.{sh|php}) : extrait des donnees seed vers src/public/borne/data/*.json pour le mode "Bloc 1 isole" (kiosk sans BDD pour les tests).

  4. Tests de validation DDL : verifier que :

    • Toutes les CHECK contraintes sont declenchees comme attendu (tests d'integration)
    • Les ON DELETE CASCADE / RESTRICT se comportent comme specifie
    • Les indexes accelerent reellement les requetes ciblees (EXPLAIN sur les requetes types du MCT)
  5. Migration tooling : decider de l'outil (phinx, doctrine migrations, ou homemade PHP script). Cf. PROJECT_CONTEXT pour le choix retenu.