# Suivi du chantier — blog multilingue, back-office et bascule MySQL

Document vivant. Branche de travail : `feat/wip` (partie de `main` @ 27925ab).
Dernière mise à jour : 08/08/2026. Suites de tests :

| Où | Configuration | Résultat |
|---|---|---|
| conteneur Sail | `php artisan test` (SQLite en mémoire) | **308 verts, 1015 assertions** |
| conteneur Sail | `php artisan test -c phpunit.mysql.xml` | **351 verts, 1143 assertions, 0 ignoré** |

Mesuré le 08/08/2026, après le lot A.7 et la correction de D-14.

Sur l'hôte, `php artisan test -c phpunit.mysql.xml` ignore 3 tests (restauration de
sauvegarde : `mysqldump` est absent de l'environnement WSL). **La référence est donc le
conteneur Sail**, où le binaire est présent et où rien n'est ignoré.

```bash
docker compose exec -T -e DB_HOST=mysql -e DB_PORT=3306 -e DB_DATABASE=testing \
  laravel.test php artisan test -c phpunit.mysql.xml
```

Légende : ✅ fait · 🚧 en cours · ⬜ à faire · ⏸️ reporté (raison indiquée)

---

## ⏸️ Reprise — lire ceci en premier

**Le chantier est terminé côté code : lots 0 à 4 faits, recette générale jouée le
02/08/2026, plus sept demandes du client traitées après livraison (A.1 à A.7).** Les deux
suites sont vertes, le front répond dans les trois langues, le back-office est complet, le
déploiement est scripté et documenté. Ce qui reste n'est plus du développement mais de
l'exploitation : deux blocages serveur (MySQL, planificateur) et une décision SEO en
attente (dette D-4).

### État du dépôt

🚧 **Le lot A.7 est dans l'arbre de travail, pas encore commité** — statut par langue,
recopie du français, retrait des champs SEO, renommage du slug, finitions du front, plus la
correction de D-14 et deux correctifs trouvés en relecture. **À commiter avant tout
déploiement** : la migration `add_status_to_post_translations` en fait partie, et déployer
le code sans elle rendrait le blog et le back-office inutilisables (`post_translations.status`
est lu par `scopePublishedIn()`).

Les commits de fond, à lire dans cet ordre pour comprendre le chantier — les commits
`docs(suivi)` n'y figurent pas, ils ne font que tenir ce document à jour :

| Commit | Contenu |
|---|---|
| `a8d6efc` | Lots 0 à 3.1 — socle, bascule MySQL, front du blog, installation de Filament |
| `a87b08a` | Lot 3.2 — accès au back-office (`users.is_admin`) |
| `cdf4e77` | Lot 3.3 — `PostResource` |
| `885cfef` | Lots 3.4 et 3.5 — `CategoryResource` et prévisualisation |
| `dbc769b` | Suivi remis au propre pour une reprise à froid |
| `e605215` | Lot 4 — `deploy.sh`, documentation de livraison, suppression de `build/` |
| `d9f6127` | Après-livraison A.1 à A.5 — seeder, tableau de bord, compte, navbar, contenu en blocs |
| `b21a711` | Après-livraison A.6 — ménage automatique des images de blocs (résout D-15) |
| _à venir_ | Après-livraison A.7 — statut par langue, recopie du français, allègement du back-office, D-14 |

Rien n'est poussé ni fusionné : la branche part de `main` @ 27925ab.

### Reprendre en trois commandes

```bash
git switch feat/wip && git status --short

# Ports décalés : cohabite avec la stack Docker d'un autre projet (cf. piège n° 1).
WWWUSER=$(id -u) WWWGROUP=$(id -g) APP_PORT=8090 VITE_PORT=5174 \
  REDIRECT_SERVER_PORT=8100 REVERB_SERVER_PORT=8110 \
  docker compose up -d --no-deps mysql laravel.test

docker compose exec -T -u sail laravel.test php artisan test          # 276 passed
docker compose exec -T -u sail -e DB_HOST=mysql -e DB_PORT=3306 -e DB_DATABASE=testing \
  laravel.test php artisan test -c phpunit.mysql.xml                  # 319 passed, 0 skipped

# entrer dans le back-office
docker compose exec -u sail laravel.test php artisan mostiglass:admin <email>
```

### Six pièges de l'environnement local, à ne pas redécouvrir

1. **`localhost:80` sur l'hôte appartient à un autre projet.** Ce n'est pas un « Apache
   tiers » comme noté à l'origine, et ce n'est pas toujours le même projet : `fitnessclic`
   le 02/08, `getinside-monorepo/backend` le 03/08. **Ne pas supposer lequel — le
   demander à Docker** :
   ```bash
   docker inspect <conteneur> --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'
   ```
   **Il n'est pas nécessaire d'arrêter l'autre stack**, contrairement à ce qui était noté
   ici. Le MySQL de recette est sur **3308**, que les autres projets n'occupent pas ; il
   suffit de décaler les ports publiés et de ne monter que le nécessaire :
   ```bash
   WWWUSER=$(id -u) WWWGROUP=$(id -g) APP_PORT=8090 VITE_PORT=5174 \
     REDIRECT_SERVER_PORT=8100 REVERB_SERVER_PORT=8110 \
     docker compose up -d --no-deps mysql laravel.test
   ```
   `--no-deps` évite de réveiller redis, meilisearch, mailpit et Dolibarr, qui eux se
   disputent 6379, 7700, 1025 et 8025 — et dont les tests n'ont pas besoin. Arrêter la
   stack d'un collègue pour lancer une suite de tests n'a jamais été nécessaire.
   ⚠️ Le healthcheck du service `mysql` rapporte `unhealthy` alors que le serveur répond
   parfaitement (`mysqladmin ping -p` avec un mot de passe vide). Ne pas s'y fier :
   vérifier par une vraie connexion PDO depuis le conteneur applicatif.
   Toute vérification HTTP doit de toute façon passer par
   `docker compose exec laravel.test curl http://localhost/…`.
2. **`docker compose exec` entre en `root` par défaut** si `WWWUSER` n'est pas exporté.
   Utiliser `-u sail`, sinon on retombe droit dans le piège n° 3.
3. **Ne jamais lancer `artisan` en tant que root dans le conteneur** pour ce qui écrit des
   fichiers (`sitemap:generate`, `db:backup`, `filament:optimize`) : les fichiers
   deviennent non réinscriptibles. C'est arrivé deux fois — la seconde pendant le lot 4.1,
   où un `filament:optimize` lancé en root a fait échouer le déploiement suivant sur
   `bootstrap/cache/filament/`. Réparation : `chown -R sail:sail storage bootstrap/cache`.
4. **`php artisan serve` sert `public/int/` en statique**, ce qui masque les routes
   `/int/*` de `routes/web.php` en développement. En production (nginx), seuls les
   fichiers réellement présents sont servis ainsi. Ne pas conclure d'un 404 local que la
   redirection héritée est cassée — cf. LOT 4.3.
5. **`public/storage` doit être un lien SYMBOLIQUE RELATIF.** `php artisan storage:link`
   crée un lien absolu vers `/var/www/mostiglass/…`, chemin qui n'existe pas dans le
   conteneur — où l'application vit sous `/var/www/html`. Le lien y est donc cassé et
   **toutes les images renvoient 404**, sans que rien ne le signale. `--relative` exige
   `symfony/filesystem`, absent : le lien a été refait à la main,
   `ln -s ../storage/app/public public/storage`, ce qui fonctionne des deux côtés. Sur le
   serveur, où il n'y a pas de double chemin, `storage:link` convient tel quel.
6. **Composer est épinglé sur PHP 8.2.32** (`config.platform.php`), la version de
   production. Les résolutions se font donc pour 8.2 quelle que soit la version qui lance
   `composer` — c'est voulu. Ne pas retirer ce pinning sans refaire un audit serveur.

### Où on s'est arrêté exactement

**LOT 1 terminé**, 1.6 compris : la recette de bascule a été jouée en local, la stack
`docker-compose.yml` embarquant tout le nécessaire (MySQL, Dolibarr + sa MariaDB,
Mailpit). Ne restent que des tâches d'exploitation, listées en « Prérequis production ».

**LOT 2 terminé**, 2.1 → 2.6. La réserve sur le 2.4 est levée : le 3.1c a apporté
medialibrary et le sanitizer, donc la couverture et le rendu du HTML riche.

**LOT 3 terminé**, 3.1 → 3.5. Accès verrouillé sur `users.is_admin`, articles et
catégories éditables en trois langues avec médias et champs SEO, prévisualisation des
brouillons — directe pour un administrateur, par lien signé temporaire pour un relecteur
sans compte.

**LOT 4 terminé**, 4.1 → 4.3. `deploy.sh` écrit **et exécuté pour de vrai** dans le
conteneur, quatre documents de livraison, recette générale de bout en bout jouée contre
les vrais services. Elle n'a **modifié aucun code existant** : les trois défauts qu'elle a mis au jour
touchent des URLs publiques et sont laissés à l'arbitrage, mesures à l'appui.

### Prochaine étape

Le chantier est en attente de l'hébergeur (MySQL 8 et planificateur). Cinq choses,
dans cet ordre :

1. **Pousser la branche.** `feat/wip` a douze commits d'avance sur `main` et **n'a jamais
   été poussé** : le chantier n'existe que sur la machine de développement. `main` en est
   un ancêtre direct, la fusion est un fast-forward.
2. ~~Appliquer D-14~~ — ✅ **fait le 08/08/2026** avec le lot A.7 : `config/backup.php`,
   quatre tests dont un garde-fou. Plus aucun défaut de code identifié en attente.
3. **Cadrer la demande à l'hébergeur** : MySQL 8 d'Oracle et **non** MariaDB (sur
   Debian 12, une demande de « MySQL » installe MariaDB — cf. `doc/mysql.md` § 0), et un
   planificateur, faute de quoi il n'y aura aucune sauvegarde.
4. **Arbitrer les URLs publiques** — D-4, D-13 et D-11 ensemble, pour ne poser les 301
   définitifs qu'une seule fois. Détail et mesures au LOT 4.3.
5. **Rejouer `doc/mysql.md` § 6 sur la production** le jour de la bascule, puis déployer
   avec `./deploy.sh` en suivant `doc/deploiement.md`.

### Deux blocages serveur, indépendants du code

Ils empêchent la mise en production, pas la poursuite du développement :

1. **MySQL n'est pas installé** (prérequis n° 2). Cible confirmée : **MySQL 8**, et non
   le MariaDB que donnent les dépôts Debian par défaut — cf. `doc/mysql.md` § 0 pour les
   commandes d'installation.
2. **Il n'y a pas de planificateur** (prérequis n° 6) : `crontab` est introuvable sur le
   serveur. En l'état, `db:backup` et `sitemap:generate` **ne tourneraient jamais** —
   donc aucune sauvegarde. Trois formes de déclencheur sont prêtes dans
   `doc/backups.md` § Planification, dont un timer systemd si le serveur n'a
   effectivement pas de cron.

### État des lieux local (données de travail)

La base de développement et le Dolibarr local contiennent des données de recette :
quelques articles de démonstration, deux devis et leurs tiers, des propositions
commerciales. Sans importance, mais à savoir avant de s'étonner de leur présence.

### Décisions en attente d'une réponse

1. **Les deux blocages serveur ci-dessus** — l'installation de MySQL 8 et le
   planificateur. Ce sont les seuls vrais goulots d'étranglement restants.
2. **Dette D-4 : deux pages anglaises vivent sous un slug français.**
   `/en/legal-notices` et `/en/venturi-effect-and-application` répondent **404** ; les
   pages sont servies sous `/en/mentions-legales` et `/en/effet-venturi-et-application`.
   Cause et correctif prêts au LOT 4.3. À trancher parce que cela **change deux URLs
   publiques** — le correctif prévoit les 301 qui vont avec.
3. Le message du client va désormais dans la **note interne** (`note_private`) du devis
   Dolibarr. À basculer en `note_public` si l'on veut qu'il figure sur le PDF envoyé au
   client (cf. LOT 1.6).
4. **Les huit stubs de `public/int/*.php`** (redirections de l'ancien site) émettent un
   **302** et pointent vers des URLs sans préfixe de langue, qui re-redirigent en 301.
   Deux sauts au lieu d'un, et le premier n'est pas permanent. Volontairement non touché
   pendant le lot 4 : ce sont des fichiers hérités qui portent du référencement réel.
   Cf. LOT 4.3.

---

## Vue d'ensemble

| Lot | Périmètre | Annoncé | Réaliste | État |
|---|---|---|---|---|
| 0 | Socle partagé (404, schéma, modèles) | — | 0,4 j | ✅ (hors 0.1, reporté) |
| 1 | Bascule MySQL, recette devis, sauvegardes | 0,5 j | 0,75 j | ✅ (recette jouée en local ; reste à rejouer sur la prod le jour J) |
| 2 | Front du blog | 2,0 j | 2,1 j | ✅ 2.1 → 2.6 |
| 3 | Back-office Filament | 2,75 j | 2,75 j | ✅ 3.1 → 3.5 |
| 4 | Livraison (déploiement, doc, recette) | 0,75 j | 0,75 j | ✅ 4.1 → 4.3 |
| **Total** | | **6,0 j** | **6,75 j** | chantier terminé côté code |

Le plan détaillé de référence est dans `~/.claude/plans/cr-er-un-plan-pour-adaptive-crane.md`.

---

## LOT 0 — Socle partagé

| # | Tâche | État |
|---|---|---|
| 0.1 | `php ^8.2` + `config.platform.php = 8.2.32` dans `composer.json` | ✅ fait au 3.1a |
| 0.2 | Vraie page 404, suppression du 301 global | ✅ |
| 0.3 | Migration des 4 tables du blog | ✅ |
| 0.4 | Enum, modèles, factories | ✅ |
| 0.5 | Publication programmée sans cron (via le scope) | ✅ inclus dans 0.4 |

**Fichiers créés**
- `resources/views/errors/404.blade.php`
- `database/migrations/2026_07_31_120000_create_blog_tables.php`
- `app/Enums/PostStatus.php`
- `app/Models/{Post,PostTranslation,Category,CategoryTranslation}.php`
- `app/Support/PostPreview.php`
- `database/factories/{Post,PostTranslation,Category,CategoryTranslation}Factory.php`
- `tests/Feature/NotFoundPageTest.php`, `tests/Unit/PostPublicationTest.php`

**Fichiers modifiés**
- `app/Exceptions/Handler.php` (301 → vrai 404)
- `lang/{fr,en,es}/global.php` (clés `404.*`)
- `resources/views/_partials/head/hreflang.blade.php` (garde `(string)` sur `currentRouteName()`, null sur la route fallback)

**Vérifié**
- `migrate` **et** `migrate:rollback` passent sur SQLite
- les redirections héritées (`/int/*.html`, `/index_pc_*.html`, `/int/{slug}`) sont des routes explicites : elles survivent à la suppression du 301
- `NotFoundPageTest` casse si le 301 global revient

**Reste du lot, fait au 3.1c** ✅
- ✅ `Post implements HasMedia` + `InteractsWithMedia`, collections `cover`/`og`, conversions WebP `thumb`/`card`/`hero`/`og` en `->nonQueued()`
- ✅ `PostTranslation::setBodyAttribute()` + `App\Support\BlogHtml` (assainissement du HTML à l'écriture)
- ✅ `PostStatus implements HasLabel, HasColor` (contrats Filament)

---

## LOT 1 — Bascule MySQL, recette devis, sauvegardes

| # | Tâche | État |
|---|---|---|
| 1.1 | `total` en `decimal(12,2)` + sécurisation du `save()` | ✅ |
| 1.2 | Connexion `sqlite_legacy` + commande `db:migrate-legacy-sqlite` | ✅ |
| 1.3 | Commande `db:backup` + `Kernel::schedule()` | ✅ (ligne de cron à poser sur le serveur) |
| 1.4 | `phpunit.mysql.xml` + `tests/Database/` | ✅ (3 tests de restauration en attente de `mysqldump`) |
| 1.5 | `doc/mysql.md`, `doc/backups.md`, mise à jour `doc/env.md` | ✅ (placeholders `<CHEMIN_APP>` / `<BINAIRE_PHP>` à remplacer après l'audit serveur) |
| 1.6 | Recette devis + Dolibarr sur MySQL | ✅ jouée sur la stack locale complète — **2 défauts trouvés et corrigés** |

**1.1 — fait**

Fichiers modifiés : `database/migrations/2024_02_29_140015_create_product_helper_results_table.php`,
`app/Livewire/ProductHelper.php`, `config/mail.php` (clé `alerts`),
`lang/{fr,en,es}/product_helper.php` (clé `error.quotation_not_saved`).
Fichiers créés : `app/Mail/QuotationNotArchived.php`,
`resources/views/mail/quotation_not_archived.blade.php`,
`tests/Feature/ProductHelperSaveFailureTest.php`.

Comportement obtenu : si l'écriture en base échoue, le devis part quand même vers
Dolibarr et par mail, une alerte `[URGENT]` contenant la demande complète est envoyée
à `MAIL_ALERTS_RECIPIENT`, l'incident est logué en `critical`, et le client voit un
message au lieu d'un formulaire muet. Les 9 tests existants du devis passent inchangés.

**1.2 — fait**

`config/database.php` : connexion `sqlite_legacy`, sur sa **propre** variable
`DB_LEGACY_SQLITE_DATABASE` (la connexion `sqlite` lit `DB_DATABASE`, qui vaudra le nom
de la base MySQL — elle pointerait donc sur un fichier nommé « mostiglass »).

`app/Console/Commands/MigrateLegacySqlite.php` → `php artisan db:migrate-legacy-sqlite`
(`--dry-run`, `--verify`, `--truncate`, `--from`, `--to`, `--table`). Valide **tous** les
JSON avant toute écriture, insère dans une transaction en préservant `id` et
horodatages, arrondit les décimaux, puis relit les deux côtés et compare des empreintes
`ksort` récursives (MySQL réordonne les clés JSON : une comparaison octet à octet
produirait des faux positifs).

`tests/Feature/MigrateLegacySqliteCommandTest.php` — **7 cas**, transfert réel depuis une
base au format historique (JSON en TEXT, `total` en INTEGER) vers le schéma actuel, avec
un jeu de données qui reproduit ce qui casse un `sqlite3 .dump | mysql` : apostrophes et
accents dans les colonnes JSON, émoji, total flottant à 13 décimales. Couvre aussi
`--dry-run` (n'écrit rien), l'abandon sans écriture sur JSON illisible, le refus d'une
cible non vide sans `--truncate`, la détection d'une divergence par `--verify`, et
l'absence de table source. **La commande est donc prouvée avant la fenêtre de
maintenance.**

**1.3 — fait**

`app/Support/SqlDumpArchive.php` (contrôle d'archive + rétention) et
`app/Console/Commands/DatabaseBackup.php` → `php artisan db:backup`. Le contrôle et la
rétention sont isolés de la commande précisément pour être testables sans MySQL : une
sauvegarde qu'on ne sait pas relire n'est pas une sauvegarde.

Propriétés : identifiants lus depuis `config('database.connections.*')` (une seule source
de vérité, rien à redéclarer en crontab), `MYSQL_PWD` plutôt que `-p…` (le mot de passe
n'apparaît pas dans `ps`), `--single-transaction` (pas de verrou sur le site),
`--no-tablespaces` (évite d'exiger le privilège global `PROCESS`), flux compressé en
`.sql.gz`, purge à `BACKUP_KEEP_DAYS` **sans jamais toucher au sous-dossier `manual/`**.

`app/Console/Kernel.php` : `db:backup` planifiée à 03:15 Europe/Paris,
`withoutOverlapping(120)`, alerte par `emailOutputOnFailure(config('mail.alerts'))` — donc
indépendante de la stack de logs (cf. D-1).

`tests/Unit/SqlDumpArchiveTest.php` (12 cas) + `tests/Feature/DatabaseBackupCommandTest.php`
(5 cas) : archive valide, tronquée, sans la table attendue, vide, non gzip, absente,
plus grosse qu'un lot de lecture ; purge respectant `manual/` et la rétention à 0 ;
refus d'une connexion non MySQL ; destination inutilisable ; planification à `15 3 * * *`.

`.env.example` documente `DB_LEGACY_SQLITE_DATABASE`, `BACKUP_KEEP_DAYS`, `BACKUP_PATH`
et `MAIL_ALERTS_RECIPIENT`.

**Deux enseignements de ces tests, à ne pas reperdre**

- `gzopen()` lit aussi les fichiers **non compressés**, en clair et sans broncher : une
  archive en texte brut nommée `.sql.gz` passait le contrôle alors que le `gunzip -c` de
  la restauration l'aurait rejetée. Le contrôle vérifie donc désormais les octets
  magiques gzip (`1f 8b`) : on valide ce que `gunzip` acceptera, pas ce que zlib tolère.
- `db:backup` doit être testée aussi sur le chemin **accepté** (`doesntExpectOutputToContain`),
  pas seulement sur le refus : une erreur de lecture de configuration faisait échouer la
  commande pour la mauvaise raison, en laissant le test vert.

**1.4 — fait**

`phpunit.mysql.xml` rejoue **toute** la suite sur un vrai MySQL (et pas seulement les
tests spécifiques), plus une suite `Database` de 43 tests qui ne valident que des
comportements que SQLite ne sait pas reproduire. Cible par défaut : le service `mysql`
du `docker-compose.yml`, vu depuis l'hôte sur le port 3308.

Les variables `DB_*` du fichier ne sont **pas** forcées : une variable déjà présente
dans l'environnement l'emporte, donc `DB_HOST=mysql DB_PORT=3306 sail test -c
phpunit.mysql.xml` fonctionne depuis le conteneur, et `DB_DATABASE=mostiglass_test`
fonctionne sur le serveur — sans toucher au fichier.

`tests/TestCase.php` gagne un garde-fou : la suite refuse de démarrer si le nom de la
base ciblée ne contient pas « test ». `RefreshDatabase` fait un `migrate:fresh` ; un
`DB_DATABASE` exporté par erreur dans le shell aurait suffi à vider la base applicative.

Fichiers créés : `phpunit.mysql.xml`, `tests/Database/` (7 fichiers).
Fichiers modifiés : `tests/TestCase.php`, `tests/Feature/ProductHelperSaveFailureTest.php`,
`tests/Feature/DatabaseBackupCommandTest.php`.

**Ce que la suite MySQL prouve, et que la suite SQLite ne pouvait pas**

| Fichier | Objet |
|---|---|
| `MysqlSchemaTest` (14 cas) | `sql_mode` strict et `utf8mb4` actifs ; `total` réellement en `decimal(12,2)` ; `steps`/`user`/`products` réellement en `json` ; `body` en `longtext` ; slugs en `varchar(191)` ; tables en InnoDB/utf8mb4 ; index uniques présents |
| `StrictModeTest` (6 cas) | le montant flottant atterrit à deux décimales exactes (bug D1, par le vrai parcours Livewire) ; refus d'un total hors capacité, d'un slug trop long, d'une meta description trop longue — avec la contre-épreuve à 500 caractères |
| `JsonColumnTest` (5 cas) | accents, apostrophes et émoji intacts ; MySQL **refuse** un JSON invalide ; MySQL **réécrit** le JSON stocké (clés réordonnées) ; requête par chemin JSON |
| `BlogConstraintTest` (8 cas) | unicité `(locale, slug)` et `(post_id, locale)` réellement appliquée ; suppression en cascade des traductions ; `nullOnDelete` sur la catégorie ; refus des références orphelines ; `published_at` sans décalage de fuseau |
| `LegacySqliteToMysqlTest` (6 cas) | **la bascule pour de vrai**, SQLite → MySQL, avec `--verify`, `--dry-run`, `--truncate` et l'abandon sur JSON illisible |
| `MysqlMigrationRollbackTest` (1 cas) | `migrate:reset` puis `migrate` passent sur InnoDB — donc l'ordre des `down()` respecte les clés étrangères |
| `DatabaseBackupRoundTripTest` (3 cas) | dump réel → restauration réelle dans une base neuve → relecture. **Ignoré ici faute de `mysqldump`** |

`mysqldump` n'est pas installé sur la machine de développement (WSL, pas de `sudo` sans
mot de passe) et le binaire du conteneur ne sert à rien : `db:backup` lance le processus
côté hôte. Les trois tests s'ignorent donc d'eux-mêmes plutôt que d'échouer.

Pour ne pas livrer du code de test jamais exécuté, ils ont été **vérifiés une fois** en
plaçant en tête de `PATH` un script jetable `mysqldump` qui relaie vers le conteneur
(`docker compose exec -T mysql mysqldump`, avec `--host`/`--port` réécrits). Les trois
passent : dump réel, restauration dans une base neuve, et relecture d'un montant à deux
décimales, d'un `Nguyễn`, d'un `Saint-Étienne` et d'un émoji intacts, avec les colonnes
restaurées en `json` et `decimal(12,2)`. Le script n'est pas versionné : c'est une
béquille de vérification, pas une dépendance de la suite. Sur le serveur, où
`mysqldump` existe, les tests tournent sans artifice.

**Trois enseignements de ces tests, à ne pas reperdre**

- **`Schema::drop()` dans un test est un commit implicite sous MySQL.**
  `ProductHelperSaveFailureTest` s'en servait pour provoquer un échec d'écriture. Sous
  MySQL, le DDL referme la transaction de `RefreshDatabase` : le rollback de fin de test
  échoue (« 1305 SAVEPOINT trans2 does not exist ») et la table reste supprimée pour tous
  les tests suivants du processus — 12 tests tombaient, dont 6 dans une autre classe.
  Invisible sous SQLite, dont le DDL est transactionnel. L'échec est désormais provoqué
  par une requête invalide émise pendant l'événement `saving` : l'erreur vient toujours du
  driver, sans DDL.
- **MySQL ne rend pas le JSON qu'on lui a donné.** Le serveur réordonne les clés des
  objets (tri par longueur puis par octets) et déséchappe l'unicode. La comparaison par
  empreinte `ksort` de `db:migrate-legacy-sqlite` n'était qu'une précaution théorique
  tant qu'on testait SQLite → SQLite ; `LegacySqliteToMysqlTest` la rend nécessaire noir
  sur blanc — sans elle, `--verify` déclarerait KO une bascule parfaitement réussie.
- **Un test qui affirme « la valeur par défaut est refusée » doit nommer sa connexion.**
  `DatabaseBackupCommandTest` s'appuyait sur « la suite tourne sur SQLite », ce qui a
  cessé d'être vrai sous `phpunit.mysql.xml`.

**1.5 — fait**

`doc/mysql.md` : runbook de la fenêtre de maintenance (prérequis à relever, sauvegarde
préalable, création de la base et de l'utilisateur, `.env`, `migrate`, transfert en
trois temps, recette, retour arrière), plus deux sections de référence — le tableau de
ce que MySQL refuse et que SQLite acceptait, et le mode d'emploi de la suite MySQL.

`doc/backups.md` : ce que fait `db:backup` et pourquoi chaque option de `mysqldump` est
là, les variables, le rôle de `manual/`, la planification, et surtout la **procédure de
restauration** — contrôle d'archive, exercice de restauration dans une base jetable,
restauration réelle en incident. Le document dit explicitement ce qui est couvert par
des tests et ce qui reste une tâche d'exploitation (copie hors-machine, espace disque).

`doc/env.md` : ajout de `DB_LEGACY_SQLITE_DATABASE`, `BACKUP_KEEP_DAYS`, `BACKUP_PATH`,
`MAIL_ALERTS_RECIPIENT`, et d'une section « variables lues autrement qu'on le croit »
qui reprend D-1 à D-3 et D-6 — c'est là qu'on les cherchera.

`doc/README.md` : index à jour.

Les runbooks utilisent des placeholders explicites (`<CHEMIN_APP>`, `<BINAIRE_PHP>`,
`<SQLITE_PROD>`, `<DB_PASS>`), avec en tête de `doc/mysql.md` la commande qui donne
chaque valeur. Ils seront exécutables tels quels une fois l'audit serveur fait.

**1.6 — fait, en local**

La stack `docker-compose.yml` embarque tout le nécessaire : MySQL, Dolibarr 23.0.0 avec
sa MariaDB, et Mailpit. La recette de `doc/mysql.md` § 6 a donc pu être jouée en entier
sans le serveur, contre de vrais services.

| Étape | Résultat |
|---|---|
| Pages FR/EN/ES, produits, configurateur | 200 |
| `/` | 301 vers `/fr` — redirection de locale, pas le 301 global supprimé au lot 0 |
| URL inconnue, et URL héritée inconnue (`/int/inconnu.html`) | **vraie 404**, sans redirection, rendue par `errors/404.blade.php` |
| Devis complet (accents, apostrophe, guillemets, émoji) | `displaySuccessMessage = true` |
| Ligne en base | `total = 1233.56` (3 × 411,185), `Nguyễn`, `Saint-Étienne` et 😀 intacts |
| Mail de devis | reçu dans Mailpit, objet `[TEST] Demande de devis Mostiglass - NGUYỄN Jean-Éric` |
| Devis dans Dolibarr | tiers créé, proposition avec une ligne `fk_product=10` (mapping 105 → 10), `total_ht = 1233.56` |
| `db:backup` | « Sauvegarde OK », archive gzip valide, marqueur `Dump completed`, ligne de devis et montant présents dans le dump |
| Répétition complète de la bascule | 3 lignes SQLite héritées → MySQL dans une base jetable : ids et horodatages préservés, `1234.5599999999999` → `1234.56`, `Ω`/accents/émoji intacts, `VÉRIFICATION OK` ; relance refusée sans `--truncate` (code 1), acceptée avec |

**Deux défauts trouvés — et qu'aucun test à connecteur simulé ne pouvait voir**

Les deux vivaient dans le chemin Dolibarr, couvert jusqu'ici par des tests qui
substituent le connecteur : ils vérifiaient que l'application appelle bien Dolibarr,
pas ce que Dolibarr en fait.

1. **Le message du client n'arrivait jamais dans le CRM.** `CreateProposal` envoyait
   `note`, propriété qui n'existe pas sur l'objet Propal. L'API répond **201**, crée le
   devis, et jette la note — sans le moindre avertissement. Vérifié à la main sur
   Dolibarr 23.0.0 : `note` ignoré, `note_private` retenu. Corrigé en `note_private`
   (note interne, jamais imprimée sur le PDF client — basculer en `note_public` si l'on
   veut l'inverse, cf. « Décisions en attente », point 4).
   La leçon dépasse ce champ : **un devis créé avec succès ne prouve pas que les champs
   envoyés ont été pris en compte.**
2. **Les noms non ASCII étaient mutilés dans Dolibarr.**
   `Dolibarr::createThirdParty()` utilisait `strtoupper()`, qui travaille octet par
   octet : « Nguyễn » arrivait en « **NGUYễN** », alors que l'objet du mail de devis —
   qui passe par `mb_strtoupper()` — affichait « NGUYỄN ». Le même client s'écrivait de
   deux façons selon l'outil consulté. Corrigé en `Str::upper()` / `Str::ucfirst()`.

Fichiers modifiés : `app/Services/Dolibarr.php`,
`app/Http/Integrations/Dolibarr/Requests/CreateProposal.php`, `doc/dolibarr.md`.
Fichier créé : `tests/Unit/CreateProposalRequestTest.php` (3 cas).
`tests/Unit/DolibarrServiceTest.php` gagne un cas sur la casse multi-octets.
Les deux corrections ont ensuite été **revérifiées sur le vrai Dolibarr** : nom
`NGUYỄN Jean-Éric`, `note_private` portant le message et son émoji.

**Un piège d'environnement, à ne pas reperdre**

Sur cette machine, `localhost:80` côté hôte est un **Apache tiers**, pas le conteneur
applicatif. Une première passe de recette lancée depuis l'hôte renvoyait 200 sur `/` et
404 sur toutes les autres URL : les réponses ne venaient pas de l'application. Toute
vérification HTTP doit passer par `docker compose exec laravel.test curl …`.

**Ce qui reste, et qui n'est pas du code**

- poser la ligne de cron `schedule:run` (ci-dessous) ;
- relever les 11 prérequis production ;
- rejouer `doc/mysql.md` § 6 sur la production le jour de la bascule.

**À poser sur le serveur, pas dans le code**

```cron
* * * * * cd <CHEMIN_APP> && <BINAIRE_PHP> artisan schedule:run >> <CHEMIN_APP>/storage/logs/schedule.log 2>&1
```
`Kernel::schedule()` était vide jusqu'ici : ajouter cette ligne ne peut donc réveiller
aucune tâche inattendue.

---

## LOT 2 — Front du blog

| # | Tâche | État |
|---|---|---|
| 2.1 | Routes localisées `blog.index` / `blog.show`, clés `blog` dans les 3 `routes.php`, `resolveRouteBinding` | ✅ |
| 2.2 | `App\Support\AlternateUrls` + masquage des langues non traduites | ✅ — **2 pages en 500 corrigées** |
| 2.3 | Page liste (GET serveur, filtres, recherche, pagination maison) | ✅ |
| 2.4 | Page article (contenu riche, `<x-media-picture>`, articles liés) | ✅ — débloqué par le 3.1c |
| 2.5 | SEO (canonical, OG, JSON-LD, réécriture du sitemap) | ✅ — **sitemap réparé et planifié** |
| 2.6 | Intégration charte (menu ×2, footer, `lang/*/blog.php`) | ✅ |

Acquis utilisables : `Post::scopePublishedIn()`, `publishedLocales()` (ordre stable),
`getRouteKey()` / `getRouteParameters()` par locale, `PostPreview::isAllowed()`, et le
404 réel du lot 0 sans lequel l'exigence « article masqué » était intestable.

**2.1 — fait**

URLs : `/fr/conseils`, `/en/advice`, `/es/consejos` (+ `/{slug}`). La clé est `blog`
dans les trois `lang/*/routes.php` — volontairement identique partout, la dette D-4
venant précisément de clés divergentes (`legale-notices` en FR contre `legal-notices`
en EN).

Le paramètre de route est nommé `{post}` : c'est ce qui déclenche la liaison implicite
vers `Post::resolveRouteBinding()`, qui ne résout le slug que dans la locale courante.
Un slug français sous `/en/news/` tombe donc en 404 — exigence SEO : un article n'a
qu'une seule URL par langue.

Fichiers créés : `app/Http/Controllers/BlogController.php`,
`resources/views/blog/{index,show,_card}.blade.php`, `lang/{fr,en,es}/blog.php`,
`tests/Feature/BlogRoutingTest.php` (19 cas).
Fichiers modifiés : `routes/web.php`, `lang/{fr,en,es}/routes.php`.

Le corps de l'article est rendu **échappé** (`{{ }}`) et non `{!! !!}` : tant que
l'assainissement à l'écriture n'existe pas (3.1), rendre ce HTML brut ouvrirait une
faille XSS. Un test le verrouille et cassera si quelqu'un bascule la vue sans avoir
posé `App\Support\BlogHtml`.

**2.2 — fait, et il révèle deux pages en erreur 500**

`App\Support\AlternateUrls` répond à une seule question — « dans quelles langues cette
page existe-t-elle vraiment ? » — pour les quatre endroits qui se la posaient
séparément : `hreflang`, canonical, Open Graph et sélecteur de langue.

Ce n'est pas cosmétique. **Deux partials du site généraient une URL pour chaque langue
sans vérifier qu'elle existe.** Sur un article traduit en français seulement,
`Post::getRouteParameters('en')` retourne `null`, et la génération lève
« Missing required parameter for [Route: en.blog.show] » **depuis le `<head>`** : la
page entière tombait en 500, alors que l'article était parfaitement valide.

| Fichier | Avant |
|---|---|
| `_partials/head/hreflang.blade.php` | `route($name, [], true, $locale)` pour chaque langue |
| `_partials/language_selector.blade.php` | `Route::localizedUrl($locale)` pour chaque langue |

Les deux passent désormais par `AlternateUrls::alternates()`, qui **omet** une langue
sans traduction au lieu de la deviner. Effet visible : sur un article FR seul, ni
`hreflang`, ni entrée dans le sélecteur de langue.

Deux garde-fous complètent le masquage :

- un paramètre scalaire non traduisible — le `{productSlug?}` du configurateur, dont la
  valeur diffère par langue — est **omis** plutôt que recopié tel quel dans l'autre
  langue. Le lien alternatif pointe sur la page sans paramètre, valide partout ;
- un `try/catch` en dernier recours : un lien alternatif manquant est une perte SEO
  mineure, une exception dans le `<head>` emporte toute la page.

Fichiers créés : `app/Support/AlternateUrls.php`,
`tests/Feature/AlternateUrlsTest.php` (7 cas, par vraies requêtes HTTP — la classe lit
`Route::current()`, la tester hors requête ne prouverait rien).
Fichiers modifiés : `resources/views/_partials/head/hreflang.blade.php`,
`resources/views/_partials/language_selector.blade.php`.

**Vérifié dans l'application réelle** (3 articles de démonstration en base de dev) :
`/fr/conseils` 200 avec 3 articles, `/en/advice` 200 avec 2 (le FR-seul exclu),
`/es/consejos` 200 avec le message « aucun article », et l'article FR-seul rend 200
avec zéro lien alternatif.

**2.3 — fait**

Filtres transmis en **GET** et traités côté serveur : chaque état est une URL
partageable et indexable, et la page fonctionne sans JavaScript. Un filtrage Livewire
aurait produit des états non adressables — exactement ce qu'il ne faut pas pour du
contenu éditorial destiné au référencement.

- **Catégorie** (`?category=<slug>`) : le slug est résolu dans la langue courante. Un
  slug inconnu, ou celui d'une autre langue, renvoie **404** et non la liste complète.
  Une URL de filtre inexistante qui répond 200 est un « soft 404 » que les moteurs
  indexent puis pénalisent. Le sélecteur ne propose que les catégories portant au moins
  un article visible.
- **Recherche** (`?q=`) : `Post::scopeSearch()`, en LIKE sur titre, chapô et corps de la
  traduction courante. Tous les termes doivent être présents ; chercher en anglais ne
  remonte pas un article français. Volontairement pas d'index FULLTEXT : il n'existe pas
  en SQLite et exigerait une migration propre à MySQL, pour un volume qui ne le justifie
  pas encore.
- **Pagination maison** : vue dédiée `blog/_pagination.blade.php`, à la charte et
  accessible (`aria-current` sur la page active, liens désactivés en `<span>` et non en
  `<a>` mort). `withQueryString()` conserve les filtres ; le formulaire ne reprend
  **pas** `page`, pour qu'un changement de filtre ramène à la page 1.

Fichiers créés : `resources/views/blog/{_filters,_pagination}.blade.php`,
`tests/Feature/BlogListingTest.php` (17 cas).
Fichiers modifiés : `app/Http/Controllers/BlogController.php`, `app/Models/Post.php`,
`resources/views/blog/index.blade.php`, `lang/{fr,en,es}/blog.php`.

Deux points qui ne se voient qu'en écrivant les tests :

- **`ESCAPE` n'est pas portable.** Sans caractère d'échappement, un « % » saisi par un
  visiteur est un joker et fait tout remonter. Mais SQLite exige `ESCAPE '\'` là où
  MySQL — qui traite déjà l'antislash comme échappement dans les littéraux — exige
  `ESCAPE '\\'` : aucune chaîne SQL unique ne convient aux deux. D'où un caractère
  neutre, `!`. Le premier jet échouait avec « ESCAPE expression must be a single
  character ».
- **Une page de recherche est en `noindex,follow`**, une page de catégorie reste
  indexable. Les combinaisons de mots-clés créent une infinité d'URLs de faible valeur ;
  une catégorie est du contenu structuré et stable.

**2.4 — partiel, assumé**

La page article est en place : titre, chapô, catégorie de la langue courante, dates,
articles liés (même catégorie d'abord, complétés par les plus récents, jamais un article
non traduit). Ce qui manque dépend de paquets non installés et arrive au **3.1** :
l'image de couverture (`<x-media-picture>`, medialibrary) et le rendu du HTML riche.

Le corps reste rendu **échappé** d'ici là, et un test le verrouille.

**2.5 — fait, et le sitemap était cassé**

`_partials/head/seo.blade.php` (canonical, Open Graph, Twitter Card) et
`App\Support\Seo` (codes `og:locale`, encodage JSON-LD). Le layout gagne un
`@stack('head')` pour que les vues poussent leurs balises propres — `article:published_time`,
`prev`/`next`, JSON-LD.

Le canonical vient d'`AlternateUrls` : sur une 404, `currentRouteName()` est nul, donc
**aucun canonical n'est publié**. En émettre un vers l'accueil ferait passer une erreur
pour une page valide.

Le JSON-LD (`BlogPosting`) est encodé avec `JSON_HEX_TAG | JSON_HEX_AMP` : un titre
contenant `</script>` ne peut pas fermer la balise qui le porte. C'est pour cette raison
que le bloc est rendu en `{!! !!}` — la protection est faite à l'encodage, Blade
échapperait les guillemets du JSON et le rendrait illisible. Un test l'attaque avec un
titre hostile.

**`sitemap:generate` produisait un fichier invalide.** Trois défauts :

1. `Url::create($route->uri)` donnait des `<loc>` **relatives** (`fr/conseils`) — la
   spécification exige des URLs absolues, le fichier entier était rejeté. C'est pour
   cela que la tâche n'avait jamais été planifiée (écart n° 4) ;
2. les gabarits de routes étaient déclarés comme des pages : `fr/conseils/{post}` ;
3. priorité 1.0 et fréquence mensuelle pour tout — aucun signal utile.

Réécrite : URLs absolues, routes à paramètre requis écartées, articles ajoutés
explicitement (une entrée par langue **réellement traduite**, jamais de `<loc>` vers une
page qui répondrait 404), `lastmod`, priorités et fréquences différenciées. Elle est
désormais **planifiée à 04:15**, après la sauvegarde de 03:15.

Un piège au passage : `url($route->uri())` conserve les segments optionnels sous forme de
gabarit, et `/fr/trouver-mon-produit/{productSlug?}` se retrouvait littéralement dans le
XML. `route($name)` les omet.

Résultat réel : **63 pages statiques + 5 articles = 68 URLs**, toutes absolues, zéro
accolade.

Fichiers créés : `app/Support/Seo.php`,
`resources/views/_partials/head/seo.blade.php`, `resources/views/blog/_json_ld.blade.php`,
`tests/Feature/BlogSeoTest.php` (12 cas),
`tests/Feature/GenerateSitemapCommandTest.php` (13 cas).
Fichiers modifiés : `app/Console/Commands/GenerateSitemap.php`, `app/Console/Kernel.php`,
`resources/views/layouts/default.blade.php`, `resources/views/blog/{index,show}.blade.php`.

**2.6 — fait**

Lien « Actualités / News / Noticias » dans le menu desktop, le menu mobile et le footer,
via `route('blog.index')` sans préfixe de langue — le paquet résout la locale courante,
comme pour les autres entrées.

Fichiers créés : `tests/Feature/BlogNavigationTest.php` (5 cas).
Fichiers modifiés : `resources/views/_partials/{navigation_menu,mobile_navigation_menu,footer}.blade.php`,
`lang/{fr,en,es}/menu.php`.

---

## LOT 3 — Back-office Filament

| # | Tâche | État |
|---|---|---|
| 3.1a | `composer require` Filament + medialibrary + plugin, **avec** le bump `php ^8.2` et le pinning de plateforme | ✅ |
| 3.1b | `AdminPanelProvider`, `ForceAdminLocale`, config medialibrary | ✅ |
| 3.1c | Compléments du lot 0 : `HasMedia`, `BlogHtml`, contrats Filament sur l'enum | ✅ |
| 3.2 | Auth (`users.is_admin`, `FilamentUser`, `mostiglass:admin`, `Authenticate::redirectTo()`, `lang:add fr`) | ✅ — **un 500 latent corrigé** |
| 3.3 | `PostResource` (onglets de langue, slugs, SEO, médias) | ✅ — **2 défauts de routage corrigés** |
| 3.4 | `CategoryResource` | ✅ |
| 3.5 | Prévisualisation (le contrat côté front est déjà posé) | ✅ |

**3.3 — fait**

`PostResource` avec ses trois pages. Le contenu traduisible est présenté en **onglets de
langue**, chacun piloté par une relation `HasOne` nommée (`translationFr`,
`translationEn`, `translationEs`) — c'est la raison d'être de ces relations, posées au
lot 0.4.

Deux mécanismes de Filament vérifiés dans le code du paquet plutôt que supposés :

- **`Group::relationship($nom, $condition)` supprime l'enregistrement lié quand la
  condition est fausse.** C'est exactement le modèle de publication retenu : vider le
  titre d'une langue retire l'article de cette langue, sans case à cocher
  supplémentaire. Un onglet laissé vide ne crée donc **aucune** ligne — capital, puisque
  l'existence de la ligne EST le critère de mise en ligne.
- **À la création, Filament fait `new PostTranslation` puis `fill($data)`.** La
  contrainte `where('locale', …)` de la relation n'est pas appliquée à l'insertion : sans
  `Hidden::make('locale')`, la colonne serait nulle et l'insertion échouerait.

Le formulaire : statut, date de publication (**requise dès que l'article est publié** —
sans elle le scope `published()` l'exclut et l'article resterait invisible sans que rien
ne le signale), catégorie, auteur, temps de lecture, couverture et image de partage
(`SpatieMediaLibraryFileUpload`), puis par langue : titre, slug, chapô, contenu
(`RichEditor`) et les trois champs SEO.

Le slug n'est proposé automatiquement **que s'il est encore vide** : modifier le titre
d'un article publié ne doit pas changer son URL. Son unicité est validée par langue,
donc le même slug reste possible en français et en anglais.

Le listing : vignette, titre français, statut en badge, langues présentes, date de
publication (annotée « programmé » si elle est future — sinon on cherche longtemps
pourquoi l'article n'apparaît pas), recherche par titre sur la table des traductions, et
un filtre « traduction manquante ».

**Deux défauts de routage, tous deux dus au même choix du lot 2.1**

`Post::getRouteKeyName()` renvoie `'slug'`, pour les jolies URLs publiques. Le
back-office en héritait :

| Symptôme | Cause | Correctif |
|---|---|---|
| `/admin/posts/{id}/edit` répondait **404** | Filament résolvait l'article par son slug français, et via le filtre de publication — un brouillon était de toute façon inatteignable | `protected static ?string $recordRouteKeyName = 'id'` |
| Créer un article **uniquement en anglais** faisait échouer la redirection : « Missing required parameter [record] » | `getUrl('edit', ['record' => $post])` laisse `route()` convertir le modèle par `getRouteKey()` — nul sans traduction française. Le bouton « Modifier » du listing aurait échoué de même | `PostResource::getUrl()` convertit le modèle en clé primaire |

`$recordRouteKeyName` ne suffisait pas : il gouverne la **résolution**, pas la
**génération**. Les deux sont couverts par
`test_an_article_without_a_french_translation_stays_reachable`.

Fichiers créés : `app/Filament/Resources/PostResource.php`,
`app/Filament/Resources/PostResource/Pages/{ListPosts,CreatePost,EditPost}.php`,
`tests/Feature/PostResourceTest.php` (16 cas).

**3.4 — fait**

`CategoryResource`, même structure d'onglets que les articles et pour les mêmes raisons.
Ajout d'un tri par `position` (réordonnable à la souris) et d'un compteur d'articles.

Le point propre aux catégories est la **suppression**. La clé étrangère est en
`nullOnDelete` : supprimer une catégorie déclasse ses articles, elle ne les supprime pas.
C'était un choix délibéré du schéma — l'inverse ferait disparaître du contenu publié
depuis un écran de gestion des catégories. L'interface l'annonce avant d'agir
(« 3 article(s) perdront leur classement mais resteront publiés ») plutôt que de laisser
la surprise, et un test vérifie que l'article survit bel et bien.

Fichiers créés : `app/Filament/Resources/CategoryResource.php`,
`app/Filament/Resources/CategoryResource/Pages/{ListCategories,CreateCategory,EditCategory}.php`,
`tests/Feature/CategoryResourceTest.php` (8 cas).

**3.5 — fait**

Le contrat existait depuis le lot 2.1 (`PostPreview::isAllowed()`) sans personne pour
produire les liens. La boucle est fermée : `PostPreview::urlFor()` génère l'URL, et deux
actions apparaissent sur la page d'édition.

- **Aperçu** : un menu, une entrée par langue traduite. L'administrateur étant connecté,
  `isAllowed()` le reconnaît sans signature — le lien ne périme pas et survit à un
  rechargement. Les langues non traduites sont **absentes** du menu plutôt que grisées :
  sans traduction il n'y a pas de slug, donc pas d'URL du tout.
- **Lien de relecture** : URL signée et temporaire (7 jours), utilisable par quelqu'un
  qui n'a pas de compte. C'est la raison d'être de la branche « signature » du contrat.
  Le lien est affiché dans une notification persistante plutôt que copié
  automatiquement : l'accès au presse-papier exige un contexte sécurisé et échouerait en
  silence sur une préproduction servie en HTTP.

Sept jours de validité : assez pour un aller-retour de relecture, assez court pour qu'un
lien oublié dans un fil de discussion ne donne pas accès indéfiniment aux brouillons.

**Vérifié dans l'application réelle**, sur un vrai brouillon :

| Requête | Résultat |
|---|---|
| URL de l'article, sans rien | **404** |
| URL + `?preview=1` seul, sans signature | **404** |
| lien signé complet | **200**, avec le bandeau « cet article n'est pas visible du public » |

Les tests couvrent en plus l'expiration, une signature trafiquée, et le fait qu'un
administrateur connecté qui **oublie `?preview=1`** obtienne lui aussi un 404 : l'aperçu
est un geste explicite, pas un privilège permanent qui changerait ce que le site montre
à l'équipe.

Fichiers créés : `tests/Feature/PostPreviewTest.php` (13 cas).
Fichiers modifiés : `app/Support/PostPreview.php` (méthode `urlFor()`),
`app/Filament/Resources/PostResource/Pages/EditPost.php`.

**3.2 — fait**

`users.is_admin`, booléen à `false` par défaut. Un booléen et non un système de rôles :
il y a un seul niveau d'accès, celui de l'équipe éditoriale — un paquet de permissions
apporterait trois tables et une interface de gestion des rôles pour exprimer « oui ou
non ». La colonne est **hors `$fillable`** : elle ne peut pas être positionnée par
affectation de masse depuis une requête, et un test le vérifie.

`User implements FilamentUser` → `canAccessPanel()`. **Sans ce contrat, Filament laisse
entrer tout utilisateur authentifié** : n'importe quel compte créé pour un autre usage
ouvrirait le back-office.

`php artisan mostiglass:admin <email>` crée ou promeut un compte, avec `--name`,
`--password` et `--revoke`. Pourquoi pas `filament:user` : la commande du paquet ne
connaît pas `is_admin` et produit donc un compte qui ne peut pas entrer — symptôme
déroutant (identifiants acceptés, puis 403) qui envoie chercher côté session pour rien.
Le mot de passe est **généré par défaut et affiché une seule fois**, plutôt que saisi en
argument où il resterait dans l'historique du shell. `--revoke` retire l'accès **sans
supprimer le compte** : il peut être l'auteur d'articles publiés (`posts.author_id`).

**Un 500 latent corrigé.** `Authenticate::redirectTo()` renvoyait `route('login')`, or
ce projet n'a aucune route nommée `login` — il n'y a pas de scaffolding
d'authentification, la seule page de connexion est celle de Filament. Constaté avant
correction :

| Requête | Avant | Après |
|---|---|---|
| `GET /api/user` depuis un navigateur | **500** `RouteNotFoundException` | 302 vers `/admin/login` |
| `GET /api/user` avec `Accept: application/json` | 401 | 401 (inchangé) |

L'URL est résolue depuis le panneau (`Filament::getPanel('admin')->getLoginUrl()`) et non
écrite en dur, le nom de route dépendant de l'identifiant du panneau.

Vérifié en réel : compte créé en base avec `is_admin = 1`, `canAccessPanel()` à `true`
pour lui et `false` pour un compte ordinaire, `/admin` redirigeant vers `/admin/login`
sans session. Le formulaire de connexion lui-même est un composant Livewire, non
pilotable au `curl` — l'autorisation est couverte par `tests/Feature/AdminAccessTest.php`
à travers la vraie pile HTTP.

Fichiers créés : `database/migrations/2026_08_01_170000_add_is_admin_to_users_table.php`,
`app/Console/Commands/MakeAdminUser.php`, `tests/Feature/AdminAccessTest.php` (9 cas),
`tests/Feature/MakeAdminUserCommandTest.php` (9 cas).
Fichiers modifiés : `app/Models/User.php`, `app/Http/Middleware/Authenticate.php`.

**3.1a — fait**

`composer.json` : `php` passé de `^8.1` à `^8.2`, et `config.platform.php = 8.2.32` —
la version réelle relevée sur `gocap-mostiglass-2`. Les résolutions se font désormais
pour la production, pas pour la machine qui lance `composer`.

Installé : `filament/filament v3.3.54`, `spatie/laravel-medialibrary 11.23.3`,
`filament/spatie-laravel-media-library-plugin v3.3.54`. Et en dépendances, deux paquets
que le chantier attendait : **`symfony/html-sanitizer v7.4.14`** (qui débloque le 3.1c,
donc le 2.4) et `doctrine/dbal 3.10.6`.

Le brassage redouté a eu lieu : 31 installations, 44 mises à jour, et une série de
**rétrogradations** — `laravel-lang/*`, `laravel/pint`, `psy/psysh`, `saloonphp/saloon`.
Ce n'est pas un accident, c'est l'effet du pinning : `vendor/` était installé pour PHP
8.3/8.4 et contenait des versions que la production ne peut pas exécuter. Elles sont
maintenant alignées sur 8.2. La dette **D-7** (vendor en avance sur le lock) est du même
coup résorbée.

Deux résultats non anticipés :

- **`composer audit` passe de 28 advisories sur 11 paquets à 10 sur 4.** Le refresh
  améliore nettement la posture de sécurité — la dette D-8 se réduit d'elle-même.
- les trois versions que le plan disait disparues de Packagist (`nette/utils 4.1.2`,
  `laravel-lang/actions 1.12.0`, `laravel-lang/attributes 2.15.2`) se résolvent sans
  problème. Le constat était périmé.

**Le risque signalé par le plan a été vérifié, pas supposé.** Le bump
`guzzle 7.10 → 7.15.2` porte l'intégration Dolibarr, et les tests unitaires substituent
le connecteur — ils ne verraient pas un changement de comportement HTTP. Le parcours de
devis a donc été rejoué **contre le vrai Dolibarr** : tiers `NGUYỄN Jean-Éric`,
proposition #9 à 1233,56 €, ligne `fk_product=10`, `note_private` avec l'émoji intact,
mail dans Mailpit, ligne en base. Aucune régression.

**3.1b — fait**

`app/Providers/Filament/AdminPanelProvider.php` (via `filament:install --panels`), avec
trois écarts au squelette : couleur primaire **bleue** (charte) au lieu de l'ambre,
`brandName('Mostiglass')`, et le middleware `ForceAdminLocale`.

`app/Http/Middleware/ForceAdminLocale.php` force le panneau en français. Placé **en
dernier** dans la pile : la locale doit être fixée après la détection du paquet
localized-routes, sinon elle est écrasée. Volontairement sans écriture en session ni en
cookie — le forçage ne doit pas survivre à la sortie du panneau, sinon une visite de
`/admin` basculerait ensuite tout le site public en français.

`config/media-library.php` et la migration `create_media_table` publiés, `lang:add fr`
exécuté (les fichiers propres au projet sont intacts, seuls `actions.php` et
`validation.php` de laravel-lang ont été rafraîchis).

Vérifié : `/admin` → 302 vers `/admin/login`, `/admin/login` → 200 et libellés français
(« Adresse e-mail », « Mot de passe »).

**3.1c — fait, et il termine le 2.4**

- `PostStatus implements HasColor, HasLabel` : les Select et badges du back-office se
  rempliront seuls.
- `Post implements HasMedia` : collections `cover` et `og` en fichier unique,
  conversions WebP `thumb` (400×250), `card` (800×500), `hero` (1600×900) et `og`
  (1200×630), toutes en **`nonQueued()`** — il n'y a pas de worker sur ce serveur, une
  conversion mise en file ne serait jamais exécutée et l'article s'afficherait sans
  image, sans erreur. `socialImageUrl()` retombe sur `cover` si `og` est vide.
- `App\Support\BlogHtml` + `PostTranslation::setBodyAttribute()` : assainissement à
  l'écriture, un seul point de passage quel que soit le producteur.

Le corps de l'article est donc passé de `{{ }}` à `{!! !!}`, et le composant
`<x-media-picture>` affiche la couverture sur le listing et l'article.

**Trois pièges rencontrés, chacun désormais couvert par un test**

- **Le sanitizer Symfony tronque à 20 000 caractères, sans erreur.** C'est son défaut.
  Un article un peu long aurait été amputé en silence à l'enregistrement, et personne ne
  l'aurait vu avant de relire la page. `BlogHtml::MAX_LENGTH` relève la limite
  explicitement, et un test l'éprouve sur un corps qui dépasse le défaut.
- **`blockElement()` conserve le texte de la balise retirée.** Un
  `<script>alert(1)</script>` ressortait en « alert(1) » affiché en clair au milieu de
  l'article — inerte, mais absurde. C'est `dropElement()` qu'il faut, qui supprime la
  balise ET son contenu. À noter : bloquer sans supprimer est aussi le comportement par
  défaut pour toute balise non autorisée.
- **La migration publiée par medialibrary n'a pas de `down()`.** `migrate:rollback`
  était donc cassé pour **toute l'application** : la table `media` survivait au
  rollback et le `migrate` suivant s'arrêtait sur « 1050 Table 'media' already exists ».
  Revenir en arrière après un déploiement raté était impossible. Détecté par
  `tests/Database/MysqlMigrationRollbackTest`, écrit au 1.4 exactement pour ça.

Fichiers créés : `app/Providers/Filament/AdminPanelProvider.php`,
`app/Http/Middleware/ForceAdminLocale.php`, `app/Support/BlogHtml.php`,
`config/media-library.php`, `database/migrations/2026_08_01_153832_create_media_table.php`,
`resources/views/components/media-picture.blade.php`,
`tests/Unit/BlogHtmlTest.php` (15 cas), `lang/fr.json` et `lang/fr/{auth,http-statuses,passwords}.php`,
plus les assets Filament dans `public/{css,js}/filament/`.
Fichiers modifiés : `composer.json`, `composer.lock`, `config/app.php`,
`app/Enums/PostStatus.php`, `app/Models/{Post,PostTranslation}.php`,
`resources/views/blog/{show,_card}.blade.php`, `tests/Feature/BlogRoutingTest.php`,
`lang/fr/{actions,validation}.php`.

---

## LOT 4 — Livraison

| # | Tâche | État |
|---|---|---|
| 4.1 | `deploy.sh`, permissions `www-data`, suppression de `build/` à la racine | ✅ — **script exécuté pour de vrai, pas seulement écrit** |
| 4.2 | `doc/back-office.md`, `doc/blog-schema.md`, `doc/deploiement.md`, `doc/guide-back-office.md` | ✅ |
| 4.3 | Recette finale de bout en bout | ✅ — **3 constats mesurés, laissés à l'arbitrage ; aucun code existant modifié** |

**4.1 — fait**

`./deploy.sh` à la racine, avec `--ref`, `--skip-assets`, `--skip-backup`,
`--no-fpm-reload` et `--dry-run`. Documenté dans `doc/deploiement.md`.

L'ordre des étapes n'est pas cosmétique, trois points le fixent :

- **les dépendances avant les caches.** Le hook `post-autoload-dump` lance
  `filament:upgrade`, qui fait `config:clear`, `route:clear` et `view:clear` : mettre les
  caches en premier reviendrait à les faire effacer dans la foulée ;
- **le rechargement de PHP-FPM avant `artisan up`**, et non « en dernière étape » comme
  le disait la note de cadrage. L'ordre inverse rouvre le site à des workers qui servent
  encore l'ancien bytecode alors que le schéma de base est déjà migré ;
- **en cas d'échec, le site reste en maintenance.** Un 503 est visible ; une page à moitié
  cassée perd des demandes de devis en silence. Le script affiche la commande de retour
  arrière et le chemin de la sauvegarde d'avant migration.

**Le script a été exécuté dans le conteneur, pas seulement relu** — et il a échoué deux
fois, utilement :

1. **Première conception de l'étape « permissions » : fausse.** Elle faisait
   `chmod -R ug+rwX storage bootstrap/cache` et s'arrêtait sur « Operation not permitted ».
   `chmod` n'est permis qu'au propriétaire : face à un fichier laissé par root — le cas
   même qu'on redoute — l'étape ne peut rien faire. Elle a été refaite pour **nommer** les
   fichiers fautifs et donner la commande `chown` à passer en root, les échecs de `chmod`
   étant désormais tolérés.
2. **`filament:optimize` a ensuite échoué** sur `bootstrap/cache/filament/panels/admin.php`,
   appartenant à root. Cause : un `filament:optimize` de vérification lancé plus tôt sans
   `-u sail`. C'est le piège n° 3 de la section « environnement local », reproduit en
   direct. L'avertissement de l'étape 9 avait nommé le fichier une étape plus tôt : la
   chaîne fonctionne.

Après correction, le script est passé de bout en bout et **le site a été servi sous une
installation réellement de production** — `composer install --no-dev`, `npm run build`,
les cinq caches, `filament:optimize` — avec `/fr`, `/en`, `/es`, les trois listings du
blog, `/admin` et le configurateur tous en 200, et une URL inconnue en 404.

**Un prérequis production non repéré jusqu'ici : Node et npm.** `public/build/` n'est
**pas versionné** (`.gitignore`) et ne l'a jamais été — `git log` sur ce chemin est vide.
Un déploiement qui ne construit pas les assets sert donc un site sans style ni
JavaScript. Ajouté en prérequis n° 12, avec la porte de sortie (construire ailleurs,
`rsync`, puis `--skip-assets`).

**Le rechargement de PHP-FPM exige une règle sudoers.** `www-data` ne pilote pas systemd.
Le script s'arrête, *avant* de rouvrir le site, avec la règle à poser — restreinte à cette
seule commande. Cf. `doc/deploiement.md` § 1.c.

**Dette D-10 résorbée.** Le `build/` de la racine était bien un vestige : `git log` ne le
touche que dans le commit initial, `public/build/` porte les assets réellement servis, et
aucune référence dans le code. Retiré du dépôt, vérification faite ensuite que la page
d'accueil charge toujours `build/assets/app-*.css` depuis `public/build/`.

**4.2 — fait**

| Document | Public visé |
|---|---|
| `doc/deploiement.md` | qui déploie : prérequis, première mise en production, déploiement courant, retour arrière, contrôles |
| `doc/blog-schema.md` | qui touche aux données : les quatre tables, les contraintes, et ce qu'elles garantissent |
| `doc/back-office.md` | qui intervient sur le panneau : accès, ressources, mécanismes Filament, pièges |
| `doc/guide-back-office.md` | **l'équipe éditoriale** : mode d'emploi sans prérequis technique |

`doc/README.md` réorganisé en trois entrées — exploitation, blog et back-office,
intégrations.

Le guide éditorial est construit autour d'une seule phrase — « une langue est en ligne si,
et seulement si, son onglet a un titre » — parce que c'est elle qui explique le
comportement qui surprend : l'article absent d'un listing, le 404 d'une URL traduite, la
dépublication par vidage du titre.

**4.3 — fait**

Recette jouée contre les vrais services de la stack locale (MySQL 8, Dolibarr 23.0.0,
Mailpit), depuis le conteneur.

| Domaine | Résultat |
|---|---|
| Suites de tests | 214 verts (693 assertions) en SQLite ; **257 verts (818 assertions), 0 ignoré** en MySQL |
| Pages FR / EN / ES, produits, configurateur | 200 |
| `/` | 301 vers `/fr` |
| URL inconnue, URL héritée inconnue | vraie **404**, sans redirection |
| Blog : listings 3 langues, article, filtre catégorie, recherche, pagination | 200 |
| Cloisonnement des langues | slug FR sous `/en/news` et `/es/noticias` → **404** ; article FR-seul en EN → **404** |
| Filtre de catégorie inconnu, ou d'une autre langue | **404** et non la liste complète (pas de soft 404) |
| SEO | canonical présent, **absent sur une 404** ; `hreflang` limité aux langues traduites ; JSON-LD `BlogPosting` ; `noindex,follow` sur la recherche, catégorie indexable |
| Devis complet (accents, apostrophe, guillemets, émoji) | `displaySuccessMessage = true`, aucune erreur de validation |
| Ligne en base | `total = 1233.56` (3 × 411,185 arrondi par la colonne `decimal(12,2)`), `Nguyễn`, `Saint-Étienne`, `12 rue de l'Église` et 😀 intacts |
| Mail de devis | reçu dans Mailpit, objet `[TEST] Demande de devis Mostiglass - NGUYỄN Jean-Éric` |
| Devis dans Dolibarr | tiers **`NGUYỄN Jean-Éric`** (casse multi-octets correcte), proposition `total_ht = 1233.56`, ligne `fk_product=10` (mapping 105 → 10), `note_private` portant le message et son émoji |
| Prévisualisation | lien signé **200** avec le bandeau « Aperçu — cet article n'est pas visible du public » ; signature trafiquée **404** ; sans `?preview=1` **404** ; brouillon absent du listing et du sitemap |
| Back-office | `mostiglass:admin` crée, révoque et re-promeut ; `canAccessPanel()` à `true` pour l'administrateur et `false` pour un compte ordinaire ; `is_admin` hors `$fillable` ; `/admin` → 302 vers `/admin/login` |
| `/api/user` | 302 vers `/admin/login` en navigateur, 401 en JSON — le 500 latent du 3.2 reste corrigé |
| `sitemap:generate` | 68 URLs, XML valide, zéro accolade, zéro URL relative ; brouillon absent ; article FR-seul présent **une seule fois** |
| `db:backup` | archive gzip (octets magiques `1f 8b`), marqueur `Dump completed`, ligne de devis et montant présents dans le dump |
| `schedule:list` | `db:backup` à `15 3 * * *`, `sitemap:generate` à `15 4 * * *` |

**Trois constats laissés à l'arbitrage — aucun code existant n'a été modifié**

Le lot 4 ne devait rien changer à ce qui était en place et fonctionnait. Les trois
constats ci-dessous sont donc **mesurés et documentés, pas corrigés** : ils touchent tous
des URLs publiques qui portent du référencement acquis, et cela relève d'une décision, pas
d'un correctif de livraison.

1. **Les redirections héritées s'annoncent permanentes et répondent 302.** Elles s'écrivent
   toutes `redirect(null, 301)->route(...)`. Sans destination, `redirect()` retourne le
   Redirector et **jette son second argument** ; le `->route()` qui suit repart sur son
   défaut, 302. Mesuré : `/index_pc_fr.html`, `/index_pc_en.html` et
   `/en/collectivites-industriels-professionnels` répondent **302**.
   L'enjeu n'est pas cosmétique — un moteur ne transfère l'autorité de l'ancienne URL que
   sur un 301 ; sur un 302 l'ancienne reste indexée et la nouvelle n'hérite de rien,
   précisément ce que ces redirections étaient censées obtenir. Le correctif tient en un
   argument déplacé (`redirect()->route('fr.index', [], 301)`) sur cinq routes de
   `routes/web.php` et une ligne de `RemoveIndexMiddleware`. Noté D-13.
2. **D-4 confirmé, avec les mesures.** `/en/legal-notices` et
   `/en/venturi-effect-and-application` répondent **404** ; les pages anglaises sont
   servies sous **`/en/mentions-legales`** et **`/en/effet-venturi-et-application`**.
   Cause : `routes/web.php` appelle `Lang::uri('legale-notices')` et `Lang::uri('venturi')`,
   clés absentes de `lang/en/routes.php` — qui déclare `legal-notices` et `venturi-effect`,
   jamais lues. Le paquet retombe alors sur la valeur française. Les clés anglaises
   correctes existent donc et ne servent à rien.
   Le correctif est mécanique — aligner les clés dans les trois fichiers, sans changer les
   valeurs FR et ES — mais il **change deux URLs publiques anglaises** et demande deux 301,
   sur le modèle de celui déjà en place pour `/en/collectivites-industriels-professionnels`.
   D'où la mise en attente de décision plutôt que la correction d'office.
2. **Les huit stubs de `public/int/*.php`** émettent un `header('Location: …')` **sans
   statut**, donc un 302, vers des URLs absolues **sans préfixe de langue**
   (`https://www.mostiglass.fr/qui-sommes-nous`) qui re-redirigent en 301 vers `/fr/…`.
   Deux sauts au lieu d'un, dont le premier temporaire ; `contact.php` mélange en plus
   `www.` et non-`www.`. Fichiers hérités portant du référencement réel : non touchés.

**Un faux défaut, à ne pas rechercher deux fois**

`/int/index.html` et `/int/{slug}` répondent 404 **en développement** alors que les routes
existent et que `LegacyRedirectTest` passe. Cause : `php artisan serve` sert `public/int/`
en statique et court-circuite le routeur. En production, nginx ne sert ainsi que les
fichiers réellement présents. Vérifié en déplaçant `public/int/` : les deux URLs
redirigent alors correctement. Le « 404 sur `/int/inconnu.html` » relevé à la recette 1.6
était donc un artefact du serveur de développement, pas un comportement de production.

---

## Après-livraison — demandes du client

Travaux menés après la clôture du lot 4, à la demande du client. Ils ne remettent pas en
cause les lots précédents.

| # | Sujet | État |
|---|---|---|
| A.1 | Seeder de compte back-office | ✅ délègue à `mostiglass:admin`, garde-fou production, `config/admin.php` |
| A.2 | Organisation du back-office : groupe « Blog », vrai tableau de bord | ✅ |
| A.3 | Mot de passe oublié, modification du mot de passe, édition du compte | ✅ + confirmation par le mot de passe actuel |
| A.4 | Espacement et responsive de la navbar | ✅ mesuré au navigateur, 3 langues, 10 largeurs |
| A.5 | Contenu d'article en blocs : images, texte à côté des images | ✅ |
| A.6 | Suppression automatique des images de blocs devenues inutiles | ✅ — résout D-15 |
| A.7 | Statut de publication **par langue**, recopie du français, retrait des champs SEO, renommage du slug du blog, finitions du front | ✅ — détail plus bas |

**A.5 — contenu en blocs**

Le besoin — « du texte sur les côtés des images » — ne pouvait pas être satisfait en
ajoutant un bouton à l'éditeur existant : le `RichEditor` de Filament s'appuie sur
**Trix**, qui sait insérer une image dans le flux mais ne sait pas placer du texte à côté.

Le corps de l'article se compose désormais dans un `Builder`, en cinq blocs typés : Texte,
Image, Image et texte, Citation, Galerie. Chaque bloc porte une intention ; le gabarit
Blade la traduit en une grille qui s'empile sur mobile.

Trois colonnes plutôt qu'une, parce qu'elles n'ont pas le même besoin d'échappement :
`content` (json, source de vérité), `search_text` (texte décodé, pour le LIKE de la
recherche) et `body` (HTML hérité, seul à être rendu quand il n'y a pas de blocs).
Réutiliser `body` comme texte de recherche a été essayé puis abandonné — une colonne ne
peut pas être à la fois du HTML sûr à rendre et du texte décodé à chercher.

**Deux défauts trouvés en relisant la sortie réelle de l'aplatissement**, tous deux
couverts par des tests : `strip_tags()` collait les blocs entre eux (« IntertitreDu
texte », donc introuvable en cherchant « intertitre »), et les entités survivaient à
l'assainissement (`l&#039;atelier` ne correspondait pas à « l'atelier »).

Vérifié au navigateur, captures à l'appui : les cinq blocs rendus, deux colonnes à 1440px,
empilement à 390px, aucun débordement horizontal, et la recherche qui retrouve un article
sur un mot présent uniquement dans la légende d'une photo.

Fichiers créés : `app/Support/{BlogBlocks,BlogImage}.php`,
`database/migrations/2026_08_02_150000_add_content_to_post_translations.php`,
`resources/views/blog/_blocks.blade.php`, `resources/views/blog/blocks/*.blade.php` (5),
`tests/Unit/BlogBlocksTest.php` (12 cas), `tests/Feature/BlogContentBlocksTest.php` (14 cas).
Fichiers modifiés : `app/Models/{Post,PostTranslation}.php`,
`app/Filament/Resources/PostResource.php`, `resources/views/blog/show.blade.php`,
`tests/Feature/PostResourceTest.php`.

**A.6 — ménage des images de contenu (résout D-15)**

Les images de blocs n'appartiennent pas à medialibrary : rien ne les supprimait, et
`storage/app/public/blog/content/` accumulait des orphelins. Elles partent désormais
d'elles-mêmes dès que plus aucune traduction ne les cite — article supprimé, langue
retirée, bloc image retiré ou image remplacée en cours d'édition.

**Trois pièges, dont deux auraient produit du code qui « marche » en test et ne fait rien
— ou fait pire que rien — en production :**

1. **`post_translations` est en `cascadeOnDelete()`.** À la suppression d'un article,
   c'est la BASE qui efface les traductions : aucun événement Eloquent ne part pour
   elles. Un hook sur `PostTranslation::deleted` n'aurait jamais vu le cas le plus
   évident. Les chemins sont donc relevés dans `Post::deleting`, tant que les lignes
   existent encore.
2. **Filament enveloppe chaque enregistrement dans une transaction**
   (`EditRecord::save()`) et la défait à la moindre exception. Supprimer depuis
   l'événement de modèle aurait effacé le fichier pendant que la base revenait en
   arrière : l'article aurait référencé une image disparue. D'où `DB::afterCommit()` —
   vérifié au préalable comme exploitable sous `RefreshDatabase`, sans quoi le
   comportement n'aurait pas été testable.
3. **Un même fichier peut être cité deux fois.** Le bouton « Cloner » du `Builder` ne
   duplique que les données, pas l'image : chaque suppression est donc précédée d'une
   relecture de ce qui reste référencé, et non d'un simple « ce bloc a disparu ». (La
   recopie d'une langue vers une autre, elle, duplique les fichiers depuis le A.7 — les
   deux langues sont indépendantes.)

Garde-fou : rien n'est jamais supprimé hors de `blog/content/`. Le chemin vient d'une
colonne JSON — le traiter comme une donnée de confiance ferait de ce ménage une primitive
de suppression de fichier arbitraire.

**Un défaut de test trouvé en route, à ne pas reperdre** : `fillForm()` **fusionne**
l'état d'un `Builder` au lieu de le remplacer. Le premier test « je retire un bloc »
laissait donc les blocs d'origine en place, et serait passé au vert sans avoir rien
retiré du tout — c'est en relisant le contenu réellement enregistré que la sortie l'a
montré. Il faut `->set('data.<relation>.content', …)`. Le test assère aussi, désormais, le
nombre de blocs restants : la fusion ne peut pas revenir sans le faire tomber.

Reste la commande de rattrapage `blog:prune-images` (`--dry-run`, `--force`), pour ce que
l'automatisme ne peut pas connaître : les fichiers laissés avant sa mise en place et ceux
d'un envoi interrompu. Volontairement **non planifiée** — un ménage qui supprime doit être
déclenché par quelqu'un qui vient de lire ce qu'il s'apprête à effacer.

**Vérifié par le vrai formulaire du back-office**, et pas seulement par les modèles :
retirer un bloc et vider une langue depuis la page d'édition Filament suppriment bien le
fichier, transaction comprise.

Fichiers créés : `app/Support/BlogContentImages.php`,
`app/Console/Commands/PruneBlogImages.php`,
`tests/Feature/BlogContentImagePruningTest.php` (22 cas).
Fichiers modifiés : `app/Support/BlogBlocks.php` (`imagePaths()`),
`app/Models/{Post,PostTranslation}.php`, `doc/back-office.md`.

**A.7 — statut par langue, recopie du français, allègement du back-office**

Six demandes traitées ensemble parce qu'elles se tiennent : elles portent toutes sur ce
que le rédacteur voit et sur ce qu'il peut mettre en ligne.

**1. Publication par langue.** L'existence d'une ligne de traduction valait publication :
préparer une traduction sans la publier exigeait de repasser l'ARTICLE ENTIER en brouillon,
donc de retirer aussi les langues déjà en ligne. Une colonne `post_translations.status`
s'ajoute au statut de l'article et **se combine** avec lui : une langue n'est en ligne que
si l'article l'est aussi. Le défaut est `published`, et c'est ce qui compte le plus dans la
migration — un défaut `draft` aurait dépublié toutes les traductions existantes au
déploiement.

Le brouillon de langue est **exactement aussi invisible qu'une langue absente** : exclu du
listing, 404 sur son URL, retiré des `hreflang`, du canonical, du sélecteur de langue et du
sitemap. La règle est levée en aperçu, qui sert précisément à relire une traduction avant sa
mise en ligne. Un index `(locale, status)` porte le `whereHas` de `scopePublishedIn()`, sur
le chemin du listing comme de la résolution d'URL.

Deux gestes distincts, désormais, et le guide client le dit : **passer une langue en
brouillon garde son texte**, **vider son titre l'efface**.

**2. « Recopier le français vers une autre langue ».** Recopie titre, chapô et blocs dans
les langues cochées. Cinq décisions, chacune pour une raison : l'action ne fait que des
`$set` (rien en base avant l'enregistrement) ; cocher une langue déjà traduite vaut
consentement à l'écraser, et le pré-cochage ne retient que les langues vides ; la langue
recopiée **passe en brouillon** — du français ne peut pas se retrouver en ligne sur une URL
anglaise ; le slug n'est pas recopié, il se regénère depuis le titre traduit (une langue
écrasée garde le sien, une URL connue du public ne bouge pas) ; les fichiers image sont
**dupliqués**, pour que remplacer l'image anglaise laisse la française intacte.

Deux pièges de forme, qui n'auraient pas sauté aux yeux : l'état d'un `Builder` est indexé
par UUID et ces clés servent de `wire:key` — réinjecter les mêmes dans un second Builder du
même composant Livewire donnerait deux éléments de clé identique, d'où la réindexation en
profondeur (répéteurs de galerie et `FileUpload` compris) ; et le schéma de la fenêtre doit
être un `Closure` pour recevoir le `$get` du formulaire principal, sans quoi la liste des
langues à cocher ne saurait pas lesquelles sont déjà remplies.

Contrepartie assumée : les copies sont écrites au clic, donc une édition abandonnée laisse
des fichiers non référencés — ce que ramasse `blog:prune-images`, qui existe déjà pour les
envois interrompus.

**3. Retrait des champs SEO du back-office** (articles et catégories). Les colonnes
`meta_*` existent toujours et sont toujours lues : laissées nulles, elles retombent sur le
titre, sur le chapô tronqué à 155 caractères et sur le `index,follow` du layout. Le
formulaire ne les expose plus, pour éviter deux titres à tenir à jour et une désindexation
posée par erreur. Les rouvrir un jour = remettre un `Fieldset`, rien à migrer. Celles des
catégories n'étaient d'ailleurs lues par aucun gabarit.

**4. Renommage du slug du blog** : `conseils` / `advice` / `consejos`, et les libellés qui
vont avec (menu, titres, méta, pagination). **Aucune 301 à poser** : la clé de route `blog`
n'a jamais existé sur `main` et la branche n'a jamais été poussée — il n'y a pas d'URL
indexée à préserver. Vérifié plutôt que supposé.

**5. Formulaire en deux colonnes** : la rédaction à gauche, les réglages à droite. Empilés
au-dessus de l'éditeur, statut, date, catégorie et couverture repoussaient le contenu — la
seule chose qu'on vienne vraiment faire ici — sous la ligne de flottaison. En dessous de
`lg`, la grille retombe à une colonne dans l'ordre du schéma : la rédaction d'abord, sans
une seule média-query.

**6. Deux finitions du front.** Le chapô des vignettes est coupé à **trois lignes en CSS**
et non au rendu : `line-clamp` compte des lignes, donc la coupe s'adapte à la largeur réelle
de la carte et à la taille de police du visiteur, là où un `Str::limit()` aurait dû choisir
un nombre de caractères valable pour le listing comme pour « À lire aussi ». Le texte entier
reste dans le HTML (lecteurs d'écran, meta description).

« À lire aussi » **sort de l'`<article>`** : la colonne de lecture est plafonnée à
`max-w-3xl`, où trois vignettes ne disposaient que de 224 px chacune quelle que soit la
taille de l'écran. Posée sur `max-w-5xl`, la section rend ~309 px par carte. Le nombre de
suggestions suit les colonnes — jamais de rangée orpheline : 2 empilées sous 640 px, 2 sur
une rangée à partir de 640 px, 3 à partir de 1024 px. La troisième est masquée **en CSS**,
le contrôleur ne connaissant pas la largeur de l'écran ; la faire dépendre d'un en-tête
client rendrait la page non cachable pour une vignette.

**Trois correctifs joints à ce lot**, trouvés en relisant l'ensemble avant déploiement :

- **D-14 corrigé** (`config/backup.php`) — cf. le tableau de la dette ;
- **les pages d'aperçu ne sont plus indexables.** En production le layout sert
  `index,follow` par défaut, et `blog/show.blade.php` ne posait `robots` que si
  `meta_robots` était renseigné — champ que ce lot vient justement de retirer du
  back-office. Un lien de relecture signé (7 jours) collé dans un fil public rendait donc un
  brouillon indexable, canonical auto-référent à l'appui. L'aperçu passe maintenant en
  `noindex,nofollow`, et un test le vérifie **en forçant l'environnement sur
  « production »** — hors production le layout désindexe tout, le test serait passé au vert
  quoi qu'on écrive dans la vue ;
- **`MysqlTestCase::column()` ne sélectionnait pas `COLUMN_DEFAULT`.** L'assertion qui
  vérifiait le défaut `published` de la nouvelle colonne lisait donc une propriété
  inexistante et tombait en « Undefined property » — la suite MySQL était rouge, et le point
  le plus important de la migration n'était en réalité pas couvert. Le schéma, lui, était
  correct : vérifié en base (`varchar(20)`, défaut `published`, index `(locale, status)`).

Fichiers créés : `database/migrations/2026_08_07_090000_add_status_to_post_translations.php`,
`config/backup.php`, `tests/Feature/BlogLanguageStatusTest.php`.
Fichiers modifiés : `app/Models/{Post,PostTranslation}.php`,
`app/Filament/Resources/{PostResource,CategoryResource}.php`,
`app/Support/BlogContentImages.php` (`duplicate()`),
`app/Console/Commands/DatabaseBackup.php`, `lang/{fr,en,es}/{routes,menu,blog}.php`,
`resources/views/blog/{_card,show}.blade.php`, `database/factories/PostTranslationFactory.php`,
`deploy.sh`, `doc/{guide-back-office,deploiement,blog-schema,env,backups}.md`,
`tests/Database/MysqlTestCase.php`, et les suites du blog, du back-office et des
sauvegardes.

---

---

## Écarts au plan, assumés

**1. Pinning Composer reporté du lot 0.1 au lot 3.1. ✅ résolu au 3.1a.**
`config.platform` est comparé au `platform-overrides` du lock : le pinning exige donc
un refresh du lock. Or trois versions verrouillées ont disparu de Packagist
(`nette/utils 4.1.2`, `laravel-lang/actions 1.12.0`, `laravel-lang/attributes 2.15.2`)
et un refresh ciblé déplace une vingtaine de versions, dont `guzzle 7.10 → 7.15` qui
porte l'intégration Dolibarr. Faire ce brassage juste avant une bascule de base de
production était le mauvais compromis : ce sera un changement unique et cohérent avec
le `composer require` de Filament, suivi de la suite de tests complète.

**2. `Mail::raw()` remplacé par un mailable dédié `QuotationNotArchived`.**
`MailFake::raw()` est un no-op dans Laravel 10 : l'alerte technique n'était pas
assertable. Le mailable est délibérément **sans** `ShouldQueue` ni `SerializesModels`
et ne reçoit que des données brutes — même garantie à l'exécution (il part alors
qu'aucune ligne n'existe en base), mais testable.

**3. `publishedLocales()` retourne les langues dans l'ordre de `supported_locales`.**
L'ordre des lignes en base n'est pas garanti, et cette sortie alimente les `hreflang`,
le sitemap et le sélecteur de langue : il doit être stable.

**4. `sitemap:generate` planifiée au 2.5 et non au lot 1. ✅ résolu.**
Le plan la plaçait dans `Kernel::schedule()` dès le lot 1. La commande produisait alors
des `<loc>` **relatives**, donc un sitemap invalide : la planifier aurait publié une
erreur tous les jours. Elle a été réécrite au 2.5 (URLs absolues, gabarits écartés,
articles ajoutés par langue traduite) puis planifiée à 04:15.

**5. Le contrôle d'archive et la rétention vivent dans `App\Support\SqlDumpArchive`,
pas dans la commande.** Sans cette séparation, rien n'était testable sans un MySQL
joignable — et c'est justement la partie qu'il faut avoir vérifiée avant de dépendre
d'une sauvegarde.

---

## Prérequis production — bloquants pour la mise en production

À relever sur `gocap-mostiglass-2`. Les commandes correspondantes sont regroupées en
tête de `doc/mysql.md`, § « Prérequis » : un seul copier-coller donne les points 1 à 3,
6, 9 et 10.

1. ✅ **Extensions PHP — vérifié le 01/08/2026, CLI et FPM.**
   PHP **8.2.32**, une seule version installée (`/etc/php/8.2`), **PHP-FPM 8.2** derrière
   nginx (pas d'Apache). Présents côté CLI : `intl`, `gd`, `imagick`, `exif`, `fileinfo`,
   `zip`, `pdo_mysql`, `mysqli`, `pdo_sqlite`. Et `intl` est bien chargé par le SAPI web :
   `/etc/php/8.2/fpm/conf.d/20-intl.ini → mods-available/intl.ini`.
   **`ext-intl` ne bloque donc plus le lot 3** — l'exigence était réelle,
   `filament/support` v3.3 déclare `"ext-intl":"*"`.
   ⚠️ Piège rencontré : `diff <(php -m) <(php-fpm -m)` échoue en tant que `www-data`, le
   binaire étant dans `/usr/sbin`, hors PATH. Le diff renvoie alors un **faux négatif
   silencieux** (tout le CLI apparaît comme « supprimé »). Contrôler les liens de
   `fpm/conf.d/` est plus fiable : c'est le mécanisme réel.
2. 🚨 **MySQL n'est PAS encore installé** (01/08/2026) — installation prévue par
   l'hébergeur. **À cadrer avant qu'il n'agisse** : sur Debian 12, « MySQL » depuis les
   dépôts officiels donne **MariaDB** (`default-mysql-server` → `mariadb-server`, il
   n'existe pas de `mysql-server` officiel).
   Recommandation : **Oracle MySQL 8**, la suite y est verte à 43/43 contre 36/43 sur
   MariaDB 11.8. Les écarts mesurés et la marche à suivre si MariaDB est imposé sont
   dans `doc/mysql.md` § 0. Reste aussi à obtenir l'accès `root` pour créer la base et
   l'utilisateur.
3. 🚧 **`<CHEMIN_APP>` = `/var/www/mostiglass`** (confirmé), application possédée par
   **`www-data`** qui dispose bien d'un shell. Reste `<BINAIRE_PHP>` : `command -v php`.
4. ⬜ La prod est-elle derrière un reverse proxy TLS ? (`TrustProxies` est vide → URLs signées et lien de reset de mot de passe cassés)
5. ⬜ `APP_URL` réel (impacte images, sitemap, URLs signées)
6. 🚨 **`crontab` : command not found** (01/08/2026). À lever d'urgence : ce n'est
   peut-être pas « la ligne manque » mais « cron n'est pas installé ». Sans planificateur,
   `db:backup` **et** `sitemap:generate` ne tourneront jamais — donc aucune sauvegarde.
   À trancher :
   ```bash
   ls -l /usr/bin/crontab; systemctl status cron; systemctl list-timers --all | head
   ```
   Deux issues possibles, cf. `doc/backups.md` § Planification : un fichier
   `/etc/cron.d/mostiglass` (préférable — il nomme explicitement l'utilisateur) ou un
   timer systemd.
7. ⬜ Destinataire de `MAIL_ALERTS_RECIPIENT`
8. ⬜ Sauvegarde hors-machine de `storage/app/backups` et `storage/app/public`
9. ⬜ Volumétrie SQLite de prod et nombre de `total` non entiers (confirme l'ampleur de D1)
10. ⬜ Outil de dump — **absent** au 01/08/2026 (`mysqldump` et `mysql` : ABSENT), normal
    puisque MySQL n'est pas installé. Il viendra avec le serveur. Attention : selon le
    moteur, le binaire s'appelle `mysqldump` **ou** `mariadb-dump` — `db:backup` gère
    désormais les deux (cf. `doc/backups.md`). Bloquant pour les 3 tests de restauration
    de `tests/Database/DatabaseBackupRoundTripTest.php`, à jouer sur le serveur.
11. 🚧 **OPcache est actif** (vérifié le 01/08/2026). `./deploy.sh` recharge PHP-FPM — mais
    `www-data` ne pilote pas systemd : **une règle sudoers reste à poser**, restreinte à
    `systemctl reload php8.2-fpm`. Cf. `doc/deploiement.md` § 1.c. Sans elle, le premier
    déploiement s'arrête avant de rouvrir le site (à dessein).
12. ⬜ **Node et npm** — relevé au lot 4.1, jamais listé jusque-là. `public/build/` n'est
    pas versionné et ne l'a jamais été : sans build sur le serveur, le site est servi sans
    style ni JavaScript. Version attendue : celle de `.nvmrc` (Node 20). À défaut,
    construire ailleurs et `rsync public/build/`, puis déployer avec `--skip-assets`.

---

## Dette technique repérée, à arbitrer

| # | Sujet | Statut |
|---|---|---|
| D-1 | `config/logging.php` code `stack` sur `['single']` → `LOG_STACK` ignoré, et le canal `nightwatch` du `.env` n'existe pas | ⬜ à corriger avec le niveau Discord, sinon flood |
| D-2 | `config/app.php` : `locale`/`fallback_locale` en dur → `APP_LOCALE`/`APP_FALLBACK_LOCALE` ignorés | ⬜ à documenter (bénéfique pour le panneau FR) |
| D-3 | `config/cache.php` lit `CACHE_DRIVER` alors que le `.env` définit `CACHE_STORE` ; `session.encrypt` en dur | ⬜ à documenter |
| D-4 | URLs EN cassées : `Lang::uri('legale-notices')` / `('venturi')` vs `lang/en/routes.php` | 🚧 **confirmé et mesuré au 4.3** : `/en/legal-notices` et `/en/venturi-effect-and-application` en 404, pages servies sous slug français. Correctif prêt, **en attente d'arbitrage** — il change deux URLs publiques (+ deux 301) |
| D-5 | `@error('lastname')` dans le formulaire de devis alors que les clés sont `user.lastname` → blocs jamais affichés | ⬜ lot UX |
| D-6 | Webhook Discord en clair dans `.env`, `chmod 600 .env` en prod | ⬜ rotation recommandée |
| D-7 | `vendor/` en avance sur `composer.lock` | ✅ **résorbé au 3.1a** : le refresh du lock a réaligné les deux |
| D-8 | `composer audit` : 28 advisories sur 11 paquets | 🚧 **ramené à 10 sur 4** par le refresh du 3.1a, sans intervention ciblée. Le reste à arbitrer |
| D-9 | `resources/views/layouts/theme.blade.php` : layout résiduel d'un autre projet | ✅ **supprimé au 2.6** — zéro référence, et il incluait des partials `theme.*` inexistants ; il portait les métadonnées SEO d'une autre agence. Récupérable dans l'historique git. |
| D-10 | `build/` à la racine : vestige d'un ancien emplacement de sortie Vite | ✅ **supprimé au 4.1** — aucune référence dans le code, `public/build/` porte les assets réellement servis, vérifié après suppression. Récupérable dans l'historique git. |
| D-11 | Les huit stubs `public/int/*.php` redirigent en **302** vers des URLs sans préfixe de langue, qui re-redirigent en 301 ; `contact.php` mélange `www.` et non-`www.` | ⬜ **repéré au 4.3**, non touché : fichiers hérités portant du référencement réel |
| D-12 | `RemoveIndexMiddleware` : `strpos($uri, '/int/')` vaut `0` — donc **faux** — pour toute URL commençant par `/int/`. La branche ne se déclenche jamais pour les URLs qu'elle vise | ⬜ **repéré au 4.3**, non corrigé : la « corriger » ferait passer `/int/gocap.php` par l'accueil au lieu de `qui-sommes-nous`, écrasant les stubs plus précis. À traiter avec D-11, pas avant |
| D-15 | Les images des blocs de contenu ne sont pas supprimées avec leur bloc : `storage/app/public/blog/content/` accumule des fichiers orphelins | ✅ **résolu au A.6** — suppression automatique dès qu'aucune traduction ne cite plus le fichier (article supprimé, langue retirée, bloc retiré, image remplacée), après commit et jamais hors de `blog/content/`. Commande `blog:prune-images` pour les orphelins d'avant |
| D-14 | `app/Console/Commands/DatabaseBackup.php` appelait `env('BACKUP_PATH')`, `env('BACKUP_KEEP_DAYS')` et `env('BACKUP_MYSQLDUMP')` **hors d'un fichier de `config/`**. Avec `config:cache` — que fait `deploy.sh` —, `env()` ne lit plus `.env` et renvoie `null` : les trois variables étaient **silencieusement ignorées en production**, et la rétention retombait sur 14 jours quoi qu'indique le `.env` | ✅ **résolu au A.7** — `config/backup.php`, trois `config()` dans la commande, quatre tests : destination, binaire et rétention lus depuis la configuration (l'angle mort qui avait laissé passer le défaut : seul le chemin « la valeur vient de l'option » était couvert), plus un garde-fou qui refuse le retour d'un `env('BACKUP_*')` hors de `config/`. Documenté dans `doc/env.md` et `doc/backups.md` |
| D-13 | `redirect(null, 301)->route(...)` répond **302** : sans destination, `redirect()` jette son second argument et `->route()` repart sur son défaut. Les six redirections héritées s'annoncent permanentes et sont temporaires | ⬜ **mesuré au 4.3**, non corrigé. Correctif d'un argument déplacé, mais il change le statut d'URLs publiques : à arbitrer avec D-4 et D-11, pour ne poser qu'une seule fois les 301 définitifs |

### D-14 — correctif appliqué au lot A.7

Consigné le 04/08/2026 pour être repris à froid, **appliqué le 08/08/2026** tel que
décrit ci-dessous : `config/backup.php`, les trois `env()` remplacés par `config()`, le
test « la valeur vient de la configuration » qui manquait, le garde-fou contre le retour
d'un `env('BACKUP_*')` hors de `config/`, et l'entrée dans `doc/env.md`. Le compte rendu
est conservé ici : c'est le raisonnement, pas seulement la retouche.

**Où** — trois appels, tous dans `app/Console/Commands/DatabaseBackup.php` :

| Ligne | Aujourd'hui |
|---|---|
| 47 | `$this->option('path') ?: env('BACKUP_PATH') ?: storage_path('app/backups')` |
| 179 | `$this->option('binary') ?: env('BACKUP_MYSQLDUMP')` |
| 250 | `$this->option('keep-days') ?: env('BACKUP_KEEP_DAYS', 14)` |

**Pourquoi c'est un défaut** — `env()` ne lit `.env` que tant que la configuration n'est
pas en cache, et `deploy.sh` fait `config:cache`. En production, ces trois appels
renvoient donc `null` **sans rien signaler**, et la rétention retombe sur 14 jours quoi
qu'indique le `.env`. Vérifié expérimentalement pendant le lot 4.

Ce qui le rend particulièrement discret : les options de ligne de commande (`--path`,
`--binary`, `--keep-days`) continuent, elles, de fonctionner. Un essai manuel avec une
option passe donc parfaitement, pendant que la tâche planifiée de 03:15 ignore le `.env`.

**Le correctif** — exactement le patron déjà retenu pour `config/admin.php` au lot A.1.

1. Créer `config/backup.php` :

```php
return [
    'path'      => env('BACKUP_PATH') ?: storage_path('app/backups'),
    'keep_days' => (int) env('BACKUP_KEEP_DAYS', 14),
    'mysqldump' => env('BACKUP_MYSQLDUMP'),
];
```

2. Remplacer les trois `env(...)` par `config('backup.path')`,
   `config('backup.mysqldump')` et `config('backup.keep_days')`.

Dans un fichier de `config/`, `env()` est légitime : c'est précisément ce que
`config:cache` évalue et fige. Ailleurs, non.

**À ajouter autour du correctif**

- un test qui fixe `config(['backup.keep_days' => 0])` et vérifie que la purge obéit :
  aujourd'hui, seul le chemin « la valeur vient de l'option » est couvert, jamais
  « la valeur vient de la configuration » — c'est exactement l'angle mort qui a laissé
  passer le défaut ;
- un garde-fou qui échoue si `BACKUP_*` réapparaît dans un `env()` hors de `config/` ;
- une entrée dans `doc/env.md`, section « variables lues autrement qu'on le croit »,
  où l'on ira les chercher.

**Effet de bord à connaître** — une fois en configuration, modifier `BACKUP_KEEP_DAYS`
dans le `.env` de production n'aura d'effet qu'après un `php artisan config:cache`.
`deploy.sh` le fait à chaque déploiement : sans conséquence en pratique, mais à savoir.
C'est déjà documenté en tête de `config/admin.php`.

Charge estimée : une demi-heure, tests et documentation compris.

---

## Repères pratiques

Le détail fichier par fichier n'est plus recopié ici : tout est commité, `git show` et
`git log --stat` sont à jour et ne peuvent pas diverger du code. Les commits de fond sont
listés en tête de document.

**Commandes ajoutées par le chantier**

```bash
php artisan db:migrate-legacy-sqlite --dry-run   # diagnostic de bascule, n'écrit rien
php artisan db:migrate-legacy-sqlite             # transfert + vérification
php artisan db:migrate-legacy-sqlite --verify    # comparaison seule
php artisan db:backup                            # dump gzip + contrôle + purge
php artisan sitemap:generate                     # sitemap.xml (pages + articles)
php artisan mostiglass:admin <email>             # crée ou promeut un compte back-office
php artisan mostiglass:admin <email> --revoke    # retire l'accès, conserve le compte
php artisan blog:prune-images --dry-run          # images de contenu orphelines, n'efface rien
php artisan blog:prune-images                    # les supprime, après confirmation
```

**Déployer**

```bash
sudo -u www-data ./deploy.sh --dry-run        # déroule les contrôles, n'écrit rien
sudo -u www-data ./deploy.sh --ref origin/main
```

**Lancer les tests** (toujours avec `-u sail` : cf. piège n° 2)

```bash
docker compose exec -T -u sail laravel.test php artisan test          # SQLite en mémoire
docker compose exec -T -u sail -e DB_HOST=mysql -e DB_PORT=3306 -e DB_DATABASE=testing \
  laravel.test php artisan test -c phpunit.mysql.xml                  # MySQL, référence
docker compose exec -T -u sail -e DB_HOST=mysql -e DB_PORT=3306 -e DB_DATABASE=testing \
  laravel.test php artisan test -c phpunit.mysql.xml --testsuite Database
```

**Où trouver quoi**

| Sujet | Fichier |
|---|---|
| **Déploiement**, prérequis serveur, retour arrière | `doc/deploiement.md` |
| Bascule SQLite → MySQL, pièges du moteur, suite de tests MySQL | `doc/mysql.md` |
| Sauvegardes, planification, **restauration** | `doc/backups.md` |
| Variables d'environnement, et celles qui sont lues autrement qu'on le croit | `doc/env.md` |
| Intégration Dolibarr et ses deux pièges d'API | `doc/dolibarr.md` |
| **Mode d'emploi éditorial** du back-office | `doc/guide-back-office.md` |
| Le back-office côté technique | `doc/back-office.md` |
| Les tables du blog et leurs contraintes | `doc/blog-schema.md` |

---

## Décisions actées

Back-office **Filament v3.3** · traductions en **tables séparées** · médias via
**medialibrary v11** · slug FR **`conseils`** (renommé au lot A.7, l'ancien n'a jamais
été en ligne) · publication **par langue** (traduction présente **et** langue publiée,
cf. A.7) · échec d'écriture → **le devis part quand
même** · **suppression du 301 global** sur les 404 · éditeur **RichEditor** natif ·
assainissement HTML **à l'écriture** · **aucun thème Filament custom** · rôles via
**`users.is_admin`**.
