L'upload de fichiers dans un formulaire Symfony
Comment permettre à un utilisateur d'envoyer un fichier (image, document) via un formulaire Symfony
Notions théoriques
Le champ FileType
Vous avez déjà appris à créer des formulaires Symfony avec AbstractType et des champs comme TextType. Pour permettre l'envoi d'un fichier (une photo de profil, un document joint...), Symfony fournit un champ dédié : FileType.
use Symfony\Component\Form\Extension\Core\Type\FileType;
$builder->add('imageFile', FileType::class, [
'label' => 'Image du personnage',
'mapped' => false,
'required' => false,
]);
'mapped' => false signifie que ce champ n'est pas directement lié à une propriété de l'entité. C'est normal : un formulaire Symfony ne peut pas mapper automatiquement un fichier envoyé (un objet UploadedFile) vers une propriété censée contenir un simple nom de fichier en base de données. Le fichier sera traité manuellement dans le contrôleur.
Le formulaire HTML : enctype="multipart/form-data"
En PHP procédural, vous avez déjà vu qu'un formulaire HTML capable d'envoyer un fichier doit obligatoirement porter l'attribut :
<form method="post" enctype="multipart/form-data">
Sans cet attribut, le fichier n'est jamais transmis au serveur. Avec Symfony, si votre formulaire contient un champ FileType, le composant Form ajoute automatiquement cet attribut lors du rendu avec form_start(form). Vous n'avez donc rien à faire de spécial côté Twig : c'est la présence du champ FileType dans la classe FormType qui déclenche ce comportement.
Récupérer le fichier envoyé : UploadedFile
Dans le contrôleur, après avoir géré la soumission du formulaire, on récupère le fichier envoyé sous la forme d'un objet UploadedFile :
use Symfony\Component\HttpFoundation\File\UploadedFile;
/** @var UploadedFile $imageFile */
$imageFile = $form->get('imageFile')->getData();
Cet objet donne accès à des informations utiles : getClientOriginalName() (le nom du fichier sur l'ordinateur de l'utilisateur), getSize(), guessExtension(), etc.
Déplacer le fichier et générer un nom unique
Par défaut, un fichier envoyé est stocké dans un dossier temporaire du serveur et sera supprimé à la fin de la requête. Il faut donc le déplacer explicitement vers un dossier de destination, avec la méthode move() :
$newFilename = uniqid() . '.' . $imageFile->guessExtension();
$imageFile->move(
$this->getParameter('characters_directory'),
$newFilename
);
Ne jamais réutiliser le nom original du fichier (getClientOriginalName()) comme nom de destination. Deux utilisateurs peuvent envoyer un fichier portant le même nom (par exemple photo.jpg), ce qui écraserait un fichier existant. Le nom original peut aussi contenir des caractères spéciaux dangereux ou permettre à un attaquant de deviner l'emplacement d'un autre fichier. On génère toujours un nom unique et imprévisible, par exemple avec uniqid().
Stocker le nom du fichier en base de données
Une fois le fichier déplacé, seul le nom du fichier (pas le fichier lui-même) est enregistré dans une propriété de l'entité, via le setter habituel :
$character->setImage($newFilename);
Cette propriété image peut ensuite être utilisée dans une vue Twig pour afficher l'image, en la combinant avec le chemin public du dossier de stockage.
Valider le fichier avec la contrainte File
Comme pour n'importe quelle propriété d'entité, il est possible de valider le fichier envoyé, par exemple pour n'accepter que des images de moins de 2 Mo :
use Symfony\Component\Validator\Constraints\File;
$builder->add('imageFile', FileType::class, [
'mapped' => false,
'required' => false,
'constraints' => [
new File([
'maxSize' => '2M',
'mimeTypes' => [
'image/jpeg',
'image/png',
],
'mimeTypesMessage' => 'Veuillez envoyer une image valide (JPEG ou PNG).',
])
],
]);
La contrainte File vérifie le type MIME réel du fichier (en inspectant son contenu), pas seulement son extension. Un fichier renommé .jpg mais contenant en réalité un script ne passera pas la validation mimeTypes.
Exemple de mise en application
Ajoutons la possibilité d'uploader une image pour un personnage. D'abord, ajoutons une propriété image à l'entité Character :
// src/Entity/Character.php
#[ORM\Column(type: "string", length: 255, nullable: true)]
private ?string $image = null;
public function getImage(): ?string
{
return $this->image;
}
public function setImage(?string $image): self
{
$this->image = $image;
return $this;
}
Ajoutons ensuite le champ imageFile non mappé au formulaire CharacterType :
// src/Form/CharacterType.php
namespace App\Form;
use App\Entity\Character;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
class CharacterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class)
->add('imageFile', FileType::class, [
'label' => 'Image du personnage',
'mapped' => false,
'required' => false,
'constraints' => [
new File([
'maxSize' => '2M',
'mimeTypes' => ['image/jpeg', 'image/png'],
'mimeTypesMessage' => 'Veuillez envoyer une image valide (JPEG ou PNG).',
])
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Character::class,
]);
}
}
Enfin, traitons l'upload dans le GameController, après validation du formulaire :
use Symfony\Component\HttpFoundation\File\UploadedFile;
#[Route('/game/character/create', name: 'create_character')]
public function createCharacter(Request $request): Response
{
$character = new Character();
$form = $this->formFactory->create(CharacterType::class, $character);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile $imageFile */
$imageFile = $form->get('imageFile')->getData();
if ($imageFile) {
$newFilename = uniqid() . '.' . $imageFile->guessExtension();
$imageFile->move(
$this->getParameter('characters_directory'),
$newFilename
);
$character->setImage($newFilename);
}
$this->characterRepository->save($character, true);
return $this->redirectToRoute('game');
}
return $this->render('game/create_character.html.twig', ['form' => $form->createView()]);
}
Le paramètre characters_directory est défini dans config/services.yaml et pointe généralement vers un dossier public comme public/uploads/characters. Il est ensuite injecté automatiquement partout où $this->getParameter('characters_directory') est appelé.
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre défi pour aujourd'hui consiste à ajouter l'upload d'une image de profil sur l'entité
Player, en suivant le même principe que pourCharacterdans l'exemple ci-dessus.
Étape 1 — Ajouter le champ imageFile au formulaire PlayerType
Ajouter 'required' => false sur un champ FileType utilisé pour une modification permet à l'utilisateur de modifier les autres champs du formulaire sans être obligé de renvoyer une nouvelle image à chaque fois.
Étape 2 — Récupérer le fichier envoyé dans le contrôleur
Étape 3 — Générer un nom unique, déplacer le fichier et enregistrer le nom en base
Le champ imageFile étant facultatif ('required' => false), il peut être null lorsque l'utilisateur ne souhaite pas changer son image. Toujours vérifier if ($imageFile) avant d'appeler move(), sinon une erreur sera levée sur un objet null.
Sans contrainte File sur le champ imageFile, n'importe quel visiteur pourrait envoyer un fichier de plusieurs centaines de mégaoctets ou un fichier qui n'est pas réellement une image. Toujours limiter maxSize et mimeTypes sur un champ d'upload accessible depuis un formulaire public.