Bundle Symfony 2 pour la gestion de vos uploads avec Doctrine 2

Après plusieurs mois d’absence, j’ai souhaité reprendre du service et vous offrir un premier article traitant de Symfony 2. Pendant cette période, j’ai été pas mal occupé avec la conception et le développement de plusieurs solutions et notamment d’un extranet exploitant les frameworks Symfony 2 et ExtJS.
J’étais super heureux de pouvoir enfin tester la nouvelle version du framework de Sensio Labs sur un projet de plus grande envergure (j’avais déjà essayé par curiosité ^^). Et mes premières impressions sur cette nouvelle monture… fantastiques ! La simplicité de prise en main est déconcertante, l’organisation du code est devenue plus naturelle, l’injecteur de dépendances est magique,… Je pourrais continuer à faire l’éloge du travail réalisé par les développeurs de Sensio mais ce n’est pas le but de cet article.
Tout d’abord, je souhaite signaler que ce billet est destiné aux personnes connaissant déjà Symfony 2. Si vous êtes débutant, je vous laisse consulter le Quick Tour sur le site de Symfony et les excellents tutoriels de Jérôme Place.
- Créer sa première application web en PHP avec Symfony2
- Améliorez vos applications développées avec Symfony2
Gestion des chargements de fichiers avec Doctrine
Habituellement le chargement des fichiers est opéré directement depuis une classe d’entité (cookbook file upload). Si votre modèle de données est constitué de plusieurs entités devant gérer cette fonctionnalité, l’implémentation devient vite laborieuse. C’est pourquoi nous allons développer un bundle offrant la possibilité de charger des fichiers sans ajouter une seule ligne de code à votre entité (enfin presque).
Dans un premier temps, nous allons créer un bunble pour tester JFSFUploadBundle. Cette première partie vous permettra d’appréhender le fonctionnement du bundle. Et par la suite, je détaillerai le mécanisme de JFSFUploadBundle.
Télécharger – Code source JFSFUploadBundle
JFSFUploadTestsBundle
Pour notre exemple nous allons créer une table « user », représentée par le schéma suivant :
Comme vous pouvez le voir, nous avons deux champs « photo » et « thumb » qui contiendront le nom des fichiers. Une fois votre base de données créée et les paramètres de connexion configurés, nous allons pouvoir générer le bundle et le controller qui permettront la gestion des utilisateurs.
app/console generate:bundle ... app/console doctrine:mapping:import JFSFUploadTestsBundle app/console doctrine:generate:entities JFSFUploadTestsBundle app/console doctrine:generate:crud ...
Vous devez désormais disposer d’un bundle permettant l’ajout, la modification et la suppression d’un utilisateur.
Ouvrez le fichier User.php créé lors de la génération des entités et ajoutez les propriétés « filePhoto » et « fileThumb » avec un accès public (les quelques lignes de code dont je vous ai parlé).
namespace JFSF\Bundle\UploadTestsBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* JFSF\Bundle\UploadTestsBundle\Entity\User
*/
class User
{
public $filePhoto;
public $fileThumb;
...
}
Il faudra également modifier le formulaire pour configurer les champs.
namespace JFSF\Bundle\UploadTestsBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
class UserType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder
->add('firstname')
->add('lastname')
->add('filePhoto', 'file')
->add('fileThumb', 'file')
;
}
public function getName()
{
return 'jfsf_bundle_uploadtestsbundle_usertype';
}
}
A ce stade, il n’y a plus besoin de coder. Il vous suffira de configurer le fichier services.yml pour gérer vos uploads.
parameters:
jfsf_upload.configuration.class: JFSF\Bundle\UploadBundle\Config\UploaderConfiguration
jfsf_upload.uploader.class: JFSF\Bundle\UploadBundle\Uploader\Uploader
jfsf_upload.listener.class: JFSF\Bundle\UploadBundle\Listener\UploaderListener
services:
jfsf_upload.configuration:
class: %jfsf_upload.configuration.class%
arguments:
configuration:
JFSF\Bundle\UploadTestsBundle\Entity\User:
photo: { file:filePhoto, destination: %kernel.root_dir%/../web/user/photos } #public, setter, getter
thumb: { file:fileThumb, destination: %kernel.root_dir%/../web/user/thumbs } #public, setter, getter
jfsf_upload.uploader:
class: %jfsf_upload.uploader.class%
arguments:
configuration: @jfsf_upload.configuration
options:
unique_filename: true
jfsf_upload.listener:
class: %jfsf_upload.listener.class%
arguments:
uploader: @jfsf_upload.uploader
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }
- { name: doctrine.event_listener, event: postPersist }
- { name: doctrine.event_listener, event: postUpdate }
Sympa non ? Mais peut-être que quelques explications s’imposent.
jfsf_upload.configuration
Le service jfsf_upload.configuration permet de définir les classes et les propriétés de vos entités pour lesquelles un fichier pourra être chargé. Plusieurs paramètres peuvent être définis pour une propriété.
- file (obligatoire) : le nom du champ permettant de lier un objet UploadedFile à l’entité
- destination (obligatoire) : le répertoire où sera envoyé le fichier
- public (facultatif, défaut = false) : permet de définir la visibilité de la propriété
- getter(facultatif) : permet de définir l’accessor à utiliser pour accéder à la propriété
- setter (facultatif) : permet de définir le mutator à utiliser pour modifier la propriété
jfsf_upload.upload
Le service jfsf_upload.upload permet la manipulation des entités et la gestion des fichiers chargés. Comme vous pouvez le voir, le constructeur de la classe Uploader attend une instance de UploaderConfiguration. Certaines options peuvent également être spécifiées pour ce service.
- unique_filename(facultatif, défaut = true) : un nom de fichier unique est généré automatiquement
jfsf_upload.listener
Pour terminer, le service jfsf_upload.listener permet d’écouter les événements envoyés par Doctrine. Le constructeur de la classe UploaderListener attend l’instance de Uploader et les méthodes de l’uploader seront appelées lors de l’insertion ou d’une mise à jour d’une de vos entités. Si vous utilisez MongoDB, il vous suffira de configurer le listener pour écouter ses événements.
jfsf_upload.listener:
class: %jfsf_upload.listener.class%
arguments:
uploader: @jfsf_upload.uploader
tags:
- { name: doctrine.odm.mongodb.default_event_listener, event: prePersist }
- { name: doctrine.odm.mongodb.default_event_listener, event: preUpdate }
- { name: doctrine.odm.mongodb.default_event_listener, event: postPersist }
- { name: doctrine.odm.mongodb.default_event_listener, event: postUpdate }
Ok, mais je souhaite gérer le nom des fichiers, comment je fais ?
Pas de problème, il suffit de créer votre listener héritant de la classe JFSF\Bundle\UploadBundle\Listener\UploaderListener et de surcharger les méthodes « prePersit » ou/et « preUpdate ».
namespace JFSF\Bundle\UploadTestsBundle\Listener;
use Doctrine\Common\EventArgs;
use JFSF\Bundle\UploadTestsBundle\Entity\User;
use JFSF\Bundle\UploadBundle\Listener\UploaderListener;
class CustomUploaderListener extends UploaderListener
{
public function prePersist(EventArgs $eventArgs)
{
if($eventArgs->getEntity() instanceof User)
{
$this->uploader->preUpload($eventArgs->getEntity(), array(
'photo' => array('filename' => 'name-photo.jpg'),
'thumb' => array('filename' => 'name-thumb.jpg'),
));
}
}
}
Pour terminer, il faudra simplement modifier la classe à utiliser dans le fichier services.yml.
parameters:
jfsf_upload.configuration.class: JFSF\Bundle\UploadBundle\Config\UploaderConfiguration
jfsf_upload.uploader.class: JFSF\Bundle\UploadBundle\Uploader\Uploader
jfsf_upload.listener.class: JFSF\Bundle\UploadTestsBundle\Listener\CustomUploaderListener
JFSFUploadBundle
Nous allons maintenant décortiquer le bundle JFSFUploadBundle dans le but de simplifier sa compréhension. Les classes de configuration ne seront pas détaillées, pas vraiment utile pour comprendre le fonctionnement du système.
Vous avez pu voir dans la première partie de l’article qu’une instance de la classe UploaderConfiguration était passée au constructeur de la classe Uploader. Une interface UploaderConfigurationInterface sera donc définie et devra être implémentée par la classe UploaderConfiguration.
namespace JFSF\Bundle\UploadBundle\Config;
interface UploaderConfigurationInterface
{
function getEntityConfiguration($entity);
}
La classe Uploader devra également respecter un certain contrat en implémentant l’interface UploaderInterface.
namespace JFSF\Bundle\UploadBundle\Uploader;
interface UploaderInterface
{
function preUpload($entity, array $options);
function upload($entity);
}
namespace JFSF\Bundle\UploadBundle\Uploader;
use JFSF\Bundle\UploadBundle\Config\UploaderConfigurationInterface;
class Uploader implements UploaderInterface
{
protected $configuration;
protected $options = array(
'unique_filename' => true,
);
public function setOptions(array $options)
{
$this->options = array_merge($this->options, $options);
}
public function getOptions()
{
return $this->options;
}
public function setUniqueFilename($value)
{
if(is_bool($value)){
$this->options['unique_filename'] = $value;
}
}
public function getUniqueFilename()
{
return $this->options['unique_filename'];
}
protected $isPreUpload = false;
protected $entityUploader;
public function __construct(UploaderConfigurationInterface $configuration, array $options)
{
$this->configuration = $configuration;
$this->setOptions($options);
}
public function preUpload($entity, array $options = array())
{
$this->isPreUpload = true;
$entityConfiguration = $this->configuration->getEntityConfiguration($entity);
if(null !== $entityConfiguration)
{
$this->entityUploader = new EntityUploader($this, $entity, $entityConfiguration);
foreach ($this->entityUploader as $propertyUploader)
{
$propertyUploader->preUpload($options);
}
}
}
public function upload($entity)
{
if(false === $this->isPreUpload) {
throw new UploaderException('Uploading impossible, the method "preUpload" must be called.');
}
$this->isPreUpload = false;
if(null !== $this->entityUploader)
{
foreach ($this->entityUploader as $propertyUploader)
{
$propertyUploader->upload();
}
$this->entityUploader = null;
}
}
}
Lors de l’appel à la méthode « preUpload », vous remarquerez l’invocation de la méthode « getEntityConfiguration » qui permet de retourner la configuration pour un type d’entité. Si le type d’entité n’a pas été configuré, une exception InvalidConfigurationException sera levée. Une fois la configuration retournée, un objet EntityUploader est instancié. Cette classe implémente l’interface Iterator permettant de récupérer les objets PropertyUploader à l’aide d’une instruction foreach.
namespace JFSF\Bundle\UploadBundle\Uploader;
use JFSF\Bundle\UploadBundle\Config\EntityConfigurationInterface;
class EntityUploader implements \Iterator
{
protected $uploader;
public function getUploader()
{
return $this->uploader;
}
protected $entity;
public function getEntity()
{
return $this->entity;
}
protected $entityConfiguration;
public function getEntityConfiguration()
{
return $this->entityConfiguration;
}
private $index = 0;
private $properties = array();
public function __construct(Uploader $uploader, $entity, EntityConfigurationInterface $entityConfiguration)
{
$this->uploader = $uploader;
$this->entity = $entity;
$this->entityConfiguration = $entityConfiguration;
foreach ($this->entityConfiguration as $propertyConfiguration)
{
$uploadedFile = $this->entity->{ $propertyConfiguration->getFile() };
if(null !== $uploadedFile) {
$this->properties[] = new PropertyUploader($this, $propertyConfiguration->getProperty(), $propertyConfiguration, $uploadedFile);
}
}
}
public function rewind()
{
$this->index = 0;
}
public function current()
{
return $this->properties[$this->index];
}
public function key()
{
return $this->index;
}
public function next()
{
$this->index ++;
}
public function valid()
{
return isset($this->properties[$this->index]);
}
}
Pour chaque objet PropertyUploader la méthode « preUpload » sera appelée pour modifier la propriété de l’entité avec le nom du fichier. Par la suite, la méthode « upload » devra être invoquée pour déplacer le fichier chargé à la bonne destination.
namespace JFSF\Bundle\UploadBundle\Uploader;
use JFSF\Bundle\UploadBundle\Config\PropertyConfigurationInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class PropertyUploader
{
protected $entityUploader;
public function getEntityUploader()
{
return $this->entityUploader;
}
protected $property;
public function getProperty()
{
return $this->property;
}
protected $propertyConfiguration;
public function getPropertyConfiguration()
{
return $this->propertyConfiguration;
}
protected $uplodedFile;
public function getUplodedFile()
{
return $this->uplodedFile;
}
public function __construct(EntityUploader $entityUploader, $property, PropertyConfigurationInterface $propertyConfiguration, UploadedFile $uplodedFile)
{
$this->entityUploader = $entityUploader;
$this->property = $property;
$this->propertyConfiguration = $propertyConfiguration;
$this->uplodedFile = $uplodedFile;
}
public function preUpload(array $options)
{
$filename = empty($options[$this->property]['filename']) ? $this->getFilename() : $options[$this->property]['filename'];
if($this->propertyConfiguration->isPublic()){
$this->entityUploader->getEntity()->{$this->property} = $filename;
}
else {
$setter = $this->propertyConfiguration->getSetter();
$this->entityUploader->getEntity()->{$setter}( $filename );
}
}
public function upload()
{
if($this->propertyConfiguration->isPublic()){
$filename = $this->entityUploader->getEntity()->{$this->property};
}
else {
$getter = $this->propertyConfiguration->getGetter();
$filename = $this->entityUploader->getEntity()->{$getter}();
}
$this->uplodedFile->move( $this->propertyConfiguration->getDestination() , $filename );
unset($this->entityUploader->getEntity()->{ $this->propertyConfiguration->getFile() });
}
protected function getFilename()
{
if($this->entityUploader->getUploader()->getUniqueFilename()) {
return uniqid().'.'. pathinfo($this->uplodedFile->getClientOriginalName(), PATHINFO_EXTENSION);
}
else {
return $this->uplodedFile->getClientOriginalName();
}
}
}
A quel moment les méthodes de l’uploader sont appelées ? Tout simplement lors de l’insertion ou d’une mise à jour d’une de vos entités depuis la classe UploaderListener.
namespace JFSF\Bundle\UploadBundle\Listener;
use JFSF\Bundle\UploadBundle\Uploader\UploaderInterface;
use Doctrine\Common\EventArgs;
class UploaderListener implements UploaderListenerInterface
{
protected $uploader;
public function __construct( UploaderInterface $uploader )
{
$this->uploader = $uploader;
}
public function prePersist(EventArgs $eventArgs)
{
$this->uploader->preUpload($eventArgs->getEntity());
}
public function preUpdate(EventArgs $eventArgs)
{
$this->uploader->preUpload($eventArgs->getEntity());
}
public function postPersist(EventArgs $eventArgs)
{
$this->uploader->upload($eventArgs->getEntity());
}
public function postUpdate(EventArgs $eventArgs)
{
$this->uploader->upload($eventArgs->getEntity());
}
}
Vous disposez désormais d’un bundle modulable pour la gestion de vos uploads. En espérant que cet article inspirera quelques personnes ^^ Voici une liste de fonctionnalités que vous pouvez vous amuser à coder :
- suppression des anciens fichiers lors d’un UPDATE
- suppression du fichier lors d’un DELETE,…
Articles traitant d'un sujet similaire
- Tester les performances du cache d’OPCode APC pour PHP avec ApacheBench
- Configurer Apache et SmartOptimizer
- La puissance du langage XSL couplé au standard OpenDocument
- Mise en ligne de plusieurs projets open source
- Optimiser son site web avec les Sprites CSS et SmartOptimizer
- Classe javascript pour le redimensionnement d’images en fullscreen

Pingback: Symfony2 : JFSF upload bundle file input NULL | Code and Programming
Cléo -
25 décembre 2012 à 21 h 45 min
Salut,
J'ai suivi le tuto à la lettre et tout fonctionne.
Par contre, quand j'essaie d'implémenter le code dans une nouvelle Entité, lors de la validation du formulaire, j'obtiens une erreur lorsque je soumets mon formulaire.
Avec un var_dump sur mon entité, voici le code :
["error":"Symfony\Component\HttpFoundation\File\UploadedFile":private]=> int(0) ["pathName":"SplFileInfo":private]=> string(36) "/Applications/MAMP/tmp/php/phpctAgfD"
Après divers tests, si je crée un bundle de zéro puis en implémentant le tuto, cela fonctionne. En tentant de créer une nouvelle entité dans un bundle existant, ça ne fonctionne plus et j'obtiens l'erreur ci-dessus.
Il existe peut-être un problème avec le services.yml.
Si quelqu'un a déjà été confronté à ce problème... Merci !
fareh -
28 juin 2012 à 19 h 59 min
Voilà aussi un bon tutoriel qui explique très bien l'upload des fichiers et images avec doctorine :
http://www.dev-skills.com/upload-file-with-doctrine-in-symfony2/
Ce tuto met chaque image dans le dossier portant l'id de la page .
Ahmed -
19 avril 2012 à 10 h 16 min
Salut,
j'ai toujours le path temporaire du fichier qui s'insère dans la base au lieux du nom généré
Merci.
movrack -
31 décembre 2011 à 1 h 44 min
Yop, encore moi.
Je suis arriver à l'étape, comment supprimer le fichier lors de la suppression de l'entité.
J'ai vu que dans la class Uploader, la méthode remove ce charge de la suppression du fichier sur le disque. Pourtant quand je teste, cela ne fonctionne pas.
J'ai également tenté de la ré implémenté dans mon listener mais cela ne fonctionne pas plus.
public function postRemove(EventArgs $eventArgs) {
$entity = $eventArgs->getEntity();
if ($entity instanceof EpisodeVersion) {
if ($entity->fileMp3 != null) {
unlink($entity->fileMp3->getAbsolutePathOgg());
}
if ($entity->fileOgg != null) {
unlink($entity->fileOgg->getAbsolutePathOgg());
}
}
$this->uploader->remove($eventArgs->getEntity());
}
Est-ce possible que ce soit parce que la suppressions ce fait via un cascade de doctrine?
J'ai une entité Episode qui contient des EpisodeVersion (qui contiennent les mp3 et ogg)
Sinon une remarque pour ton tuto car j'ai un peu chercher mais j'y suis arriver. Pour renommer le fichier lors de l'upload:
$eventArgs->getEntity()->photo->getClientOriginalName()
permettra de retrouver le nom original. C'est bête mais ça va en éviter certain qui commence avec sf à chercher comment on fait ;)
Jérôme Fath -
31 décembre 2011 à 14 h 59 min
Yo, movrack merci pour le commentaire. Concernant la classe d'uploader, je n'ai jamais implémenté la méthode remove().
interface UploaderInterface
{
function preUpload($entity, array $options);
function upload($entity);
}
Je sais que bat a réalisé un fork du bundle, tu parles peut être de son code.
Pour automatiser la suppression des fichiers, l'implémentation du code devrait être réalisé dans les classes Uploader, EntityUploader et PropertyUploader et non depuis le Listener.
movrack -
30 décembre 2011 à 15 h 29 min
Pas très constructif par rapport au post, mais cela fait tjs plaisir à entendre.
Merci pour ce bundle. Très pratique.
Jérôme Fath -
20 décembre 2011 à 18 h 04 min
Désolé bat, je me suis mal exprimé. L'upload des fichiers fonctionne de manière transparente grâce à cette configuration :
jfsf_upload.listener:
class: JFSF\Bundle\UploadBundle\Listener\UploaderListener
arguments:
uploader: @jfsf_upload.uploader
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }
- { name: doctrine.event_listener, event: postPersist }
- { name: doctrine.event_listener, event: postUpdate }
A chaque fois qu'une entité est ajoutée, doctrine diffuse les événements prePersist et postPersit et les méthodes prePersit() et postPersist() de la classe UploaderListener sont appelées.
public function prePersist(EventArgs $eventArgs)
{
$this->uploader->preUpload($eventArgs->getEntity());
}
public function preUpdate(EventArgs $eventArgs)
{
$this->uploader->preUpload($eventArgs->getEntity());
}
Ce qui permet le chargement des fichiers sans une seule ligne de code, c'est justement l'appel aux méthodes du listener lors de l'insertion ou la maj des différentes entités. L'uploader vérifie ensuite la configuration des entités, si l'entité est configurée alors le processus est lancé. Il ne doit en aucun cas lever une exception si l'entité n'est pas configurée.
Tu peux également te passer du listener et gérer tes uploads depuis ton controller mais il faut coder ^^
public function createAction()
{
$entity = new User();
$request = $this->getRequest();
$form = $this->createForm(new UserType(), $entity);
$form->bindRequest($request);
if ($form->isValid()) {
$this->container->get('jfsf_upload.uploader')->preUpload($entity);
$em = $this->getDoctrine()->getEntityManager();
$em->persist($entity);
$em->flush();
$this->container->get('jfsf_upload.uploader')->upload($entity);
return $this->redirect($this->generateUrl('user_show', array('id' => $entity->getId())));
}
return $this->render('JFSFUploadTestsBundle:User:new.html.twig', array(
'entity' => $entity,
'form' => $form->createView()
));
}
bat -
20 décembre 2011 à 10 h 26 min
Merci beaucoup pour ce bel exemple.
Par contre je n'arrive pas à enregistrer le nom de l'image sur un update (le fichier est bien créé).
Sur une création tout fonctionne correctement.
bat -
20 décembre 2011 à 12 h 12 min
Autre problème : les évènements sont déclenchés pour toutes mes entités.
J'ai donc maintenant partout des "The entity class "%s" is not configured for uploader.".
Jérôme Fath -
20 décembre 2011 à 15 h 03 min
Hello bat,
Merci pour la remarque, je viens de mettre à jour l'article et le code source du Bundle. Désormais aucune exception ne sera levée lorsque l'entité n'est pas configurée.
Concernant le problème du nom de fichier qui n'est pas mis à jour lors d'un update. Je n'ai pas le temps de regarder pour le moment mais dès que l'occasion se présente, je me penche dessus. Si tu trouves la solution n'hésite pas à la partager.
bat -
20 décembre 2011 à 16 h 49 min
Hélas je ne pense pas qu'enlever l'exception soit suffisant : je pense qu'il faut pour chaque callback vérifier que l'entité fait partie des entités déclarées dans les paramètres du service (configuration), et si ce n'est pas le cas, ne pas lancer l'update.
J'en arrive alors à avoir des doutes quant à la pertinence de cette solution.
Corrige-moi si je me trompe, mais si tu as 100 entités, et seulement une dizaine qui implémentent l'upload de fichier, les callbacks seront appelées pour rien dans 90% des cas (je simplifie bien sûr), ce qui n'est pas trop optimal...