Téléverser une image
Bloc Laravel · en autonomie · ~90 min
Sur le site du refuge, la fiche de Rex montre le même pictogramme de chien que celles de Nala, de Léon et de Nougat. Les bénévoles voudraient montrer une vraie photo de chaque animal. Dans ce chapitre, vous ajoutez un champ photo au formulaire d'un animal, vous rangez le fichier reçu sur le serveur, puis vous l'affichez sur la fiche et dans la liste. Envoyer un fichier de son ordinateur vers un serveur s'appelle téléverser. Vous faites ce chapitre seul, après le chapitre 17.
À la fin de ce chapitre, vous serez capables de :
- envoyer un fichier depuis un formulaire, et expliquer le rôle de
enctype="multipart/form-data", - valider une image et sa taille, avec des messages en français,
- enregistrer le fichier sur le disque
public, et l'afficher grâce au lienpublic/storage, - supprimer l'ancien fichier quand la photo change ou que l'animal disparaît,
- vérifier le tout avec des tests qui simulent le disque et le fichier.
1. Avant de commencer
Vous partez de votre fork de refuge-26, avec les huit katas du chapitre 17 faits. Dans son dossier, lancez les tests :
php artisan test
Les 41 tests doivent être verts. Les extraits de ce chapitre suivent le corrigé des katas : si votre code diffère, adaptez l'endroit où vous collez chaque morceau.
Les tests de ce chapitre sont donnés en entier à la section 8. Comme au chapitre 17, ils sont l'énoncé. Vous pouvez copier le fichier dès maintenant, et lancer php artisan test --group=photo après chaque section.
Préparez deux photos de moins de 2 Mo, en JPEG ou en PNG, nommées rex.jpg et rex-ete.jpg, et un fichier PDF quelconque.
2. Un fichier dans un formulaire
Les champs du dépôt passent par les composants <x-input>, <x-select> et <x-textarea>, qui lisent $errors eux-mêmes. Aucun ne gère un champ de fichier, et <x-input type="file"> ne conviendrait pas : ses classes dessinent la bordure d'un champ texte. Créez un quatrième composant, resources/views/components/file-input.blade.php, sur le modèle d'input.blade.php :
{{-- Un champ de fichier. Comme <x-input>, il lit $errors : en erreur, aria-invalid="true" et aria-describedby="{name}-error". --}}
@props(['name'])
<input
type="file"
name="{{ $name }}"
@if ($errors->has($name)) aria-invalid="true" aria-describedby="{{ $name }}-error" @endif
{{ $attributes->merge(['id' => $name])->class('mt-2 block w-full text-sm text-zinc-700 file:mr-4 file:rounded-lg file:border-0 file:bg-brand-50 file:px-4 file:py-2 file:font-semibold file:text-brand-700 hover:file:bg-brand-100 dark:text-zinc-300') }}
>
Un champ type="file" affiche un bouton « Choisir un fichier », qui ouvre la fenêtre de choix de votre système. Le modificateur Tailwind file: met en forme ce bouton. Dans resources/views/components/animal-fields.blade.php, ajoutez ce bloc entre la description et les traits de caractère :
<div>
<x-label for="photo">Photo</x-label>
<x-file-input name="photo" accept="image/*" />
</div>
accept="image/*" demande à la fenêtre de choix de ne proposer que des images. C'est un confort, pas un contrôle : la fenêtre permet de passer à « Tous les fichiers ».
Pour voir ce que le serveur reçoit, ajoutez cette ligne en tête de store(), dans AnimalController :
dd($request->file('photo'), $request->input('photo'));
$request->file('photo') renvoie le fichier reçu dans le champ photo, ou null. Ouvrez http://refuge-26.test/animaux/nouveau, tapez Rex, choisissez Chien et un refuge, puis choisissez rex.jpg. Envoyez :
null // app\Http\Controllers\AnimalController.php:47
"rex.jpg" // app\Http\Controllers\AnimalController.php:47
Le serveur n'a reçu que le nom du fichier, comme un champ texte. Par défaut, un formulaire envoie ses champs les uns derrière les autres, comme dans une adresse : name=Rex&species=chien&…&photo=rex.jpg. Cette écriture ne transporte que du texte, et le navigateur y place le nom du fichier sans son contenu.
enctype
Pour envoyer le contenu, le formulaire doit choisir une autre écriture, multipart/form-data. Le corps de la requête est alors découpé en parties, une par champ. La partie du champ photo contient le nom du fichier, son type, puis tous ses octets. L'attribut enctype de la balise <form> choisit cette écriture. Ajoutez-le dans animals/create.blade.php :
<form method="POST" action="{{ route('animals.store') }}" enctype="multipart/form-data" class="…">
Faites de même dans animals/edit.blade.php, sur le formulaire qui envoie vers route('animals.update', $animal->id). Rechargez le formulaire d'ajout, remplissez-le de nouveau, choisissez rex.jpg et envoyez. Sortie raccourcie :
Illuminate\Http\UploadedFile {#508 ▼ // app\Http\Controllers\AnimalController.php:47
-originalName: "rex.jpg"
-mimeType: "image/jpeg"
-error: 0
…
pathname: "C:\Users\admin\AppData\Local\Temp\php992B.tmp"
…
size: 7172
…
}
null // app\Http\Controllers\AnimalController.php:47
Laravel range le fichier reçu dans un objet UploadedFile. originalName et mimeType viennent du navigateur. pathname montre où PHP a écrit le contenu : un fichier temporaire, php992B.tmp, que PHP efface à la fin de la requête. Tant que le code ne le copie pas ailleurs, la photo est perdue.
Retirez la ligne dd(). Un formulaire qui envoie un fichier porte enctype="multipart/form-data", sinon le serveur ne reçoit que le nom du fichier.
3. La colonne photo
La base ne garde pas l'image elle-même. Elle garde le chemin du fichier, un texte comme animals/khqE….jpg. Il faut une colonne pour ce chemin :
php artisan make:migration add_photo_to_animals_table
INFO Migration [database/migrations/2026_10_10_144822_add_photo_to_animals_table.php] created successfully.
Chez vous, Artisan affiche le chemin complet et la date du jour. Le nom finit par to_animals_table : Laravel en déduit que la migration modifie la table animals, et le fichier contient déjà Schema::table('animals', …). Complétez up() et down() :
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('animals', function (Blueprint $table) {
$table->string('photo')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('animals', function (Blueprint $table) {
$table->dropColumn('photo');
});
}
};
La colonne accepte null : les douze animaux du seeder n'ont pas de photo, et un bénévole peut ajouter un animal sans photo. down() retire la colonne si vous annulez la migration avec php artisan migrate:rollback.
php artisan migrate
INFO Running migrations.
2026_10_10_144822_add_photo_to_animals_table ....................................... 10.64ms DONE
Dans app/Models/Animal.php, ajoutez photo à la liste #[Fillable], sinon create() et update() ignorent la colonne :
#[Fillable(['shelter_id', 'name', 'species', 'birth_date', 'description', 'photo', 'adopted_at'])]
4. Valider le fichier
Dans AnimalRequest, ajoutez une règle pour photo, sous celles de tags :
'photo' => ['nullable', 'image', 'max:2048'],
| Règle | Ce qu'elle vérifie |
|---|---|
nullable |
le champ peut rester vide |
image |
le fichier est une image JPEG, PNG, GIF, BMP, WebP, AVIF ou HEIC |
max:2048 |
pour un fichier, max compte en kilo-octets : 2 048 Ko, soit 2 Mo |
Rien à ajouter pour le nom du champ : le tableau attributes de lang/fr/validation.php, venu de Laravel-Lang, contient déjà 'photo' => 'photo', et Laravel s'en sert pour les champs que la méthode attributes() d'AnimalRequest ne nomme pas.
Le message d'erreur s'affiche sous le champ, comme pour les autres. Remplacez le bloc de la section 2 par sa version complète :
<div>
<x-label for="photo">Photo</x-label>
<x-file-input name="photo" accept="image/*" />
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">Facultatif. Une image de 2 Mo au plus.</p>
@error('photo')
<p id="photo-error" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
Le message garde l'identifiant photo-error, celui que le composant annonce dans aria-describedby quand le serveur a refusé le fichier, comme <x-input> pour les autres champs. Gardez composer run dev ouvert, ou lancez npm run build, pour que Vite produise les classes file:.
Sur le formulaire d'ajout, remplissez les champs, passez le filtre de la fenêtre sur « Tous les fichiers » et choisissez votre PDF. Envoyez :
Le champ photo doit être une image.

Le nom et l'espèce sont restés, grâce à old(). Le champ Photo, lui, est vide. Un navigateur ne pré-remplit jamais un champ de fichier, et old() ne peut rien y mettre : il faut choisir le fichier de nouveau.
Renommez une copie du PDF en rex.jpg et envoyez-la : le message est le même. La règle image ne se fie ni au nom ni au type annoncé par le navigateur. Laravel lit les premiers octets du fichier, y reconnaît un PDF, et refuse.
Avec une photo de 7 Mo, le message change :
La taille du fichier de photo ne peut pas dépasser 2048 kilo-octets.
Astuce
« Le fichier du champ photo 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. Lisez-la avec php -i | grep upload_max_filesize, ou php -i | Select-String upload_max_filesize dans PowerShell. Si une photo de moins de 2 Mo reçoit ce message, augmentez la limite dans les réglages de PHP de Herd ou de Laragon, puis redémarrez.
5. Enregistrer le fichier
Dans AnimalController, store() range la photo avant de créer l'animal :
public function store(AnimalRequest $request): RedirectResponse
{
$validated = $request->validated();
if ($request->hasFile('photo')) {
$validated['photo'] = $request->file('photo')->store('animals', 'public');
}
$animal = Animal::create($validated);
$animal->tags()->sync($validated['tags'] ?? []);
return redirect()
->route('animals.show', $animal->id)
->with('status', 'L\'animal a été ajouté.');
}
$request->hasFile('photo') vaut true quand la requête contient un fichier reçu sans erreur. La méthode store() de l'objet UploadedFile copie le fichier temporaire dans le dossier animals du disque public, et renvoie son chemin. Ce chemin remplace l'objet UploadedFile dans $validated, et create() l'écrit dans la colonne photo. Ajoutez le même if dans update(), entre $validated = $request->validated(); et $animal->update($validated);.
Un disque est un emplacement nommé où Laravel range des fichiers. Les disques sont décrits dans config/filesystems.php. Les deux premiers s'appellent local et public :
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
Le mot local apparaît deux fois, pour deux choses différentes. Le pilote, 'driver' => 'local', dit comment les fichiers sont rangés : sur le disque dur du serveur, dans le dossier root. Les deux disques partagent ce pilote. Le troisième du fichier, s3, en utilise un autre, qui envoie les fichiers chez Amazon. Le nom du disque, local ou public, dit où et pour qui. Le disque local range dans storage/app/private ce que personne ne doit télécharger, comme une sauvegarde de la base. Le disque public range dans storage/app/public ce qui est fait pour être vu, et sa ligne url donne l'adresse sous laquelle le navigateur le demandera. Une photo d'animal est faite pour être vue : elle va sur le disque public.
Ouvrez http://refuge-26.test/animaux/1/modifier, le formulaire de Rex. Choisissez rex.jpg et enregistrez. Lisez la colonne :
php artisan tinker --execute="echo App\Models\Animal::find(1)->photo;"
animals/khqEGt4XYzzwclcPfTYNIR3V1rhPIloTsGT8mRcj.jpg
ls storage/app/public/animals
khqEGt4XYzzwclcPfTYNIR3V1rhPIloTsGT8mRcj.jpg
Chez vous, les quarante caractères sont différents, et PowerShell les affiche dans un tableau. store() tire ce nom au hasard, pour deux raisons. Deux bénévoles qui envoient chacun un photo.jpg n'écrasent pas le fichier l'un de l'autre. Et le nom choisi par l'expéditeur, qui peut contenir des espaces, des accents ou un ../, n'arrive jamais sur le disque. L'extension .jpg vient du contenu, comme pour la règle image.
La fiche de Rex affiche toujours le pictogramme : rien ne lit encore la colonne.
6. Afficher la photo
Dans animals/show.blade.php, remplacez la balise <img> du pictogramme par ce bloc :
@if ($animal->photo !== null)
<img src="{{ asset('storage/'.$animal->photo) }}" alt="Photo de {{ $animal->name }}" class="aspect-4/3 w-full rounded-xl object-cover shadow-xs">
@else
<img src="{{ asset($animal->species->image()) }}" alt="" width="200" height="200" class="aspect-4/3 w-full rounded-xl bg-brand-50 object-contain p-8 shadow-xs dark:bg-brand-100">
@endif
asset('storage/'.$animal->photo) donne l'adresse http://refuge-26.test/storage/animals/khqE….jpg. object-cover remplit le cadre sans déformer la photo, en coupant ce qui dépasse. Sans photo, la fiche garde le pictogramme. Son alt reste vide, car le pictogramme décore et le badge écrit déjà « Chien ». La photo, elle, montre un animal précis, et alt="Photo de Rex" le dit aux lecteurs d'écran.
Faites le même changement dans la carte de la liste, animals/index.blade.php ou <x-animal-card> selon votre code, avec object-cover pour la photo.
Rechargez la fiche de Rex. À la place de la photo, le navigateur montre une image cassée. Ouvrez F12, onglet Réseau, et rechargez : la requête vers storage/animals/khqE….jpg répond 403.
Au chapitre 6, vous avez vu que la racine web est public/, et le serveur n'y trouve aucun dossier storage. La requête arrive donc à public/index.php, et Laravel la confie à la route storage/{path}, que route:list vous a montrée au même chapitre. Cette route sert les fichiers du disque local, et seulement aux adresses signées que Laravel fabrique lui-même. L'adresse de la photo n'a pas de signature, et la route répond 403.
Le lien public/storage
Laravel prévoit une commande qui rend le dossier storage/app/public visible depuis public/ :
php artisan storage:link
INFO The [C:\…\refuge-26\public\storage] link has been connected to [C:\…\refuge-26\storage\app/public].
Chez vous, Artisan affiche les chemins complets. La commande crée public/storage, qui n'est pas un vrai dossier. C'est un lien symbolique : une entrée de dossier qui renvoie vers un autre dossier. Ouvrir public/storage/animals revient à ouvrir storage/app/public/animals, et le serveur web trouve enfin le fichier. Sous Windows, Laravel crée une variante appelée jonction, qui ne demande pas de droits d'administrateur. Rechargez la fiche : la photo de Rex s'affiche.

La liste /animaux montre la photo dans la carte de Rex, et le pictogramme dans les autres.
Astuce
« The [… public\storage] link already exists. » veut dire que le lien est déjà là : il n'y a rien à refaire.
Ce que Git ne voit pas
Ouvrez .gitignore, à la racine du projet. Il contient cette ligne :
/public/storage
Le lien n'est pas versionné : il pointe vers un chemin propre à votre ordinateur. Les photos non plus, car storage/app/public/.gitignore contient *, qui exclut tout, puis !.gitignore, qui garde ce seul fichier. Quiconque clone le dépôt lance donc php artisan storage:link une fois, comme php artisan key:generate. Ajoutez la commande aux étapes d'installation de votre README.md.
Pourquoi asset() plutôt que Storage::disk('public')->url($animal->photo) ? Relisez la ligne url du disque public, à la section 5. Elle construit l'adresse avec APP_URL, qui vaut http://localhost:8000 dans le .env du dépôt. asset(), lui, part de l'adresse de la page ouverte, http://refuge-26.test.
7. Remplacer et supprimer
Modifiez encore Rex, choisissez rex-ete.jpg et enregistrez. La fiche montre la nouvelle photo. Regardez le dossier :
ls storage/app/public/animals
F2GAvUCbwejurmSCCUeysaorEVI8eB9MBjNTz8jW.jpg
khqEGt4XYzzwclcPfTYNIR3V1rhPIloTsGT8mRcj.jpg
La colonne photo désigne le nouveau fichier, et l'ancien reste sur le disque sans qu'aucune ligne le cite. Ajoutez ensuite un animal, Bruno, avec une photo, puis supprimez-le depuis sa fiche. Sa ligne disparaît, mais son fichier reste dans storage/app/public/animals. Après un an d'adoptions, le dossier serait plein de photos que plus personne ne peut afficher.
La base ne connaît que le chemin : le fichier se supprime à part. Storage::disk('public')->delete($path) efface le fichier $path du disque public. Importez la façade en haut du contrôleur :
use Illuminate\Support\Facades\Storage;
Dans update(), effacez l'ancienne photo quand une nouvelle arrive :
public function update(AnimalRequest $request, Animal $animal): RedirectResponse
{
$validated = $request->validated();
if ($request->hasFile('photo')) {
// L'ancienne photo est remplacée : son fichier ne servira plus.
if ($animal->photo !== null) {
Storage::disk('public')->delete($animal->photo);
}
$validated['photo'] = $request->file('photo')->store('animals', 'public');
}
$animal->update($validated);
// Aucune case cochée : le champ tags n'est pas envoyé, sync([]) retire tous les traits.
$animal->tags()->sync($validated['tags'] ?? []);
return redirect()
->route('animals.show', $animal->id)
->with('status', 'L\'animal a été modifié.');
}
Dans destroy(), effacez la photo avant de supprimer la ligne :
public function destroy(Animal $animal): RedirectResponse
{
// La base ne connaît que le chemin : le fichier se supprime à part.
if ($animal->photo !== null) {
Storage::disk('public')->delete($animal->photo);
}
// Les lignes de animal_tag suivent grâce à cascadeOnDelete().
$animal->delete();
return redirect()
->route('animals.index')
->with('status', 'L\'animal a été supprimé.');
}
Le if protège les animaux sans photo. delete() attend un chemin, et s'arrête sur null :
TypeError
League\Flysystem\Filesystem::delete(): Argument #1 ($location) must be of type string, null given, called in vendor\laravel\framework\src\Illuminate\Filesystem\FilesystemAdapter.php on line 610
Dans storage/app/public/animals, effacez à la main les fichiers de trop : gardez seulement celui que cite la colonne de Rex, avec la commande tinker de la section 5. Remplacez ensuite de nouveau la photo de Rex, puis ajoutez et supprimez Bruno. Le dossier ne garde que la photo actuelle de Rex :
eaKbHpXrccnKDSQ5qw4vN57RZileX3GX4KBYt6Rl.jpg
Modifiez enfin la description de Rex sans choisir de fichier : la photo reste. Laravel écarte le champ photo vide, $validated n'a pas de clé photo, et update() ne touche pas à la colonne.
8. Les tests
Créez tests/Feature/PhotoTest.php et copiez-y ce fichier en entier :
<?php
use App\Models\Animal;
use App\Models\Shelter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/*
| Chapitre 18 : une photo par animal.
| Storage::fake('public') remplace le disque public par un dossier temporaire, vidé à chaque test :
| vos fichiers de storage/app/public ne sont jamais touchés.
| UploadedFile::fake()->image('rex.jpg') fabrique une vraie image JPEG de 10 × 10 pixels.
*/
test('les deux formulaires envoient en multipart/form-data et proposent un champ photo', function (): void {
$animal = Animal::factory()->create();
$this->get('/animaux/nouveau')
->assertOk()
->assertSee('enctype="multipart/form-data"', false)
->assertSee('type="file"', false)
->assertSee('name="photo"', false);
$this->get("/animaux/{$animal->id}/modifier")
->assertOk()
->assertSee('enctype="multipart/form-data"', false)
->assertSee('name="photo"', false);
})->group('photo');
test('la photo envoyée à l\'ajout est rangée sur le disque public et son chemin en base', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$this->post('/animaux', validAnimal($shelter, [
'photo' => UploadedFile::fake()->image('rex.jpg'),
]))->assertSessionHasNoErrors();
$animal = Animal::where('name', 'Rex')->firstOrFail();
expect($animal->photo)->toStartWith('animals/')->toEndWith('.jpg');
Storage::disk('public')->assertExists($animal->photo);
})->group('photo');
test('la photo est facultative', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$this->post('/animaux', validAnimal($shelter))->assertSessionHasNoErrors();
expect(Animal::where('name', 'Rex')->firstOrFail()->photo)->toBeNull();
})->group('photo');
test('un PDF est refusé, avec un message en français', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$this->post('/animaux', validAnimal($shelter, [
'photo' => UploadedFile::fake()->create('fiche-rex.pdf', 100, 'application/pdf'),
]))->assertSessionHasErrors(['photo' => 'Le champ photo doit être une image.']);
$this->assertDatabaseCount('animals', 0);
expect(Storage::disk('public')->allFiles())->toBeEmpty();
})->group('photo');
test('une image de plus de 2 Mo est refusée', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$this->post('/animaux', validAnimal($shelter, [
'photo' => UploadedFile::fake()->image('rex.jpg')->size(3000),
]))->assertSessionHasErrors('photo');
$this->assertDatabaseCount('animals', 0);
})->group('photo');
test('la fiche et la liste affichent la photo, sinon le pictogramme de l\'espèce', function (): void {
$rex = Animal::factory()->create(['name' => 'Rex', 'species' => 'chien', 'photo' => 'animals/rex.jpg']);
$minette = Animal::factory()->create(['name' => 'Minette', 'species' => 'chat', 'photo' => null]);
$this->get("/animaux/{$rex->id}")
->assertOk()
->assertSee('storage/animals/rex.jpg', false)
->assertSee('alt="Photo de Rex"', false);
$this->get("/animaux/{$minette->id}")
->assertOk()
->assertSee('images/species/chat.svg', false);
$this->get('/animaux')
->assertOk()
->assertSee('storage/animals/rex.jpg', false)
->assertSee('images/species/chat.svg', false);
})->group('photo');
test('modifier sans choisir de fichier garde la photo', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$old = UploadedFile::fake()->image('rex.jpg')->store('animals', 'public');
$animal = Animal::factory()->for($shelter)->create(['photo' => $old]);
$this->put("/animaux/{$animal->id}", validAnimal($shelter, ['description' => 'Un nouveau texte.']))
->assertSessionHasNoErrors();
expect($animal->fresh()->photo)->toBe($old);
Storage::disk('public')->assertExists($old);
})->group('photo');
test('remplacer la photo supprime l\'ancien fichier', function (): void {
Storage::fake('public');
$shelter = Shelter::factory()->create();
$old = UploadedFile::fake()->image('rex.jpg')->store('animals', 'public');
$animal = Animal::factory()->for($shelter)->create(['photo' => $old]);
$this->put("/animaux/{$animal->id}", validAnimal($shelter, [
'photo' => UploadedFile::fake()->image('rex-ete.jpg'),
]))->assertSessionHasNoErrors();
$new = $animal->fresh()->photo;
expect($new)->not->toBe($old);
Storage::disk('public')->assertExists($new);
Storage::disk('public')->assertMissing($old);
})->group('photo');
test('supprimer un animal supprime sa photo', function (): void {
Storage::fake('public');
$photo = UploadedFile::fake()->image('rex.jpg')->store('animals', 'public');
$animal = Animal::factory()->create(['photo' => $photo]);
$this->delete("/animaux/{$animal->id}")->assertRedirect(route('animals.index'));
$this->assertModelMissing($animal);
Storage::disk('public')->assertMissing($photo);
})->group('photo');
Trois outils de Laravel rendent ces tests possibles :
Storage::fake('public')remplace le disquepublicpar un dossier temporaire, vidé avant chaque test. Vos photos destorage/app/publicne sont jamais touchées.UploadedFile::fake()->image('rex.jpg')fabrique un objetUploadedFilequi contient une vraie image, dessinée par l'extension GD de PHP.->size(3000)lui fait annoncer 3 000 Ko.create('fiche-rex.pdf', 100, 'application/pdf')fabrique un fichier qui n'est pas une image.assertExists()etassertMissing()vérifient qu'un fichier est présent ou absent sur le disque simulé.
validAnimal() vient de tests/Pest.php, comme dans les tests du chapitre 17. Lancez le groupe :
php artisan test --group=photo
PASS Tests\Feature\PhotoTest
✓ les deux formulaires envoient en multipart/form-data et proposent un champ photo 0.60s
✓ la photo envoyée à l'ajout est rangée sur le disque public et son chemin en base 0.07s
✓ la photo est facultative 0.03s
✓ un PDF est refusé, avec un message en français 0.04s
✓ une image de plus de 2 Mo est refusée 0.03s
✓ la fiche et la liste affichent la photo, sinon le pictogramme de l'espèce 0.05s
✓ modifier sans choisir de fichier garde la photo 0.03s
✓ remplacer la photo supprime l'ancien fichier 0.04s
✓ supprimer un animal supprime sa photo 0.04s
Tests: 9 passed (39 assertions)
Si un test est rouge, ce tableau dit ce qu'il attend de votre code :
| Test | Ce que vous écrivez |
|---|---|
| les deux formulaires envoient en multipart/form-data… | enctype="multipart/form-data" sur les deux <form>, et <input type="file" name="photo"> dans <x-animal-fields> |
| la photo envoyée à l'ajout est rangée sur le disque public… | la colonne photo, photo dans #[Fillable], et store('animals', 'public') dans store() |
| la photo est facultative | nullable dans la règle et dans la migration |
| un PDF est refusé, avec un message en français | la règle image |
| une image de plus de 2 Mo est refusée | la règle max:2048 |
| la fiche et la liste affichent la photo… | @if ($animal->photo !== null) dans la fiche et dans la carte, avec alt="Photo de {{ $animal->name }}" |
| modifier sans choisir de fichier garde la photo | le if ($request->hasFile('photo')) dans update() |
| remplacer la photo supprime l'ancien fichier | Storage::disk('public')->delete($animal->photo) dans update() |
| supprimer un animal supprime sa photo | le même delete() dans destroy() |
Lancez enfin php artisan test sans groupe : les 41 tests du chapitre 17 doivent rester verts. Dans SupprimerTest, les animaux n'ont pas de photo : sans le if de destroy(), ils s'arrêtent sur le TypeError de la section 7. Faites un commit et poussez. La CI lance tous les fichiers de tests/Feature/, le nouveau compris.
9. Ce qu'il ne faut pas faire
Attention
- Faire confiance au nom du fichier.
getClientOriginalName()etgetClientOriginalExtension()renvoient ce que l'expéditeur a écrit, et n'importe qui peut envoyer unrex.jpgqui n'est pas une image. La règleimageetstore()lisent le contenu : gardez-les. - Ranger les fichiers directement dans
public/. Tout ce qui s'y trouve est servi tel quel, sans passer par Laravel, et le dossierpublic/est versionné. Le disquepublicet le lienpublic/storagegardent les fichiers des visiteurs à part. - Versionner
public/storageoustorage/app/public. Le lien ne fonctionne que sur votre ordinateur, et les photos n'ont rien à faire dans le dépôt. Ne retirez pas les lignes des deux.gitignore. - Oublier
max. Sans cette règle, seule la limiteupload_max_filesizede PHP arrête un fichier, et elle peut valoir plusieurs gigaoctets.
Les fichiers téléversés posent d'autres problèmes de sécurité. Ils reviennent au chapitre « Les failles typiques du web ».
À retenir
- Un formulaire qui envoie un fichier porte
enctype="multipart/form-data", sinon le serveur ne reçoit que le nom du fichier. - La base garde le chemin de la photo, et le fichier va sur le disque
public, dansstorage/app/public. store('animals', 'public')donne au fichier un nom tiré au hasard, avec l'extension lue dans son contenu.php artisan storage:linkcrée le lienpublic/storage, que Git ignore et que chaque installation doit recréer.- Quand la photo change ou que l'animal disparaît, le code efface l'ancien fichier avec
Storage::disk('public')->delete().
À vérifier
| Vérification | Comment |
|---|---|
| La photo s'affiche sur la fiche et dans la liste | la fiche de Rex et /animaux, après une modification avec photo |
| Un animal sans photo garde son pictogramme | la fiche de Minette |
| Le PDF est refusé en français | « Le champ photo doit être une image. » sous le champ |
| L'ancien fichier disparaît | ls storage/app/public/animals, après un remplacement puis après une suppression |
Les tests du groupe photo sont verts |
php artisan test --group=photo, puis php artisan test |
public/storage n'est pas dans Git |
git status ne cite ni public/storage ni storage/app/public/animals |
L'examen propose la photo d'un objet trouvé parmi ses fonctionnalités supplémentaires. Elle se construit avec le même mécanisme : un champ de fichier, une règle image, le disque public et le lien public/storage.