La gestion des images dans un projet Symfony

publié le 07/10/2017

symfony 3 VichUploaderBundle LiipImagineBundle

Même en utilisant ces 2 bundles incontournables que sont VichUploaderBundle et LiipImagineBundle, la mise en place d'un système de téléchargement et de manipulation d'images sur Symfony est relativement périlleuse. La documentation est loin d'être complète sur le sujet et au terme de nombreux essais sur des projets différents, il semble désormais que je dispose d'un système de gestion d'images robuste.


Mise en place du projet

En ligne de commande:

symfony new my-project

Voici les dépendances de mon projet (extrait du fichier composer.json):

{  
    "require": {
        "php": ">=5.5.9",
        "doctrine/annotations": "^1.5",
        "doctrine/doctrine-bundle": "^1.6",
        "doctrine/orm": "^2.5",
        "incenteev/composer-parameter-handler": "^2.0",
        "liip/imagine-bundle": "^1.9",
        "sensio/distribution-bundle": "^5.0.19",
        "sensio/framework-extra-bundle": "^3.0.2",
        "symfony/monolog-bundle": "^3.1.0",
        "symfony/polyfill-apcu": "^1.0",
        "symfony/swiftmailer-bundle": "^2.3.10",
        "symfony/symfony": "3.3.*",
        "twig/twig": "^1.0||^2.0",
        "vich/uploader-bundle": "^1.6"
    }
}
composer install

pour installer les dépendances puis éditez le fichier appKernel.php

<?php
// app/AppKernel.php
// ...
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new Liip\ImagineBundle\LiipImagineBundle(),
            new Vich\UploaderBundle\VichUploaderBundle(),
        );
        // ...
    }
    // ...
}
# app/config/routing.yml
 
# ajout des routes liées à LiipImagineBundle
_liip_imagine:
    resource: "@LiipImagineBundle/Resources/config/routing.xml"

Nous allons ensuite configurer dans parameters.yml le chemin relatif du dossier de destination des images, étant donné qu'on aura souvent besoin de ce paramètre.

# app/config/config.yml
 
parameters:
    path_image: images

Configuration des bundles

Il s'agit maintenant de configurer les 2 bundles. Editez le fichier config.yml

# app/config/config.yml
 
vich_uploader:
    db_driver: orm
 
    mappings:
        images:
            uri_prefix: '%path_image%' # utilisation de notre paramètre dynamique
            upload_destination: '%kernel.root_dir%/../web/%path_image%' 
            namer: vich_uploader.namer_origname # permet d'avoir un nom d'image unique
            inject_on_load: true # injecte l'image au chargement du formulaire
            delete_on_update: true
            delete_on_remove: true
 
# j'ai laissé la configuration par défaut.
liip_imagine:
    resolvers:
        default:
            web_path: ~
    filter_sets:
        cache: ~

Création de l'entité image et de son repository

<?php
 
// src/AppBundle/Entity/Image.php
 
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
use Symfony\Component\Validator\Constraints as Assert;
use AppBundle\Validator\Constraints as AppAssert; // cette contrainte de validation sera créée lors de l'étape suivante
 
 
/**
 * @Vich\Uploadable
 * @ORM\Entity(repositoryClass="AppBundle\Repository\ImageRepository")
 * @AppAssert\Image
 */
class Image
{
 
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\Length(max=255)
     * @var string
     */
    private $image;
 
    /**
     * @Vich\UploadableField(mapping="images", fileNameProperty="image")
     * @Assert\File(
     * maxSize="1000k",
     * maxSizeMessage="Le fichier excède 1000Ko.",
     * mimeTypes={"image/png", "image/jpeg", "image/jpg", "image/svg+xml", "image/gif"},
     * mimeTypesMessage= "formats autorisés: png, jpeg, jpg, svg, gif"
     * )
     * @var File
     */
    private $imageFile;
 
    /**
     * @ORM\Column(type="datetime")
     * @var \DateTime
     */
    private $updatedAt;
 
    /**
    * @var string
    *
     * @ORM\Column(type="string", length=255, nullable=true)
    */
    private $tmpFile;
 
    public function __toString(){
    return (string) $this->image;
    }
 
    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }
 
    public function setImageFile(File $image = null)
    {
        $this->imageFile = $image;
 
        // VERY IMPORTANT:
        // It is required that at least one field changes if you are using Doctrine,
        // otherwise the event listeners won't be called and the file is lost
        if ($image) {
            // if 'updatedAt' is not defined in your entity, use another property
            $this->updatedAt = new \DateTime('now');
        }
    }
 
    public function getImageFile()
    {
        return $this->imageFile;
    }
 
    public function setImage($image)
    {
        $this->image = $image;
    }
 
    public function getImage()
    {
        return $this->image;
    }
 
    public function setUpdatedAt($updatedAt)
    {
        $this->updatedAt = $updatedAt;
    }
 
    public function getUpdatedAt(){
        return $this->updatedAt;
    }
     
    /*
    * Set tmpFile
    * @return Image
    */
    public function setTmpFile($tmpFile)
    {
        $this->tmpFile = $tmpFile;
        return $this;
    }
 
    /*
    * Get tmpFile
    * @return string
    */
    public function getTmpFile()
    {
        return $this->tmpFile;
    }
}
<?php
 
// src/AppBundle/Repository/ImageRepository.php
 
namespace AppBundle\Repository;
 
use Doctrine\ORM\EntityRepository;
 
/**
* ImageRepository
*/
class ImageRepository extends EntityRepository
{
}

Création de la contrainte de validation personnalisée

Cette contrainte de validation sert dans la situation où l'utilisateur a tenté de télécharger une image mais qu'une erreur survient dans un autre champ de l'entité. Lorsque l'utilisateur validera une nouvelle fois son formulaire, le code plantera s'il n'a pas pensé à renseigner de nouveau son fichier. Le code ci dessous est un fallback pour éviter ce genre de désagrément.

<?php
 
//src/AppBundle/Validator/Constraints/Image.php
 
namespace AppBundle\Validator\Constraints;
 
use Symfony\Component\Validator\Constraint;
 
/**
 * @Annotation
 */
class Image extends Constraint
 
{
    public $message = "L'image n'a pas été renseignée.";
    
    public function getTargets()
    {
        return self::CLASS_CONSTRAINT;
    }
}
<?php
 
// src/AppBundle/Validator/Constraints/ImageValidator.php
 
namespace AppBundle\Validator\Constraints;
 
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
 
class ImageValidator extends ConstraintValidator
{
    public function validate($protocol, Constraint $constraint)
    {
        if($protocol->getImageFile() === null && $protocol->getImage() === null && $protocol->getUpdatedAt() === null)
        {
            return $this->context
                ->buildViolation($constraint->message)
                ->atPath('imageFile')
                ->addViolation()
                ;
        }
    }
}

Création des listeners

La difficulté principale quand on associe VichUploaderBundle et LiipImagineBundle consiste à synchroniser le cache de liip et le répertoire de destination des uploads de vich. Pour résoudre ce problème, il va falloir créer 1 listener.

<?php
 
namespace AppBundle\Listeners;
 
use Doctrine\ORM\Event\LifecycleEventArgs;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use AppBundle\Entity\Image;
use AppBundle\Services\ImageTransformer;
use Vich\UploaderBundle\Event\Event;
use Doctrine\ORM\EntityManagerInterface;
 
/**
 * ImageListener
 */
class ImageListener
{
    private $cacheManager;
    private $path_image;
    private $orm;
 
    public function __construct(CacheManager $cacheManager, EntityManagerInterface $orm, string $path_image)
    {
        $this->cacheManager = $cacheManager;
        $this->path_image = $path_image;
        $this->orm = $orm;
    }
 
    public function onVichUploaderPreInject(Event $args)
    {
        $entity = $args->getObject();
 
        if (!$entity instanceof Image) {
            return;
        }
 
        $image = $entity->getImage();
        $entity->setTmpFile($image);
        $this->orm->flush();
 
    }
 
 
    public function postUpdate(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
 
        if (!$entity instanceof Image) {
            return;
        }
        $changeSet = $args->getEntityManager()->getUnitOfWork()->getEntityChangeSet($entity);
 
        if(!array_key_exists("image", $changeSet)){ 
        return;
        }
  
        try {
            $this->cacheManager->remove($this->path_image.'/'.$entity->getTmpFile());
            $this->cacheManager->resolve($this->path_image.'/'.$entity->getImage(), null);
 
        } catch (\Exception $e) {
 
        }
 
    }
 
    public function preRemove(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
 
        if (!$entity instanceof Image) {
            return;
        }
 
        $target = $this->path_image.'/'.$entity->getImage();
        try {
            $this->cacheManager->remove($target);
        } catch (\Exception $e) {
 
        }
    }
 
    public function postPersist(LifecycleEventArgs $args)
    {
        $entity = $args->getObject();
 
        if (!$entity instanceof Image) {
            return;
        }
        $file = $this->path_image.'/'.$entity->getImage();
    }
}
# app/config/services.yml
services
      AppBundle\Listeners\ImageListener:
        arguments:
            $path_image: "%path_image%"
        tags:
            - {name: doctrine.event_listener, event: postUpdate}
            - {name: doctrine.event_listener, event: preRemove}
            - {name: doctrine.event_listener, event: postPersist}
            - {name: kernel.event_listener, event: vich_uploader.pre_inject }

Le cas particulier du format SVG

Il semble que LiipImagine ne supporte pas le format SVG. Selon le driver utlisé dans la configuration (imagick, gmagick), le filtre du bundle, une fois appliqué, soit ne génère aucune image, soit génère une image pixellisée au format SVG. Dans les 2 cas, le navigateur n'affiche pas l'image. Ma solution est de créer un filtre personnalisé qui redirige vers liip uniquement si l'image n'est pas au format svg.

<?php
namespace AppBundle\Twig;
 
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
 
class TwigExtension extends \Twig_Extension
{
        private $imageDir;
        private $cacheManager;
 
    public function __construct(CacheManager $cacheManager, string $imageDir)
    {
        $this->imageDir = $imageDir;
        $this->cacheManager = $cacheManager;
    }
 
    public function getFilters(){
        return array(
            new \Twig_SimpleFilter('my_imagine_filter', array($this, 'myImagineFilter')),
        );
    }
 
    public function myImagineFilter($path, $filter, array $runtimeConfig = array(), $resolver = null)
    {
        $ext = pathinfo($path, PATHINFO_EXTENSION);
         
        if($ext === "svg")
        {
             return $this->imageDir."/".basename($path);
        }
        else
        {
            return $this->cacheManager->getBrowserPath($path, $filter, $runtimeConfig, $resolver);
        }
 
    }
 
}
#app/config/services.yml
services:
    AppBundle\Twig\TwigExtension:
        arguments:
            $imageDir: "%path_image%"
        tags:
            - {name: twig.extension}

Désormais, dans un template, vous pouvez utliser les fonctionnalités Liip de cette manière:

  <img src="{{asset('VectorOrRasterImage')|my_imagine_filter('yourFilter')}}" alt="" />  

En espérant que cet article vous aura été utile. N'hésitez pas à me laisser un message si vous avez des questions ou des remarques.