Skip to Content

Chapitre 9.5 — Upload de fichiers

Partie 9 : Formulaires — Chapitre 5/5 Version de référence : Symfony 7.4 / VichUploader / Flysystem.

⏱️ TL;DR

  • Un champ FileType gère l’upload ; le formulaire passe en multipart/form-data (géré par form_start).
  • Côté serveur, on récupère un UploadedFile : on valide (type MIME, taille) avec #[Assert\File]/#[Assert\Image], on génère un nom sûr et unique, et on déplace le fichier vers un stockage.
  • Ne jamais faire confiance au nom d’origine ni stocker dans un dossier exécutable exposé. Valider le vrai type MIME.
  • VichUploader automatise le tout, lié à une entité (upload, nommage, suppression).
  • Flysystem abstrait le stockage : local en dev, S3 (ou compatible) en prod, sans changer le code.
  • Pour un dev Next.js : c’est votre gestion d’upload (Multer/formidable) + stockage objet (S3), mais intégré au formulaire et à l’entité.

🎯 Objectifs

  • Ajouter un champ d’upload et valider le fichier.
  • Générer un nom de fichier sûr et déplacer le fichier.
  • Automatiser avec VichUploader.
  • Stocker sur S3 via Flysystem, et sécuriser les uploads.

1. Le champ FileType et la validation

use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Validator\Constraints as Assert; ->add('logo', FileType::class, [ 'label' => 'Logo du prestataire', 'mapped' => false, // non lié à une propriété de l'entité (on gère à la main) 'required' => false, 'constraints' => [ new Assert\Image( maxSize: '2M', mimeTypes: ['image/png', 'image/jpeg', 'image/webp'], mimeTypesMessage: 'Formats acceptés : PNG, JPEG, WebP.', ), ], ])

#[Assert\File] (fichiers en général) et #[Assert\Image] (images, avec dimensions min/max) valident la taille et le type MIME réel (pas juste l’extension). mapped: false signifie « ce champ n’est pas une propriété de l’objet lié » — on récupère le fichier manuellement.

2. Traiter l’upload à la main

use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\String\Slugger\SluggerInterface; if ($form->isSubmitted() && $form->isValid()) { /** @var UploadedFile|null $file */ $file = $form->get('logo')->getData(); if ($file) { // nom SÛR : on ne fait jamais confiance au nom d'origine $safeName = $slugger->slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); $newName = $safeName . '-' . uniqid() . '.' . $file->guessExtension(); $file->move($this->getParameter('logos_dir'), $newName); // déplace vers le stockage $provider->setLogo($newName); // on stocke le NOM en base, pas le fichier } $em->flush(); }

Points de sécurité non négociables :

  • Nom sûr : getClientOriginalName() vient du client (potentiellement ../../evil.php). On slugifie et on régénère un nom unique. Jamais le nom d’origine tel quel.
  • Extension : guessExtension() déduit l’extension du contenu (MIME réel), pas de ce que prétend le client.
  • Emplacement : stocker hors d’un dossier exécutable. Idéalement un dossier de public/uploads/ pour des fichiers publics non exécutables, ou mieux, un stockage objet (S3) hors du serveur web (§4).

⚠️ Piège sécurité majeur : autoriser l’upload de fichiers exécutables (un .php) dans un dossier servi par le serveur web = exécution de code à distance. Toujours : (1) valider le MIME réel (allowlist de types), (2) régénérer le nom et l’extension, (3) stocker dans un emplacement non exécutable (ou S3). Ne servez jamais un dossier d’upload comme du PHP.

3. Automatiser avec VichUploader

Gérer tout ça à la main pour chaque champ est répétitif. VichUploaderBundle l’automatise, lié à l’entité :

composer require vich/uploader-bundle
use Vich\UploaderBundle\Mapping\Annotation as Vich; #[ORM\Entity] #[Vich\Uploadable] class Provider { #[Vich\UploadableField(mapping: 'provider_logos', fileNameProperty: 'logoName')] private ?File $logoFile = null; // le fichier (non persisté) #[ORM\Column(nullable: true)] private ?string $logoName = null; // le nom stocké en base }

Config du mapping (config/packages/vich_uploader.yaml) : où stocker, comment nommer. Désormais, il suffit d’ajouter un FileType mappé sur logoFile, et VichUploader gère l’upload, le nommage (avec un nommeur configurable) et la suppression du fichier quand l’entité est supprimée. Beaucoup moins de code, et cohérent.

4. Stockage flexible avec Flysystem (et S3)

En dev, on stocke en local ; en prod, on veut souvent un stockage objet (S3, MinIO, Scaleway, Cloudflare R2…) — pour la scalabilité, les sauvegardes, et parce que le disque d’un serveur web éphémère (conteneur, worker) ne doit pas garder les fichiers. Flysystem abstrait ça :

composer require league/flysystem-bundle league/flysystem-async-aws-s3
# config/packages/flysystem.yaml flysystem: storages: default.storage: adapter: 'local' options: { directory: '%kernel.project_dir%/var/storage' } uploads.storage: adapter: 'aws' # S3 en prod options: client: 'aws_client' bucket: '%env(S3_BUCKET)%'

Le même code (écrire/lire via le storage) fonctionne en local et sur S3 — on change juste l’adaptateur par environnement. VichUploader s’intègre à Flysystem pour stocker directement sur S3.

💡 Pour un dev Next.js : Flysystem ≈ une abstraction à la @aws-sdk/client-s3 mais agnostique (local/S3/…), et VichUploader ≈ un middleware d’upload lié à votre modèle. Le pattern « stocker sur S3, garder l’URL/le nom en base » est identique à ce que vous feriez côté Node. En prod conteneurisée, ne stockez pas sur le disque local (éphémère) : S3 (ou compatible) est la norme.

5. Servir les fichiers

  • Fichiers publics (logos, images produits) : servis directement (URL S3/CDN, ou public/uploads/).
  • Fichiers privés (documents d’un client, factures) : servis via un contrôleur qui vérifie les droits (Partie 10) avant de renvoyer le fichier (BinaryFileResponse ou un flux depuis S3). Ne mettez jamais un document privé dans public/.

✏️ Exercices

Exercice 1. Écrivez la contrainte de validation pour un logo : image PNG/JPEG/WebP, 2 Mo max.

✅ Solution

new Assert\Image( maxSize: '2M', mimeTypes: ['image/png', 'image/jpeg', 'image/webp'], mimeTypesMessage: 'Formats acceptés : PNG, JPEG, WebP.', )

#[Assert\Image] valide la taille et le type MIME réel (contenu), pas l’extension déclarée.

Exercice 2. Pourquoi ne jamais utiliser getClientOriginalName() comme nom de fichier stocké ?

✅ Solution

Parce que ce nom vient du client et n’est pas sûr : il peut contenir un chemin (../../, path traversal), une extension trompeuse (photo.php), des caractères dangereux, ou entrer en collision avec un fichier existant. On doit slugifier la base du nom, régénérer un identifiant unique, et déduire l’extension du contenu (guessExtension()). Sinon : écrasement, path traversal, voire exécution de code.

Exercice 3. En production sur des conteneurs éphémères, où stocker les fichiers uploadés et pourquoi ?

✅ Solution

Sur un stockage objet (S3 ou compatible : MinIO, R2, Scaleway), via Flysystempas sur le disque local du conteneur, qui est éphémère (les fichiers disparaissent au redéploiement/scaling et ne sont pas partagés entre instances). Flysystem permet de garder le même code en changeant juste l’adaptateur (local en dev, S3 en prod).

🧠 Quiz de révision

1. Quel champ gère l’upload et quel encodage le formulaire adopte-t-il ?

FileType ; le formulaire passe en multipart/form-data (géré automatiquement par form_start).

2. Trois règles de sécurité pour un upload ?

Valider le MIME réel (allowlist), régénérer un nom sûr et unique (jamais le nom client), stocker dans un emplacement non exécutable (ou S3).

3. Que fait VichUploader ?

Il automatise l’upload lié à une entité : validation, nommage, stockage et suppression du fichier — moins de code manuel.

4. À quoi sert Flysystem ?

À abstraire le stockage : le même code écrit/lit en local (dev) ou sur S3/compatible (prod), en changeant juste l’adaptateur.

5. Comment sert-on un fichier privé (facture d’un client) ?

Via un contrôleur qui vérifie les droits (Partie 10) avant de renvoyer le fichier — jamais depuis public/.


Fin de la Partie 9. BookLab sait collecter, valider et stocker des données proprement. La validation écrite ici protège aussi l’API. Il est temps de sécuriser l’application.

Prochaine étape : Partie 10 — Sécurité & authentification — firewalls, rôles, voters, et le JWT pour l’API Next.js.