# Rapport de version — v1.3.1

*STE MOBDIE PRIVÉ — application de gestion — correctif du 28 juillet 2026*

## Ce qui était cassé

Depuis le déploiement de la v1.3.0, aucun téléversement de fichier n'aboutissait en production. Ni l'assistant « Nouveau dossier », ni la fenêtre « Déposer des pièces » de la fiche client : le fichier partait, l'interface annonçait « Traitement effectué », puis une notification rouge « Erreur lors du chargement de la page » apparaissait et le fichier n'existait nulle part. En local, tout fonctionnait — ce qui a longtemps égaré le diagnostic.

Les mesures déjà prises sur le serveur cernaient le problème avec précision. Dans `storage/app/private/livewire-tmp/` ne s'accumulaient que des fiches `<hash>.pdf.json` de 122 octets, une de plus à chaque tentative, jamais le moindre `.pdf`. Ces fiches portaient la bonne taille et le bon type MIME, preuve que PHP avait parfaitement lu le fichier temporaire au moment où il les écrivait. Aucune `UnableToWriteFile` n'apparaissait dans le journal, même après avoir passé tous les disques à `'throw' => true` ; seules apparaissaient, après coup, des erreurs de lecture sur un fichier absent. Et la même écriture, lancée en ligne de commande, réussissait et persistait. Le disque, les droits et le quota étaient donc hors de cause : restait la différence de contexte, et le fait que la source soit un fichier téléversé plutôt qu'un fichier ordinaire.

## Ce que la lecture du code a confirmé

La piste indiquée dans le dossier était la bonne, et elle a été vérifiée dans le `vendor/` réellement installé plutôt qu'admise sur parole.

`Livewire\Features\SupportFileUploads\FileUploadConfiguration::storeTemporaryFile()` écrit deux choses coup sur coup. D'abord la fiche descriptive, par `Storage::disk($disk)->put(...)`, avec un contenu construit en mémoire : cette écriture n'ouvre aucun fichier, elle réussit donc toujours. Ensuite le fichier lui-même, par `$file->storeAs(...)`, qui descend dans `Illuminate\Filesystem\FilesystemAdapter::putFileAs()`, dont la toute première instruction est `fopen(is_string($file) ? $file : $file->getRealPath(), 'r')`.

Là se trouve la divergence. `getRealPath()` est `realpath()` appliqué au chemin temporaire, tandis que `getSize()` et `getMimeType()` — celles qui venaient de remplir la fiche sans difficulté — passent par `getPathname()`. Les deux chemins peuvent différer, et c'est ce que le serveur donnait à voir.

Reste à savoir ce que produit exactement l'échec, et c'est là que le silence du journal s'explique. Mesuré sous le PHP 8.3 du projet, `fopen(false, 'r')` ne renvoie pas `false` : il lève un `ValueError: Path cannot be empty`. La requête de téléversement retourne donc une erreur 500, Livewire affiche sa notification rouge « Erreur lors du chargement de la page » — le message exact rapporté par l'utilisateur — et aucune exception d'écriture n'apparaît dans le journal puisqu'aucune écriture n'a jamais été tentée. La variante où `realpath()` rend un chemin périmé plutôt que `false` est plus sournoise encore : `fopen()` renvoie alors `false`, `put()` reçoit ce `false`, l'écrit comme une chaîne vide, et un fichier de zéro octet est créé sans le moindre avertissement.

Cette lecture explique tout ce qui avait été observé : la fiche écrite, le fichier absent, l'absence d'erreur d'écriture, la notification rouge, et la réussite en ligne de commande où le fichier source est un fichier ordinaire dont `realpath()` ne pose aucun problème.

## La sonde, livrée en premier

Rien de tout cela ne pouvait être conclu depuis un terminal, puisque le terminal fonctionnait déjà. La première chose livrée est donc une sonde qui tourne dans une requête web, à l'adresse **Administration → Diagnostic du téléversement**, réservée au super-administrateur.

Un point de conception mérite d'être signalé, parce qu'il n'est pas anodin : le formulaire de cette page est un formulaire HTML ordinaire, en POST multipart, et non un champ de dépôt Filament. C'est délibéré. Le mécanisme de fichier temporaire de Livewire est précisément ce que l'on cherche à mesurer ; un champ Filament aurait échoué avant que la sonde n'ait rien vu. En passant par une requête classique, on observe le fichier tel que PHP le remet.

La sonde rend compte, étape par étape et en clair, du code d'erreur PHP du fichier reçu, de `is_uploaded_file()`, de `getPathname()` et de `getRealPath()`, du fait qu'ils coïncident ou non, de `is_readable()`, `filesize()` et `fopen()` sur chacun des deux, du nombre d'octets effectivement recopiés, du résultat de `putFileAs()`, de `Storage::exists()` juste après, de la taille du fichier écrit, du contenu réel du dossier de destination, et de l'état du dossier `livewire-tmp` — dont le déséquilibre entre fiches `.json` et fichiers réels est la signature exacte de la panne. Chaque étape affiche sa valeur brute, un verdict et une explication, et toute exception levée pendant une mesure est capturée et montrée plutôt que de faire tomber la page : c'est bien une exception non capturée qui était soupçonnée.

Elle tente enfin, en secours, une écriture par le contenu — `Storage::put($chemin, file_get_contents($fichier->getPathname()))` — et dit si celle-là aboutit. C'est cette comparaison qui désigne l'étape fautive : si l'écriture par le contenu réussit là où `putFileAs()` échoue, la faute est bien dans le chemin employé pour ouvrir le flux, et non dans le disque. Un bouton « Copier le rapport » produit le texte à faire remonter du serveur, et un second permet de supprimer les fichiers d'essai.

## La correction

Elle est posée en un seul point, dans `AppServiceProvider`, et non au cas par cas dans chaque champ de dépôt. Tout disque configuré en `'driver' => 'local'` — donc `local` et `public` — passe désormais par `App\Support\Filesystem\TeleversementLocalAdapter`, qui redéfinit `putFileAs()`. Le disque standard est d'abord construit par la fabrique de Laravel, dont on reprend le système de fichiers et l'adaptateur tels quels : la correction ne réimplémente pas `createLocalDriver()` et survivra donc aux évolutions du framework.

Le principe est simple. On n'ouvre plus le flux sur `getRealPath()` les yeux fermés : on retient le premier des deux chemins qui soit réellement lisible, en préférant `getPathname()` — celui dont les mesures ont montré qu'il fonctionne, puisque c'est lui qui permettait de lire la taille et le type. Si aucun des deux n'est exploitable, une exception explicite est levée, qui nomme le fichier et dit ce qui manque, plutôt que d'écrire un fichier vide. Comme la correction est sur le disque et non dans l'interface, elle vaut aussi bien pour le fichier temporaire de Livewire que pour le rangement définitif d'une pièce, et pour tout dépôt qui sera ajouté demain.

Le commentaire de classe de `TeleversementLocalAdapter` contient l'intégralité de ce qui a été mesuré et le raisonnement qui a conduit à cette forme, de sorte que personne n'ait à refaire l'enquête.

## Un échec de téléversement ne peut plus être silencieux

Trois dispositions, qui répondent à ce qui a coûté le plus de temps.

`'throw' => true` est désormais posé dans `config/filesystems.php` sur les deux disques locaux, et non plus seulement sur le serveur : le fichier du dépôt portait encore `false`, la modification faite en production n'ayant jamais été committée. Une écriture qui échoue lève maintenant une exception au lieu de retourner `false` sans bruit, et `'report' => true` la fait consigner.

Chaque dépôt vérifie ensuite que le fichier est réellement arrivé à destination et qu'il compte autant d'octets que sa source. Le choix de l'invariant mérite une explication, car le premier essai était faux : comparer la taille écrite à `getSize()` ne convient pas, parce que `getSize()` est une valeur *déclarée* — les fichiers d'essai de Laravel, `UploadedFile::fake()->create()`, annoncent une taille qu'ils n'ont pas, zéro octet sur le disque pour dix kilo-octets déclarés. L'invariant retenu est donc « autant d'octets écrits que d'octets lus », mesuré sur le chemin que l'écriture emprunte réellement. Il attrape le fichier de zéro octet écrit silencieusement, sans rejeter à tort un fichier dont la taille annoncée diffère. Quand la vérification échoue, le fragment écrit est effacé, aucune ligne n'est créée en base, et l'utilisateur reçoit une notification rouge persistante qui nomme le fichier, dit qu'aucune pièce n'a été enregistrée et renvoie vers l'écran de diagnostic. L'assistant se comporte de même : si le rangement des pièces échoue, le dossier n'est pas créé du tout et les saisies sont conservées — créer le client puis découvrir que ses pièces n'ont pas suivi serait le pire des deux mondes.

Enfin, `php artisan mobdie:diagnostic` reçoit un contrôle « Téléversement » qui écrit un fichier par le même chemin de code que l'application, `putFileAs()`, puis le relit et compare les octets. Il ne remplace pas la sonde web — il tourne en ligne de commande, or c'est le contexte web qui était en cause — mais il fait partie du contrôle d'ensemble et sort en échec avec le reste.

## Les deux corrections annexes

Le témoin de vie de la file d'attente était rangé dans le cache. Chaque `php artisan optimize:clear` l'effaçait, et l'application affichait aussitôt un bandeau « La file d'attente est arrêtée » alors que le cron tournait très bien. Un témoin qui ment est pire que pas de témoin. Il est déplacé dans un fichier, `storage/framework/queue-battement`, qui survit au vidage du cache ; l'ancienne clé de cache reste lue en repli le temps qu'un premier battement soit écrit après la mise à jour, et l'écriture du battement est enveloppée de sorte qu'elle ne puisse jamais faire échouer le travail qu'elle surveille.

Quant aux `Route [filament.admin.pages.echeancier-global] not defined` du journal de 14:05, le défaut ne subsiste pas dans le code : la page existe, sa route est enregistrée, et l'entrée apparaît normalement dans le menu. Le symptôme est celui d'un cache de routes plus ancien que le code déployé, ce que la v1.3.0 a pu provoquer en ajoutant une page. La correction est donc opérationnelle et figure dans les notes de déploiement, où la reconstruction du cache de routes n'est plus présentée comme facultative. Le code, lui, est désormais couvert : un test construit la navigation réelle du panneau et charge ses trente-deux entrées, si bien qu'une entrée pointant sur une route inexistante ferait échouer la recette au lieu de tomber en production — et une telle entrée ne casse pas seulement sa propre page, elle fait tomber le rendu de la barre latérale, donc de tous les écrans.

## Comment vérifier

La recette compte deux cent deux tests pour huit cent quatre-vingt-neuf assertions, tous au vert, contre cent quatre-vingt-quatre avant ce correctif.

Le test central reproduit la panne exactement : un fichier téléversé dont `getSize()` et `getMimeType()` fonctionnent mais dont `getRealPath()` renvoie `false`. La correction retirée, il échoue sur `ValueError: Path cannot be empty` — le message même qui provoquait l'erreur 500 en production ; la correction remise, le fichier est écrit et se relit intégralement. Cette vérification a été faite dans les deux sens plutôt que supposée. Une variante couvre le chemin périmé, une autre le cas où plus aucun chemin n'est lisible et où l'exception doit être explicite. Deux tests vérifient qu'un fichier absent ou tronqué à destination ne laisse aucune ligne en base ni aucun fragment sur le disque. Un test parcourt un dépôt complet, du fichier jusqu'à la pièce enregistrée et relue octet pour octet. Quatre tests couvrent la sonde elle-même, y compris sa capacité à désigner l'étape fautive et à montrer que l'écriture de secours aboutit là où `putFileAs()` échouerait. Deux tests vérifient que le battement de la file survit à un vidage de cache et disparaît quand on l'efface vraiment. Trois tests, enfin, construisent la navigation et chargent chaque entrée du menu.

Sur le serveur, la vérification se fait dans cet ordre : `php artisan optimize:clear`, la reconstruction des caches de configuration et de routes, `npm ci && npm run build`, puis `php artisan mobdie:diagnostic`, dont la ligne « Téléversement » doit être au vert. Et enfin, connecté en super-administrateur, l'envoi du PDF qui échouait par **Administration → Diagnostic du téléversement**. Les étapes « putFileAs() », « Storage::exists() juste après putFileAs() » et « Taille du fichier écrit » doivent être au vert. Il se peut que « getRealPath() » et « Les deux chemins coïncident-ils ? » restent au rouge : c'est l'anomalie du serveur elle-même, et l'application sait désormais s'en passer. C'est même le résultat attendu, et il vaut confirmation que la panne était bien celle-là.

## Ce qui reste ouvert

La cause première de l'échec de `realpath()` sous LiteSpeed n'est pas établie, et ce correctif ne la traite pas : il rend l'application indifférente à ce comportement plutôt qu'il ne le corrige. Le rapport de la sonde, une fois exécutée sur le serveur, dira si `getRealPath()` renvoie `false` ou un chemin périmé, ce qui orientera vers l'une ou l'autre explication — un `/tmp` cloisonné par compte dont la résolution des liens échoue pour le processus web étant l'hypothèse la plus probable au vu de l'environnement décrit. L'application fonctionnera dans les deux cas, mais il reste utile de savoir laquelle, ne serait-ce que pour anticiper d'autres effets du même cloisonnement.

Deux limites méritent d'être connues. La sonde écrit ses fichiers d'essai dans `storage/app/private/diagnostic-televersement` et ne les supprime pas d'elle-même ; le bouton prévu à cet effet est à utiliser après chaque campagne de mesure. Et le contrôle d'intégrité compare les octets écrits à ceux de la source telle qu'elle est lisible au moment du dépôt : il attrape une écriture tronquée ou vide, mais ne détecterait pas un fichier déjà corrompu à l'arrivée sur le serveur, ce qui relève des couches en amont.
