Aller au contenu principal

Upload de fichiers

Le composant Symfony Form fournit un FileType utilisable lors de la construction d'une classe de formulaire (App\Form\XxxType).

Comme dans l'upload de fichiers en PHP, l'idée est donc de stocker le nom du fichier dans la colonne de la table visée, et de gérer avec PHP l'enregistrement du fichier.

Dans le contrôleur, on gèrera l'upload du fichier en récupérant une instance de la classe UploadedFile, fournie par le composant HttpFoundation de Symfony.

Voyons donc le scénario suivant : changer les informations d'un utilisateur, donc possiblement sa photo de profil, à partir d'un formulaire. L'URL configurée sera du type /user/edit/{id}, sans authentification ni contrôle d'accès pour le moment.

Doctrine - BDD​

Dans une entité, on peut facilement ajouter une propriété de type string à l'aide de la commande make:entity.

On pourra la nommer profilePicFilename : en effet, on va y stocker le nom du fichier et non le fichier lui-même.

FormType​

Avec la commande make:form, on peut générer un formulaire pour l'entité concernée.

Dans le FormType généré, nous devons adapter quelques détails :

src/Form/UserType.php
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email')
->add('profilePic', FileType::class, [
'mapped' => false,
'required' => false,
'constraints' => [
new Image()
]
])
->add('Enregistrer', SubmitType::class)
;
}
  • Le champ profilePicFilename automatiquement généré ne nous est pas utile : il s'agit d'une string. Nous voulons un fichier !
  • Nous pouvons donc renommer le champ en profilePic et indiquer son type comme étant un FileType : c'est ce champ qui présentera une balise input de type file dans la page
  • La propriété profilePic n'existe pas dans l'entité : nous devons indiquer à Symfony l'option 'mapped' => false pour qu'il ne considère pas ce champ comme une propriété de notre entité
  • Nous n'allons pas rendre ce champ required : quand on met à jour un profil, on peut mettre à jour l'email seulement, par exemple. Il n'est donc pas obligatoire qu'on renseigne une nouvelle image à chaque fois
  • Enfin, toujours dans les options du champ de formulaire, on ajoute une contrainte avec la classe Image issue des contraintes du composant Validator : on veut que Symfony contrôle que le fichier uploadé est bien une image

Contrôleur​

Une fois le formulaire en place, on peut passer au contrôleur. À la base, on va indiquer le code habituel pour la gestion d'un formulaire :

#[Route('/user/edit/{id}', name: 'user_edit')]
public function edit(
Request $request,
User $user,
EntityManagerInterface $em
): Response {
$form = $this->createForm(UserType::class, $user);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
// Ici, on va ajouter plus tard la logique pour gérer l'upload du fichier

$em->flush();
}

return $this->render('user/edit.html.twig', [
'form' => $form,
'user' => $user
]);
}

La vue​

Dans le template Twig, on va procéder de la manière suivante :

  • On affiche dans la partie supérieure de la page les informations de l'utilisateur :
    • Email
    • Photo de profil si elle est présente
  • On affiche ensuite un formulaire pour mettre à jour les informations
{% block body %}
<div class="mx-auto max-w-[900px]">
<h1 class="text-3xl my-6">User {{ user.email }}</h1>

{% if user.profilePicPath is not null %}
<p>
<img
src="{{ '/uploads/user/' ~ user.profilePicPath }}"
alt="{{ user.email }}"
class="rounded-full max-h-[150px]"
/>
</p>
{% endif %}

<div>{{ form(form) }}</div>
</div>
{% endblock %}

Si on affiche une page comme /user/edit/1 par exemple, alors on affiche un utilisateur qui peut ne pas avoir de photo de profil :

Form base

Nous pouvons maintenant retourner dans le contrôleur et ajouter la gestion de l'upload.

Gestion de l'upload​

Dans le contrôleur, si le formulaire est soumis et valide, alors on va vérifier si un fichier a été uploadé :

if ($form->isSubmitted() && $form->isValid()) {
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $profilePic */
$profilePic = $form->get('profilePic')->getData();

if ($profilePic) {
// Si une image est présente, alors on déclenche l'upload
}

$em->flush();
}

Si le formulaire a été soumis et est considéré comme valide, alors on tente de récupérer les données du fichier auprès du formulaire avec $form->get('profilePic')->getData().

On reprend évidemment le nom indiqué dans la classe de formulaire, ici profilePic.

La condition if ($profilePic) permet d'éviter de déclencher un upload si aucun fichier n'a été soumis. Si aucun fichier n'est présent, alors $profilePic vaut null : on ne rentrera pas dans le if.

PHPDoc
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $profilePic */
$profilePic = $form->get('profilePic')->getData();

On a ajouté une ligne de PHPDoc au-dessus de la déclaration de la variable $profilePic : cela servira à VSCode pour l'auto-complétion. VSCode considèrera bien cette variable comme une instance de la classe UploadedFile

Si un fichier est bien présent, on peut rentrer dans le if. On va alors construire un nom de fichier unique, puis déplacer le fichier de sa zone de transit vers la destination.

Voici le code complet et commenté, par étape :

if ($form->isSubmitted() && $form->isValid()) {
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $profilePic */
$profilePic = $form->get('profilePic')->getData();

if ($profilePic) {
// On récupère le nom original du fichier, tel que nommé par le client
// La méthode getClientOriginalName est disponible sur les instances de UploadedFile
$originalFilename = pathinfo($profilePic->getClientOriginalName(), PATHINFO_FILENAME);
// On en génère une nouvelle version sans caractères spéciaux, sans accents, etc...
// On appelle ça un "slug"
// Exercice : trouvez comment avoir cette variable $slugger
// Indice : explorez dans les types autowirés ce que le container vous propose
// pour le terme "slugger"
// -> php bin/console debug:autowiring slugger
// Dès que vous avez trouvé le type correspondant, effectuez un type-hint et
// nommez le paramètre $slugger
$safeFilename = $slugger->slug($originalFilename);
// On peut alors construire le nom du fichier tel qu'il sera stocké sur le serveur
$filename = $safeFilename . '-' . uniqid() . '.' . $profilePic->guessExtension();

try {
// À la manière de move_uploaded_file en PHP, on tente ici de déplacer le fichier
// de sa zone de transit à sa destination
// Cette méthode peut lancer des exceptions : on encadre donc l'appel par un bloc
// try...catch
$profilePic->move(
'uploads/user/',
$filename
);
// Si on n'est pas passé dans le catch, alors on peut enregistrer le nom du fichier
// dans la propriété profilePicFilename de l'utilisateur
$user->setProfilePicFilename($filename);
} catch (FileException $e) {
$form->addError(new FormError("Erreur lors de l'upload du fichier"));
}
}

// De toute façon, on met à jour l'enregistrement en base de données
$em->flush();
}

Enfin, si on retourne dans le navigateur et qu'on tente d'uploader un fichier, on verra l'utilisateur mis à jour :

Form success

Si on tente d'uploader un fichier qui n'est pas une image, nous aurons une erreur :

Form error

Ici, c'est la contrainte de validation Image qui a déclenché l'erreur : le fichier n'était pas au bon format.

Refactorisation​

La documentation Symfony est très bien faite : elle reprend les étapes décrites ci-dessus. Elle va même un peu plus loin en montrant comment isoler la logique d'upload dans un service.

Cela évite d'avoir trop de code dans le contrôleur, et favorise la réutilisation du code à différents endroits de l'application.

Supprimer l'ancien fichier​

Il y a un problème dans la gestion d'upload mise en place : si on met à jour la photo de profil d'un utilisateur, la précédente n'est pas supprimée !

Dans le contrôleur, au moment de l'upload, donc dans le try du bloc try...catch, on peut ajouter cette gestion assez facilement :

try {
$profilePic->move(
'uploads/user/',
$filename
);
// Ici, nettoyage avant de modifier le nom du fichier
if ($user->getProfilePicPath() !== null) {
unlink(__DIR__ . "/../../public/uploads/user/" . $user->getProfilePicPath());
}
$user->setProfilePicPath($filename);
} catch (FileException $e) {
// ...
}

Aller plus loin​

Pour aller plus loin, la documentation conseille également l'utilisation d'un Bundle : VichUploaderBundle.

L'installation de ce bundle permet de bénéficier d'une gestion complète d'upload au sein de l'application.

Après quelques étapes de configuration, le bundle sera capable de prendre en charge l'upload lui-même, déposer le fichier au bon endroit, utiliser une stratégie de nommage du fichier qu'on lui aura configurée, mais aussi supprimer l'ancien fichier lors d'une mise à jour ou d'une suppression.