---
suivi: 1639
date: 2026-09-22
sujet: Correctif preg_match « Unknown modifier '|' » sur le dépôt de documents foncier
chantier: module-foncier
type: fix
statut: poussé
hash: ccd319c7
fichiers:
  - app/Http/Controllers/Foncier/Concerns/ManagesFoncierEntityDocuments.php
  - tests/Unit/FoncierDocumentsReturnToRegex1639Test.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - docs/suivi/SUIVI_1639_fix_preg_match_documents_foncier.md
---

# SUIVI #1639 — fix `preg_match(): Unknown modifier '|'` (documents foncier)

## PROMPT ENVOYÉ

Erreur de production
`preg_match(): Unknown modifier '|' at .../ManagesFoncierEntityDocuments.php:141`.
Corriger le motif (délimiteurs sûrs, `preg_quote` sur toute portion variable),
vérifier les occurrences similaires, ne rien élargir ni restreindre, aucun `.vue`
modifié, test unitaire qui exécute réellement `preg_match`.

## 1. LIGNES 130-150 D'ORIGINE, EN BRUT

Fichier `app/Http/Controllers/Foncier/Concerns/ManagesFoncierEntityDocuments.php`
(état avant correctif ; la trace prod cite la ligne 141, le fichier local était
à la ligne **145** — le #1627 a inséré 4 lignes de commentaire dans la méthode
`storeEntityDocuments` en amont, entre le log du 21/09 12:52 et aujourd'hui.
C'est le **même** `preg_match`, la seule instruction PCRE du fichier.)

```
        $this->foncierDocumentAccess()->assertDelete($user, $documentable);
        $this->foncierDocuments()->assertDocumentBelongsTo($documentable, $entityDocument);

        $this->foncierDocuments()->delete($entityDocument);

        return redirect()
            ->to($this->resolveDocumentReturnTo($request, $documentable))
            ->with('success', 'Document supprimé.');
    }

    /**
     * Retour vers l’onglet DOCUMENTS du dossier si demandé (#1203).
     */
    protected function resolveDocumentReturnTo(Request $request, Model $documentable): string
    {
        $default = $this->foncierDocumentRedirectRoute($documentable);
        $return = $request->input('return_to');
        if (! is_string($return) || $return === '') {
            return $default;
        }
        if (! preg_match('#^/foncier/dossiers/\d+(\?|#|$)#', $return)) {
            return $default;
        }

        return $return;
    }
}
```

### Construction du 1er argument de `preg_match`
**Il n'y en a pas.** Le motif est une **chaîne littérale** écrite en dur dans
l'appel : aucune concaténation, aucune constante, aucune config, aucune liste
d'extensions, aucune variable. Remontée faite : le seul identifiant de la ligne
est `$return`, qui est le **SUJET** (2ᵉ argument), pas le motif. Il vient de
`$request->input('return_to')`, validé plus haut en
`['nullable', 'string', 'max:500']` (L53).

L'hypothèse du prompt (liste d'extensions jointe par `|` et injectée sans
`preg_quote`) est donc **écartée** : il n'y a rien à `preg_quote` ici. La liste
d'extensions (`FoncierEntityDocumentService::TYPES_ACCEPTES`) est validée par
**consultation de tableau** (`isset(self::TYPES_ACCEPTES[$ext])`) et le service
ne contient **aucun** appel PCRE (vérifié par test).

## 2. POURQUOI LE MOTIF EST INVALIDE, ET QUAND

`'#^/foncier/dossiers/\d+(\?|#|$)#'`

Le délimiteur est `#` (1ᵉʳ caractère). PCRE ferme donc le motif au **prochain `#`
non échappé** — celui de l'alternation `(\?|#|$)`. Résultat :

- motif réellement compilé : `^/foncier/dossiers/\d+(\?|`
- reste de la chaîne interprété comme **modificateurs** : `|$)#`
- premier « modificateur » lu : `|` → **« Unknown modifier '|' »**

Reproduit hors ERP, sortie brute :

```
$ php -r "var_dump(preg_match('#^/foncier/dossiers/\d+(\?|#|$)#', '/foncier/dossiers/12?onglet=documents')); var_dump(preg_last_error_msg());"
bool(false)
string(14) "Internal error"
PHP Warning:  preg_match(): Unknown modifier '|' in Command line code on line 1
```

En production, Laravel convertit ce warning en `ErrorException` → **HTTP 500**.

### Données d'entrée qui déclenchent l'erreur
L'erreur ne dépend **ni du nom de fichier, ni de l'extension, ni de l'entité** :
elle dépend **uniquement de la présence d'un `return_to` non vide**. Le motif
étant invalide, il échoue pour **toute** valeur.

- `return_to` **absent ou vide** → `return $default` avant le `preg_match` → **aucune erreur**.
- `return_to` **présent** → `preg_match` exécuté → **erreur systématique**.

Qui envoie `return_to` ? `EntityDocumentsPanel.vue` l'ajoute au payload
uniquement si la prop est renseignée (L140-141 pour le dépôt, L158-159 pour la
suppression), et deux écrans seulement la renseignent :

| Écran | Source de `documents_return_to` | Impact avant correctif |
|---|---|---|
| Onglet Documents d'un **dossier foncier** | `DossierFoncierController` L642 (`/foncier/dossiers/{id}?onglet=documents#documents`, variante corbeille) | **500 à chaque dépôt ET chaque suppression** |
| Onglet **Foncier d'un programme** | `ProgrammeFoncierController` L36 (`/programmes/{id}/foncier?onglet=documents#documents`) | **500 à chaque dépôt ET chaque suppression** |

Toutes les autres fiches (offres, promesses, avenants, actes, valorisations,
échanges, parcelles, et scénarios depuis le #1629) ne passent **pas** de
`return_to` → elles n'ont **jamais** été affectées.

Conséquence : la fonctionnalité « revenir à l'onglet Documents » (#1203) n'a
**jamais fonctionné** depuis son écriture — tout appel la portant tombait en 500.
Le document lui-même était déjà enregistré (le `preg_match` est postérieur au
`storeMany` / `delete`), d'où un ressenti « ça plante mais le fichier est là ».

## 3. CORRECTIF

**Avant**

```php
if (! preg_match('#^/foncier/dossiers/\d+(\?|#|$)#', $return)) {
```

**Après**

```php
if (! preg_match('~^/foncier/dossiers/\d+(\?|\#|$)~', $return)) {
```

- délimiteur **`~`**, absent du motif ;
- `#` interne **échappé** (`\#`) : inoffensif aujourd'hui, et robuste si le
  modificateur `x` était ajouté un jour (où `#` introduirait un commentaire) ;
- **intention strictement conservée** : ancrage `^`, `\d+` pour l'identifiant,
  et fin de motif sur `?`, `#` ou fin de chaîne. Aucun modificateur ajouté
  (ni `i`, ni `u`), aucune portion élargie ni restreinte ;
- aucun `preg_quote` : le motif est 100 % littéral (voir §1).

Sortie brute après correctif :

```
$ php -r "var_dump(preg_match('~^/foncier/dossiers/\d+(\?|\#|$)~', '/foncier/dossiers/12?onglet=documents')); var_dump(preg_last_error_msg());"
int(1)
string(8) "No error"
```

### Effet de bord à connaître (aucune modification apportée)
Le motif n'autorise que `/foncier/dossiers/{id}`. Avec le correctif :

- dossier foncier → `return_to` **accepté**, retour à l'onglet Documents : le
  #1203 fonctionne enfin ;
- onglet Foncier d'un **programme** (`/programmes/{id}/foncier?...`) → **hors
  allowlist**, donc repli sur la redirection par défaut de l'entité (plus
  d'erreur, mais pas de retour à l'onglet) ;
- variante **corbeille** (`/foncier/dossiers/corbeille/{id}`) → hors allowlist
  (segment non numérique), même repli.

Ces deux derniers cas correspondent à l'**intention d'origine** de l'allowlist
(#1203, volontairement restreinte pour éviter une redirection ouverte). Les
élargir serait un changement de comportement, hors périmètre de ce correctif :
**signalé à Robin comme décision à prendre**, non fait ici.

## 4. OCCURRENCES SIMILAIRES AILLEURS

`grep` sur `preg_match|preg_replace|preg_split|preg_quote` dans `app/`, puis
examen ciblé de tous les appels utilisant `#` ou `~` comme délimiteur :

| Fichier / ligne | Motif | Verdict |
|---|---|---|
| `app/Http/Controllers/Foncier/Concerns/ManagesFoncierEntityDocuments.php:145` | `'#^/foncier/dossiers/\d+(\?|#|$)#'` | **DÉFAUT — corrigé** (délimiteur présent dans le motif) |
| `app/Support/AoDevisStorage.php:67` | `'#^'.preg_quote($expectedPrefix, '#').'[^/]+\.pdf$#i'` | **Correct** : portion variable passée à `preg_quote` avec le bon délimiteur, pas de `#` littéral dans le motif |
| `app/Services/Assistant/AssistantTacheSuiviService.php:658` | `'#/programmes/(\d+)#'` | **Correct** : délimiteur `#` mais aucun `#` interne |
| `app/Models/Facture.php:271`, `DossierFoncierController.php:1986`, `ProgrammeController.php:1210` | `'/^'.preg_quote($prefix, '/').'(\d+)$/'` | **Correct** : `preg_quote` avec le délimiteur `/` |
| `MarcheController.php:2917` | `'/[\\\\\/:*?"<>|#%]/u'` | **Correct** : `/` interne échappé, `#` non délimiteur |
| `DepotFactureController.php:6465`, `DemandeJuridiqueDemandeurResolver.php:153`, `FactureValidateurNv1AssignmentService.php:230` | UUID, délimiteur `/`, aucun `/` interne | **Correct** |
| ~60 autres appels (délimiteur `/`, motifs littéraux) | — | **Correct**, non touchés |

**Une seule occurrence défectueuse dans tout `app/`.** Rien d'autre n'a été
modifié.

## 5. VÉRIFICATIONS

`php -l` : OK sur le trait, le test et le journal (sortie brute en synthèse).

`ReflectionClass` + instanciation DI des **8 contrôleurs consommateurs** du
trait : tous instanciables et résolus par le conteneur (sortie brute en
synthèse). `ProgrammeFoncierController` n'utilise pas le trait (il ne fait que
fournir `documents_return_to` à l'écran), ce qui confirme le chemin d'appel.

⚠️ Ces contrôles **ne détectent pas** une variable non capturée dans un `use()`
de closure. **Aucune closure n'a été ajoutée ni modifiée par ce lot** : la
modification se limite à une chaîne littérale et à un bloc de commentaire. Les
closures existantes du trait (`storeEntityDocuments` → `catch`, aucune closure ;
`downloadEntityDocument`, `destroyEntityDocument`, `resolveDocumentReturnTo` →
aucune closure) ont été relues une par une : le trait n'en contient aucune.

**Test exécutant réellement `preg_match`** (pas d'assertion de source) :
`tests/Unit/FoncierDocumentsReturnToRegex1639Test.php`, 6 tests / 92 assertions.
Il instancie une classe anonyme consommant le trait (sans base, sans `route()`),
appelle la vraie méthode et vérifie après **chaque** appel que
`preg_last_error() === PREG_NO_ERROR`. Couvre : le payload exact de production,
les chemins acceptés, 9 entrées qui doivent être **refusées** (URL absolue,
protocol-relative, traversée de chemin, identifiant non numérique, corbeille,
autre écran…), 11 entrées truffées de métacaractères regex (`(\?|#|$)`, `#`,
`~`, crochets, parenthèses, accents, espaces, apostrophe, `^$|(){}[]\`), un nom
de fichier pour **chacune** des 9 extensions autorisées, et la preuve que le nom
de fichier ne traverse **aucune** regex (le service n'a aucun appel PCRE, le
trait n'en a qu'un).

**Aucun `.vue` modifié, aucun rebuild Vite** : `git status` ne montre que du PHP
et du Markdown (sortie brute en synthèse). Le lot #1636, en cours sur
`Scenarios/Show.vue`, n'entre donc pas en conflit.

## DÉPLOIEMENT-TEST

```bash
git pull origin main
php artisan optimize:clear
php artisan view:clear
php artisan journal:sync
```

Aucune migration, aucune route, **pas de `npm`** (aucun asset touché).

1. Dossier foncier → onglet Documents → déposer un PDF : enregistrement OK,
   **retour sur l'onglet Documents**, plus de page d'erreur.
2. Même écran : supprimer un document → OK, retour sur l'onglet.
3. Onglet Foncier d'un programme → déposer un document : plus d'erreur (retour
   sur la fiche de l'entité, pas sur l'onglet — cf. §3).
4. Négociation / promesse / acte / valorisation / échange / scénario (#1629) :
   dépôt et suppression inchangés.
5. Nom de fichier avec parenthèses, accents, apostrophe et points multiples
   (`l'acte d'achat (copie).v2.pdf`) : dépôt OK.
6. `storage/logs/laravel.log` : plus aucune occurrence de
   « Unknown modifier » après les tests 1 à 5.
7. Limites inchangées : 50 Mo sur dossier et scénario, 10 Mo ailleurs ; un
   fichier de 55 Mo reste refusé avec le message mentionnant la limite.

## LEÇON

Un délimiteur de motif ne doit **jamais** figurer dans le motif — et le piège
est maximal avec `#`, qui sert à la fois de délimiteur usuel et de caractère
d'URL (fragment). Choisir `~` quand le motif contient `#` ou `/`, et échapper
malgré tout le caractère : le coût est nul, l'oubli coûte un HTTP 500.

Un motif invalide échoue pour **toute** entrée : quand la trace pointe un
`preg_match`, l'entrée n'est presque jamais la cause — ici le nom de fichier et
l'extension étaient hors de cause, seul comptait le fait qu'un `return_to` soit
transmis. Chercher **qui envoie le paramètre** délimite l'impact bien plus vite
que chercher **quelle donnée** le déclenche.

Ce genre de bug survit longtemps quand il est masqué par un succès partiel : le
document était déjà stocké avant le `preg_match`, donc l'utilisateur voyait une
erreur mais retrouvait son fichier — et ne signalait pas.
