Téléverser une image
Temps 1 · séance 4 · ~60 min
- Ajouter un champ de fichier à un
<Form>d'Inertia et suivre ce qu'il envoie. - Valider une image avec les règles
image,mimesetmax. - Ranger le fichier sur le disque
publicet envoyer son adresse à la page. - Remplacer une image sans laisser l'ancien fichier sur le disque.
- Afficher la progression de l'envoi.
1. Ce que vous savez déjà
Au chapitre 18 de 5XCOS, vous avez ajouté une photo à chaque animal du refuge. Le formulaire Blade portait enctype="multipart/form-data", la règle image refusait un PDF, store('animals', 'public') rangeait le fichier, et php artisan storage:link le rendait visible depuis le navigateur.
Ce chapitre refait le même chemin dans le back-office du portfolio. Deux choses changent. Le formulaire est un <Form> d'Inertia, qui choisit seul l'écriture de la requête. Et l'adresse de l'image, calculée côté serveur et envoyée en prop, s'écrit une seule fois, dans le modèle.
Vos captures du seeder vivent déjà sur le disque public, dans storage/app/public/images/. La colonne cover_path contient images/quiz-capitales.jpg, et le lien public/storage existe depuis le chapitre 1.06. Ce chapitre ajoute un second dossier sur le même disque, projects/, pour les images téléversées depuis le back-office. À la fin du chapitre, chaque projet aura une image de couverture choisie depuis le back-office.
2. Le champ dans le <Form>
Ouvrez resources/js/pages/admin/projects/Create.vue, la page du chapitre 1.12. Ajoutez progress aux variables du slot de <Form> :
<Form
v-bind="ProjectController.store.form()"
v-slot="{ errors, processing, progress }"
class="space-y-6"
novalidate
>
Puis ajoutez ce bloc avant le <fieldset> des technologies :
<div class="grid gap-2">
<Label for="cover">Image de couverture</Label>
<Input id="cover" name="cover" type="file" accept="image/*" />
<progress
v-if="progress"
:value="progress.percentage"
max="100"
class="w-full"
>
{{ progress.percentage }} %
</progress>
<InputError :message="errors.cover" />
</div>
Le composant Input de shadcn-vue accepte type="file", et ses classes prévoient déjà l'apparence du bouton de choix. L'attribut accept="image/*" demande à la fenêtre de choix de montrer d'abord les images. Il ne protège rien : n'importe qui peut choisir un autre fichier, et c'est le serveur qui vérifie.
Vous n'avez pas écrit d'enctype. <Form> regarde ce qu'il envoie. Sans fichier, il envoie les champs en JSON, comme au chapitre 1.12. Avec un fichier, il construit un FormData, c'est-à-dire un corps multipart/form-data avec une partie par champ. Voici la requête relevée dans le navigateur lors de l'envoi d'une image depuis la page de modification :
xhr POST http://…/admin/projets/2?_method=PUT
body FormData:title,summary,demo_url,repo_url,cover,tags[0],tags[1]
Le corps contient le champ cover, à côté des autres. La même modification envoyée sans choisir de fichier part en JSON, sans aucune clé cover.
La méthode reste POST, avec ?_method=PUT dans l'adresse. Vous avez vu cette écriture au chapitre 1.13 : Wayfinder l'ajoute dans update.form(project). Elle compte ici, car PHP ne lit pas le corps multipart/form-data d'une vraie requête PUT. Grâce au POST, le fichier arrive, puis Laravel lit _method et appelle update().
3. Valider l'image
Dans app/Http/Requests/ProjectRequest.php, ajoutez une règle pour cover dans rules(), sous celle de repo_url :
'cover' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
Ajoutez aussi son nom en français dans attributes() :
'cover' => 'image de couverture',
| Règle | Ce qu'elle vérifie |
|---|---|
nullable |
le champ peut rester vide |
image |
Laravel lit le début du fichier et y reconnaît une image |
mimes:jpg,jpeg,png,webp |
seuls ces quatre formats passent |
max:2048 |
pour un fichier, max compte en kilo-octets : 2 Mo au plus |
image seule accepterait aussi un BMP, ou un HEIC venu d'un téléphone, que la plupart des navigateurs n'affichent pas. mimes limite la liste aux formats que toutes les pages savent montrer.

Une capture trop lourde reçoit ce message sous le champ :
La taille du fichier de image de couverture ne peut pas dépasser 2048 kilo-octets.
La tournure « de image » vient du message de Laravel-Lang, qui ne fait pas l'élision.
Astuce
« Le fichier du champ image de couverture n'a pu être téléversé. » veut dire que PHP a écarté le fichier avant Laravel, parce qu'il dépasse upload_max_filesize, la limite du fichier php.ini. PHP la règle à 2 Mo par défaut, et chaque installation peut la changer. Lisez la vôtre avec php -i | grep upload_max_filesize, ou php -i | Select-String upload_max_filesize dans PowerShell. Le plus simple reste de réduire vos captures sous 2 Mo, en WebP ou en JPEG.
4. Ranger le fichier
Dans app/Http/Controllers/Admin/ProjectController.php, la méthode store() du chapitre 1.12 passait $request->safe()->except('tags') à Project::create(). Le fichier ne peut pas aller tel quel dans create() : la colonne s'appelle cover_path et attend un chemin. Voici store() modifiée :
public function store(ProjectRequest $request): RedirectResponse
{
$data = $request->safe()->except(['tags', 'cover']);
if ($request->hasFile('cover')) {
$data['cover_path'] = $request->file('cover')->store('projects', 'public');
}
$project = Project::create([
...$data,
'position' => Project::max('position') + 1,
]);
$project->tags()->sync($request->validated('tags', []));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Projet créé.']);
return to_route('admin.projects.index');
}
except(['tags', 'cover']) retire les deux clés que la table projects ne connaît pas. $request->hasFile('cover') vaut true quand un fichier est arrivé sans erreur. store('projects', 'public') copie le fichier dans le dossier projects du disque public, sous un nom tiré au hasard, et renvoie son chemin. Ce chemin, par exemple projects/4ayJrdZUcgdYpkLWFkKDnh9KJ9rxhzsP2pfcjaGy.jpg, va dans cover_path.
Le disque public correspond au dossier storage/app/public de votre projet. Le second argument de store() compte : le disque par défaut du kit est local, un dossier privé (FILESYSTEM_DISK=local dans .env).
Le navigateur atteint déjà ce dossier : le lien public/storage, créé au chapitre 1.06 avec php artisan storage:link, renvoie vers storage/app/public. Rien à refaire ici.
5. L'adresse de l'image, calculée une fois
La page a besoin d'une adresse complète, pas d'un chemin sur le disque. Au chapitre 1.07, index() et show() la calculaient chacun avec Storage::disk('public')->url($project->cover_path). Le calcul ne change pas, mais la page de modification en a besoin aussi. Plutôt que de l'écrire une troisième fois, écrivez-le une seule fois, dans le modèle app/Models/Project.php :
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Storage;
/**
* @return Attribute<string|null, never>
*/
protected function coverUrl(): Attribute
{
return Attribute::get(fn () => $this->cover_path
? Storage::disk('public')->url($this->cover_path)
: null);
}
Les deux use vont en tête du fichier. Une méthode qui renvoie un Attribute est un accesseur : elle ajoute au modèle une propriété calculée, qui se lit comme une colonne. Laravel tire le nom de la propriété de celui de la méthode : coverUrl() donne $project->cover_url. Le commentaire de type se lit Attribute<ce qu'on lit, ce qu'on écrit> : string|null à la lecture, never à l'écriture. Cet accesseur n'a pas de mutateur : $project->cover_url = … n'existe pas, l'adresse se calcule et ne s'assigne jamais. PHPStan lit ce commentaire et signalerait une telle affectation.
Storage::disk('public')->url() construit l'adresse à partir de la valeur APP_URL de votre .env, réglée sur http://portfolio.test au chapitre 1.04. Tinker le montre pour un projet du seeder :
php artisan tinker --execute="echo App\Models\Project::firstWhere('slug', 'meteo-wavre')->cover_url;"
http://portfolio.test/storage/images/meteo-wavre.jpg
Au chapitre 18 de 5XCOS, vous aviez préféré asset(), parce que APP_URL valait encore http://localhost:8000. Ici, APP_URL est juste depuis le chapitre 1.04, et il le sera chez OVH : le disque peut construire l'adresse lui-même. Si les images sont cassées, regardez d'abord APP_URL.
Remplacez maintenant chaque Storage::disk('public')->url($project->cover_path) par l'accesseur. Dans le ProjectController public, index() et show() remplacent leur condition sur trois lignes par une seule ligne, car l'accesseur renvoie lui aussi null pour un projet sans image :
'cover_url' => $project->cover_url,
L'import de Storage ne sert plus dans ce contrôleur : retirez-le.
Dans Admin\ProjectController, la méthode edit() ajoute 'cover_url' à la liste de son only(), qui lit les accesseurs comme les colonnes :
...$project->only('id', 'title', 'summary', 'content', 'demo_url', 'repo_url', 'cover_url'),
La page reçoit cover_url, jamais cover_path. La prop reste explicite, et la page n'a pas à savoir où les fichiers sont rangés.
6. Remplacer sans laisser l'ancien fichier
Dans update(), le nouveau fichier prend la place de l'ancien. La base ne connaît que le chemin, donc l'ancien fichier se supprime à part, avant l'enregistrement du nouveau chemin. Seulement s'il a été téléversé :
public function update(ProjectRequest $request, Project $project): RedirectResponse
{
$data = $request->safe()->except(['tags', 'cover']);
if ($request->hasFile('cover')) {
if ($project->cover_path && str_starts_with($project->cover_path, 'projects/')) {
Storage::disk('public')->delete($project->cover_path);
}
$data['cover_path'] = $request->file('cover')->store('projects', 'public');
}
$project->update($data);
// sync des tags, flash et redirection : inchangés
}
Ajoutez use Illuminate\Support\Facades\Storage; en tête du contrôleur. Sans nouveau fichier, $data ne contient pas de clé cover_path, et update() ne touche pas à l'image.
str_starts_with($project->cover_path, 'projects/') vaut true quand le chemin commence par projects/, le dossier des téléversements. Une capture du seeder, dans images/, n'est jamais supprimée : elle est versionnée avec votre code, et le prochain migrate:fresh --seed remet son chemin dans la base.
destroy() supprime aussi le fichier, avec la même condition, juste avant la ligne :
if ($project->cover_path && str_starts_with($project->cover_path, 'projects/')) {
Storage::disk('public')->delete($project->cover_path);
}
$project->delete();
Après l'envoi d'une nouvelle image pour le « Quiz des capitales » de l'exemple, storage/app/public/projects/ contient 4ayJrdZUcgdYpkLWFkKDnh9KJ9rxhzsP2pfcjaGy.jpg, et quiz-capitales.jpg reste dans storage/app/public/images/. Une seconde image pour ce projet supprime 4ayJrdZUcgdYpkLWFkKDnh9KJ9rxhzsP2pfcjaGy.jpg et prend sa place.
Dans Edit.vue, ajoutez progress au slot et le même bloc que dans Create.vue, avec l'image actuelle au-dessus du champ :
<img
v-if="project.cover_url"
:src="project.cover_url"
alt=""
class="w-48 rounded"
/>
Le alt est vide, car le libellé « Image de couverture » dit déjà ce que montre l'aperçu.

7. Deux dossiers sur le même disque
Le disque public contient maintenant deux dossiers.
| Dossier | Ce qu'il contient | Dans Git |
|---|---|---|
images/ |
les captures du seeder | oui, grâce au .gitignore du chapitre 1.06 |
projects/ |
les images téléversées depuis le back-office | non |
Les captures du seeder font partie du projet : un clone de votre dépôt les retrouve après migrate:fresh --seed. Les images téléversées appartiennent au site en marche, comme les lignes que le back-office ajoute à la base.
Le seeder ne change pas. cover_path reste un chemin relatif au disque, quel que soit le dossier, et le même accesseur calcule les deux adresses. Un projet du seeder garde son chemin :
php artisan tinker --execute="echo App\Models\Project::firstWhere('slug', 'meteo-wavre')->cover_path;"
images/meteo-wavre.jpg
Un projet dont l'image a été téléversée porte un chemin en projects/. Seuls ces fichiers-là sont supprimés quand l'image change ou que le projet disparaît.
8. La barre de progression
progress vaut null tant que rien n'est envoyé. Pendant l'envoi d'un fichier, <Form> le remplit avec l'avancement, et progress.percentage donne un pourcentage de 0 à 100. La balise HTML <progress> dessine la barre.
Sur votre ordinateur, l'envoi d'une capture de quelques centaines de kilo-octets est instantané : la barre apparaît à 100, puis la page change. Elle prend son sens en ligne, depuis une connexion lente. Pour la voir avancer en local, ouvrez l'onglet Réseau des outils de développement, choisissez un débit lent dans le menu de limitation, puis envoyez une image.

Cette barre n'est pas la fine barre grise du haut de la page. Celle-là vient de la clé progress de resources/js/app.ts, et elle s'affiche pendant chaque visite d'Inertia.
9. Ce qui change chez OVH
Trois points vous attendent à la mise en ligne, détaillés au chapitre 1.22.
- L'hébergement mutualisé d'OVH ne suit pas les liens symboliques : le lien
public/storagen'y fonctionne pas. Le chapitre 1.22 présente cette exception et la variable d'.envqui change la racine du disque sur le serveur. APP_URLdoit valoir l'adresse publique de votre site, sinon chaquecover_urlpointe versportfolio.test.- Le seeder ne tourne pas chez OVH. Votre base déjà remplie part par FTP avec le code, et le dossier
storage/app/publicentier part avec elle :images/etprojects/. Sans lui, les chemins de la base désignent des fichiers absents.
10. Pratique : une couverture pour chaque projet (30 min)
- La règle
coveret son nom dansProjectRequest. store(),update()etdestroy()qui rangent, remplacent et suppriment le fichier, ce dernier seulement dansprojects/.- L'accesseur
coverUrl()dansProject, utilisé parindex(),show()etedit()à la place des appels àStorage::disk('public')->url(). - Le bloc « Image de couverture » dans
Create.vueetEdit.vue, avecprogressdans le slot et l'aperçu dansEdit.vue. - Rien à changer dans le seeder ni dans le lien
public/storagedu chapitre 1.06 : la commande Tinker de la section 7 montre uncover_pathenimages/.
Vérifiez dans le navigateur.
portfolio.test/projetsmontre vos trois captures, et le code source de la page donne descover_urlenhttp://portfolio.test/storage/images/….- Un nouveau projet créé avec une image l'affiche sur sa fiche, avec une
cover_urlenhttp://portfolio.test/storage/projects/…. - Remplacer l'image d'un projet du seeder garde la capture dans
storage/app/public/images/. - Remplacer une seconde fois l'image d'un projet laisse un seul fichier pour ce projet dans
storage/app/public/projects/. - Modifier un projet sans choisir de fichier garde son image.
- Un PDF est refusé avec un message sous le champ.
Ce que votre portfolio contient maintenant
Une image de couverture par projet, choisie depuis le back-office et rangée sur le disque public. Deux dossiers sur ce disque : images/ pour les captures du seeder, versionné, et projects/ pour les téléversements, ignoré par Git. Une seule méthode du modèle calcule l'adresse des deux. Remplacer l'image ou supprimer un projet ne laisse aucun fichier téléversé orphelin, et ne touche jamais aux captures du seeder.
À retenir
<Form>envoie unFormDatadès qu'il contient un fichier, et du JSON sinon : vous n'écrivez pas d'enctype.- La modification passe en
POSTavec?_method=PUT, ce qui permet à PHP de lire le fichier envoyé. store('projects', 'public')range le fichier sous un nom tiré au hasard et renvoie le chemin que vous enregistrez danscover_path.- Le disque
publica deux dossiers :images/pour les captures du seeder, versionné, etprojects/pour les téléversements, ignoré par Git. - L'accesseur
coverUrl()calcule l'adresse avecStorage::disk('public')->url(), qui part deAPP_URL, en un seul endroit pour toutes les pages. - Un nouveau fichier fait supprimer l'ancien avec
Storage::disk('public')->delete(), en modification comme en suppression, seulement si l'ancien est dansprojects/.