Comment générer une image avec Intervention Image et Messenger sous Symfony

Cet article est la suite de notre précédent article à propos de Twig Component où nous avons créé et utilisé un composant simple permettant de rendre une balise meta OpenGraph. Nous allons détailler ici comment créer l'image utilisée pour la prévisualisation des articles.

Installation, configuration et prérequis

Pour créer notre service, nous allons utiliser :

  • Intervention Image : une librairie permettant de manipuler des images en PHP, à l'aide de GD ou Imagick.
  • Flysystem et son bundle Symfony permettant d'intéragir avec notre filesystem (ici, local).
  • Messenger pour déporter la création de notre image en asynchrone.

Prérequis

Vous aurez besoin d'installer une extension correspondante au driver utilisé par Intervention Image, choisissez l'une des extensions suivantes :

Je ne vais pas détailler ici l'installation de l'extension, utilisez le gestionnaire de paquet de votre distribution si vous travaillez sans Docker, sinon, utilisez le script de Michele Locati devenu incontournable lorsque vous souhaitez ajouter des extensions PHP à votre image docker.

Configuration du stockage

Nous allons utiliser Flysystem avec un adapter "local".

## config/packages/flysystem.yaml
flysystem:
    storages:
        storage.og_image:
            adapter: 'local'
            options:
                directory: '%kernel.project_dir%/var/storage/og_images'

Création du service

Nous allons générer une image très simple, avec un titre et une image de fond. Rien ne vous empêche d'ajouter un logo depuis une image, des informations supplémentaires, etc.

## src/Service/OpenGraphImageManager.php
<?php

namespace App\Service;

use Intervention\Image\ImageManager;
use Intervention\Image\Interfaces\DriverInterface;
use Intervention\Image\Interfaces\ImageInterface;
use Intervention\Image\Typography\FontFactory;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(
    bind: [
        '$imageDriver' => '@og_image_driver',
        '$imagesStorage' => '@storage.og_image',
        '$projectDir' => '%kernel.project_dir%',
    ]
)]
final readonly class OpenGraphImageManager
{
    private const string IMAGE_EXTENSION = 'webp';
    private const int IMAGE_WIDTH = 1200;
    private const int IMAGE_HEIGHT = 600;

    private const array TITLE_SETTINGS = [
        'x' => 20,
        'y' => 50,
        'offsetWidth' => 20,
        'size' => 52,
        'color' => '#FFF',
        'position' => 'top',
        'font' => 'MonaspaceArgon-Regular.woff',
    ];

    private ImageManager $imageManager;

    private ImageInterface $image;

    public function __construct(
        private DriverInterface $imageDriver,
        private FilesystemOperator $imagesStorage,
        private string $projectDir,
    )
    {
        $this->imageManager = new ImageManager($this->imageDriver);
    }

    /**
     * @throws FilesystemException
     * @throws \Exception
     */
    public function get($title): string
    {
        $filename = $this->getFilename($title);

        if($this->imagesStorage->fileExists($filename)){
            return $this->imagesStorage->read($filename);
        }

        // Return a temporary static image here or implement your own logic.
        throw new \Exception('The image is not generated');
    }

    /**
     * @throws FilesystemException
     */
    public function generate(
        string $title,
        ?string $backgroundImage = null,
    ): void
    {
        $this->image = $this->imageManager->create(width: self::IMAGE_WIDTH, height: self::IMAGE_HEIGHT);

        if($backgroundImage !== null){
            $this->placeBackground($backgroundImage);
        }
        $this->setTitle($title);

        $this->encodeAndSave($title);
    }

    private function placeBackground(string $backgroundImage): void
    {
        $backgroundFile = sprintf(
            "%s/assets/%s",
            $this->projectDir,
            $backgroundImage
        );

        if(file_exists($backgroundFile) === false){
            return;
        }

        $this->image->place($backgroundFile);
    }

    private function setTitle(string $title): void
    {
        $this
            ->image
            ->text(
                text: $title,
                x: self::TITLE_SETTINGS['x'],
                y: self::TITLE_SETTINGS['y'],
                font: fn (FontFactory $font) =>
                    $font
                        ->filename(sprintf('%s/assets/fonts/%s', $this->projectDir, self::TITLE_SETTINGS['font']))
                        ->size(self::TITLE_SETTINGS['size'])
                        ->color(self::TITLE_SETTINGS['color'])
                        ->wrap(self::IMAGE_WIDTH - self::TITLE_SETTINGS['offsetWidth'])
                        ->valign(self::TITLE_SETTINGS['position'])
            );
        ;
    }

    /**
     * @throws FilesystemException
     */
    private function encodeAndSave(string $title): void
    {
        $filename = $this->getFilename($title);
        $image = $this->image->encodeByPath($filename)->toString();

        $this->imagesStorage->write(
            $filename,
            $image
        );
    }

    private function getFilename(string $title): string
    {
        return sprintf('%s.%s', md5($title), self::IMAGE_EXTENSION);
    }
}

Génération asynchrone de l'image

Pour générer l'image de manière asynchrone, nous allons utiliser un controller, à titre d'exemple, dans lequel nous allons générer notre Message, puis le dispatch dans le bus de messenger qui est configuré en asynchrone.

Configuration de Messenger

## config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    multiplier: 2

        routing:
            App\Messenger\OpenGraphImage\GenerateOpenGraphImageMessage: async

Le controller pourrait ressembler à cela :

final class PreviewImageController extends AbstractController
{
    #[Route(
        path: 'generate_async',
        name: 'generate_async',
        methods: [Request::METHOD_GET]
    )]
    public function testGeneration(MessageBusInterface $bus): Response
    {
        $bus->dispatch(
            new GenerateOpenGraphImageMessage(
                title: 'This is my title '.uniqid(),
                backgroundImage: 'images/amp-min.jpg'
            )
        );

        return new Response();
    }
}

Le handler du message est assez simple, étant donné que nous avons déjà créé notre service OpenGraphImageManager, nous l'injectons et nous utilisons la méthode generate avec le titre et l'image de fond qui nous viennent du message envoyé dans Messenger.

<?php

namespace App\Messenger\OpenGraphImage;

use App\Service\OpenGraphImageManager;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class GenerateOpenGraphImageMessageHandler
{
    public function __construct(
        private OpenGraphImageManager $imageManager,
    ){
    }

    public function __invoke(GenerateOpenGraphImageMessage $message): void
    {
        $this->imageManager->generate(
            $message->title,
            $message->backgroundImage
        );
    }
}

Il ne nous reste plus qu'à lancer un bin/console messenger:consume.
Une fois lancé, vous pouvez aller sur la route du controller qui ira envoyer le message dans Messenger.

Vous devriez voir votre message consommé dans la console et votre image apparaître dans le dossier var/storage/og_images.

Crédit photo: Mike Hindle

Arnaud DE ABREU

Arnaud De Abreu

Développeur PHP/ Symfony