# Stabilité des URL éditoriales

## Constat initial

| Famille | Génération et modification avant ce chantier | Cockpit avant ce chantier | Route publique | Publication |
| --- | --- | --- | --- | --- |
| Projet | Génération dans le CRUD si vide ; pas de recalcul d'un slug renseigné | Champ désactivé défini mais absent du formulaire retourné | `/projet/{slug}` | `online` |
| Offre | Recalcul aux événements de création et modification, et dans le CRUD | Pas de champ slug | `/offre/{slug}` | `published` et description non vide |
| Ressource | Recalcul aux événements de création et modification | Pas de champ slug | `/ressource/{slug}` | Description non vide |
| Pôle | Génération si vide | Pas de champ dans le CRUD principal | `/pole/slug/{slug}` ; `/pole/{id}` redirige déjà vers cette adresse | `online` |
| Page | Annotation Gedmo liée au titre, avec mise à jour par défaut ; champ EasyAdmin lié au titre | Slug éditable mais couplé au titre | `/page/{slug}` | `online` |

L'unicité existait pour toutes ces familles sauf Projet. Aucun historique éditorial n'était conservé. Offre, Ressource et Page pouvaient donc changer d'adresse après une modification du titre.

La route interne Ressource dépend de sa description, même si la fiche possède aussi des paramètres de lien externe : cette règle existante est conservée. Les réponses de remplacement vers les listes pour Projet, Offre et Ressource indisponibles deviennent des 404, conformément au comportement demandé. Pôle et Page répondaient déjà 404.

Hors périmètre : le chargeur historique `CmsRouteLoader` référence `FrontController::slugs`, méthode absente. Il n'est pas réécrit ; les Pages sont testées sur leur route publique `/page/{slug}`.

## Fonctionnement retenu

Un champ texte facultatif « Adresse (slug) » permet une modification volontaire dans les cinq CRUD principaux. À la création, un champ laissé vide est généré depuis le titre lors de l'enregistrement. Après enregistrement, le titre ne pilote plus l'adresse ; vider un slug existant produit une erreur de validation. L'aide du champ explique ce fonctionnement.

`EditorialSlugRegistry` conserve dans `editorial_slug` la famille, le slug, l'identifiant stable du contenu et le fait que cette adresse a été publique. La contrainte unique `(family, slug)` réserve une adresse à une seule fiche de cette famille. Les réservations couvrent aussi les brouillons et restent après suppression pour empêcher une réattribution accidentelle ; seuls les slugs ayant été publics peuvent rediriger. Une fiche peut reprendre l'un de ses propres slugs.

Le subscriber Doctrine enregistre l'ancien et le nouveau slug dans la transaction d'enregistrement. Le validateur donne une erreur près du champ en cas de collision ; les contrôles de persistance et les contraintes SQL restent actifs pour les écritures ORM hors Cockpit. Les écritures SQL directes contournent les événements Doctrine : ne pas les utiliser pour modifier les slugs ou la publication.

Le subscriber HTTP intervient après le routeur et `PublicUrlSubscriber`, avant la conversion des paramètres des Pages. Une adresse actuelle garde toujours la priorité. Sinon, l'historique retrouve la fiche par identifiant, contrôle sa publication et produit une 301 directement vers son slug actuel. Il n'enregistre aucune URL de destination intermédiaire. Une fiche absente ou non publique ne provoque aucune redirection.

Les préfixes `/public/`, `/index.php/`, les anciennes structures et la normalisation de domaine restent traités par le mécanisme existant. Une URL cumulant une ancienne forme technique et un ancien slug peut donc recevoir une redirection technique, puis une redirection éditoriale ; les changements successifs de slug, eux, ne créent pas de chaîne. Les paramètres de requête d'une ancienne adresse éditoriale ne sont pas recopiés dans la destination.

Canoniques et sitemap restent fondés sur les slugs actuels. Les apostrophes et accents de certaines adresses existantes sont acceptés ; espaces, slashs, paramètres, fragments et séquences encodées injectées sont refusés pour les nouvelles adresses et les modifications volontaires.

## Migration préparée, non appliquée aux bases réelles

`migrations/Version20260924120000.php` concerne exclusivement la base éditoriale SQLite :

- création du registre et de ses index ;
- ajout de l'unicité sur `projet.slug` ;
- réservation des slugs actuels non vides, avec leur état de publication au moment de la migration ;
- arrêt en cas de doublon, sans réécriture automatique des contenus ; les formats historiques hors validation actuelle ne bloquent pas la copie.

Les slugs actuellement vides ne sont pas inventés par la migration. Une valeur existante inchangée peut être conservée ; toute nouvelle adresse volontaire doit être valide. L'état passé de publication des fiches déjà hors ligne est inconnu : un ancien slug confirmé peut être rattaché explicitement après republication.

Le retour arrière automatique est interdit pour ne pas perdre l'historique. La migration doit être validée puis appliquée avant l'utilisation du nouveau Cockpit et des recherches d'anciennes adresses. Tant qu'elle n'est pas appliquée, le code dépendant de la nouvelle table n'est pas utilisable sur la base concernée. Ce chantier prépare la migration mais ne l'exécute sur aucune base réelle.

## Rattacher une ancienne URL connue

La commande `app:editorial-slug:add` exige la famille, l'identifiant de la fiche, l'ancien slug et le slug actuel attendu. Sans `--apply`, elle vérifie et affiche la correspondance sans écriture. Avec `--apply`, elle la revérifie dans une transaction et réserve l'alias. Une cible absente, non publique, différente du slug attendu ou une collision est refusée. Répéter le même rattachement est sans effet supplémentaire.

Cas « Parcours croisés » : l'inspection en lecture seule de la base locale a identifié l'offre **4**, intitulée « Parcours croisés / Design de service », publiée sous `parcours-croises-design-de-service`. Cet identifiant doit être vérifié séparément dans chaque environnement : il n'est pas codé en dur dans la migration ni dans le mécanisme. Le test crée sa propre fiche et vérifie que l'alias retrouve exactement son identifiant.

Après migration autorisée et confirmation de l'identifiant dans l'environnement visé, contrôler d'abord :

```sh
php bin/console app:editorial-slug:add offre ID_CONFIRME parcours-croises-en-design-de-service --expected-current-slug=parcours-croises-design-de-service
```

Puis, après vérification de la fiche affichée, enregistrer explicitement :

```sh
php bin/console app:editorial-slug:add offre ID_CONFIRME parcours-croises-en-design-de-service --expected-current-slug=parcours-croises-design-de-service --apply
```

`ID_CONFIRME` est à remplacer par l'identifiant vérifié. Aucune de ces écritures n'a été effectuée sur les données réelles pendant ce chantier. La réparation de cette ancienne URL y reste donc à appliquer.

Pour une autre URL repérée dans Search Console, identifier sa famille et sa fiche par vérification éditoriale, puis utiliser la même commande. Fournir uniquement le segment de slug, sans `/offre/`, `/pole/slug/`, `/page/`, domaine ou query string. Aucune correspondance n'est déduite d'une ressemblance de titres ou de slugs.

## Validation locale et préproduction

Les tests `tests/EditorialSlugs/EditorialSlugTest.php` couvrent les cinq familles : création automatique et manuelle, titre stable, changements successifs, collisions, retour à son propre slug, dépublication, suppression, réservations, priorité aux URL actuelles, publication après brouillon, 301 GET/HEAD, coexistence avec les redirections techniques, canoniques, sitemap, migration et rattrapage « Parcours croisés ». Un parcours de formulaire Page contrôle aussi l'édition et l'erreur de validation dans le Cockpit. Le cache Symfony de production est construit dans un répertoire temporaire.

Les bases et fichiers de ces tests sont créés exclusivement dans des répertoires temporaires isolés. Les suites existantes `LegacyRoutesSecurityTest`, `PublicMetadataTest`, `PoleRedirectTest`, `PublicUrlSubscriberTest` et `ProjetCanonicalTest` vérifient les régressions concernées.

Résultat local : **122 tests, 841 assertions réussies** (29 nouveaux scénarios et 93 tests existants), syntaxe PHP valide pour les 22 fichiers concernés, `git diff --check` sans erreur et construction du cache réussie. PHPUnit signale des dépréciations des dépendances existantes ; aucune dépendance n'a été modifiée. Aucun LESS n'a changé.

En préproduction, après autorisation de la migration et sur des fiches de contrôle : vérifier visuellement les cinq formulaires Cockpit, créer sans saisir de slug, modifier un titre puis volontairement l'adresse, tester les anciennes URL et les collisions, dépublier, et contrôler canonique/sitemap. Confirmer séparément l'identité de l'offre « Parcours croisés » avant son rattachement. Aucun test en préproduction, déploiement ou changement de données réelles n'est inclus dans cette livraison.


## Correction de compatibilité des données historiques

La migration réserve les valeurs existantes non vides **exactement telles quelles**, sans filtre de format ni transformation. Les contrôles de doublons et les index uniques restent actifs. La validation du registre et du Cockpit accepte une valeur hors format uniquement si elle est identique à celle encore stockée sur la même fiche (identifiant vérifié en base). Cela permet de modifier le titre ou la publication sans imposer un changement d'adresse. Les champs ne suppriment plus automatiquement les espaces en bordure. Une création ou une modification volontaire reste soumise à la validation stricte ; même le propriétaire ne peut pas reprendre un ancien alias invalide après l'avoir remplacé.

Formats potentiellement concernés, outre les slashs : espaces, antislashs, caractères `%`, `?`, `#`, ponctuation initiale, lettres avec accents décomposés (caractères combinants), caractères de contrôle et longueurs supérieures à 255 caractères. SQLite conserve ces valeurs sans appliquer la longueur indicative du VARCHAR. Leur conservation en base ne vaut pas autorisation de les utiliser comme destination HTTP. Les apostrophes droites et typographiques et les lettres accentuées précomposées sont déjà acceptées par la validation actuelle.

Les routes publiques conservent leur paramètre `{slug}` à un seul segment, sans route supplémentaire ni correspondance approximative. Les trois exemples `parcours-croises-/-design-de-service-4`, `audit-ux/ui-outils-internes-ou-solution-7` et `formation-ux/ui-13` restent donc **404**, avant comme après un changement volontaire vers un slug valide, y compris avec un slash encodé `%2F`. Ils sont réservés en base mais ne deviennent ni des routes ni des destinations de redirection. Aucune suppression du slash ni correspondance avec le début du slug n'est tentée. Une réparation HTTP de ces adresses demanderait un chantier explicite distinct ; elle n'est pas inventée ici.

Un ancien slug avec apostrophe typographique reste accessible avant modification, puis redirige directement vers le slug actuel valide. Une cible dépubliée ne redirige pas et reste inaccessible. Pour les autres formats historiques, le comportement des routes existantes est conservé ; le mécanisme d'historique ne prend en charge que les sources et destinations respectant sa validation stricte. La commande de rattachement conserve également cette validation : elle ne sert pas à inventer une route multi-segment.

Les tests de correction migrent les quatre exemples dans une base temporaire, vérifient les valeurs et propriétaires inchangés, une édition du titre, les URL brutes et encodées avant/après changement du slug, les collisions et la dépublication. Aucun accès aux bases réelles n'est nécessaire.
