Aller au contenu

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 :

  1. envoyer un fichier depuis un formulaire, et expliquer le rôle de enctype="multipart/form-data",
  2. valider une image et sa taille, avec des messages en français,
  3. enregistrer le fichier sur le disque public, et l'afficher grâce au lien public/storage,
  4. supprimer l'ancien fichier quand la photo change ou que l'animal disparaît,
  5. 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 formulaire « Ajouter un animal » renvoyé avec une erreur : sous le champ Photo, vide, le message rouge « Le champ photo doit être une image. », les autres champs gardent Rex, Chien et le refuge choisi

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 fiche de Rex : sa photo à gauche, dans un cadre aux coins arrondis, à droite le badge Chien, le nom, la description, le refuge, l'âge, les traits et les boutons Modifier, Adopter et Supprimer

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 disque public par un dossier temporaire, vidé avant chaque test. Vos photos de storage/app/public ne sont jamais touchées.
  • UploadedFile::fake()->image('rex.jpg') fabrique un objet UploadedFile qui 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() et assertMissing() 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() et getClientOriginalExtension() renvoient ce que l'expéditeur a écrit, et n'importe qui peut envoyer un rex.jpg qui n'est pas une image. La règle image et store() 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 dossier public/ est versionné. Le disque public et le lien public/storage gardent les fichiers des visiteurs à part.
  • Versionner public/storage ou storage/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 limite upload_max_filesize de 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, dans storage/app/public.
  • store('animals', 'public') donne au fichier un nom tiré au hasard, avec l'extension lue dans son contenu.
  • php artisan storage:link crée le lien public/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.