Aller au contenu principal

Générer son SEO avec Symfony AI et Gemini

Implémentez la génération automatique de titre, description et mots-clés SEO avec Symfony AI, Gemini et Messenger dans votre administration EasyAdmin
Catégorie

Symfony

Architecture, composants et patterns avancés du framework Symfony.

Lecture
12 min
Niveau
Expert
févr 15 2026
Partager

Rédiger des métadonnées SEO pour un article technique est un travail ingrat. Le titre doit tenir en 60 caractères avec le mot-clé principal en tête. La description doit occuper entre 150 et 160 caractères pour éviter la troncature dans les résultats Google. Les mots-clés doivent refléter le contenu réel, pas les intentions de l'auteur. Et cette tâche revient à chaque publication.

J’ai décidé d’implémenter une fonctionnalité de génération SEO automatique dans une administration EasyAdmin. Le traitement est délégué à Google Gemini via le bundle Symfony AI, exécuté en arrière-plan par Symfony Messenger, et les résultats sont présentés dans un panneau latéral où le rédacteur accepte ou ignore chaque suggestion individuellement.

L'ensemble repose sur trois packages Composer : symfony/ai-bundle, symfony/ai-gemini-platform et symfony/ai-platform.

Pourquoi de l'asynchrone

Un appel à l'API Gemini prend entre 2 et 10 secondes selon la taille du prompt. Bloquer la requête HTTP pendant ce temps signifie un navigateur figé, un spinner sans fin, et un bouton "retour" tentant.

Le choix retenu : dispatcher un message Messenger, retourner immédiatement un identifiant de job au frontend, et laisser un worker traiter l'appel IA en arrière-plan. Le navigateur poll un endpoint GET toutes les 2 secondes jusqu'à obtenir le résultat.

Ce pattern — dispatch immédiat, polling léger, cache éphémère — est réutilisable pour toute intégration d'API externe lente.

Configuration Symfony AI

Enregistrement et plateforme Gemini

PHP
// config/bundles.php
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
PHP
// config/packages/ai.php
return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('ai', [
        'platform' => [
            'gemini' => [
                'api_key' => '%env(GEMINI_API_KEY)%',
            ],
        ],
    ]);
};

Cette configuration enregistre un service PlatformInterface dans le container, injectable via autowiring dans les services applicatifs.

Transport Messenger dédié

Les messages IA sont routés vers un transport Redis séparé (async_ai) :

PHP
// config/packages/messenger.php
return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('framework', [
        'messenger' => [
            'transports' => [
                'async'    => '%env(MESSENGER_TRANSPORT_DSN)%',
                'async_ai' => '%env(MESSENGER_AI_TRANSPORT_DSN)%',
            ],
            'routing' => [
                GenerateSeoMessage::class => 'async_ai',
                'App\Message\*'           => 'async',
            ],
        ],
    ]);
};

Le DSN utilise Redis Streams sur la base de données 2 : redis://redis:6379/2/ai_messages?stream_max_entries=1000. Le paramètre stream_max_entries limite la taille du stream. La séparation des transports permet de scaler les workers IA indépendamment des workers applicatifs, avec des limites de mémoire et de temps adaptées.

Rate limiting

Pour protéger le quota Gemini, un rate limiter en sliding window est configuré : 20 requêtes par minute.

PHP
// config/packages/rate_limiter.php (extrait)
'gemini_api' => [
    'policy' => 'sliding_window',
    'limit' => 20,
    'interval' => '1 minute',
    'cache_pool' => 'cache.rate_limiter',
],

Il est injecté dans les services via #[Autowire(service: 'limiter.gemini_api')].

Le pivot du système : AiJobResultService

Ce service gère le cycle de vie des jobs dans le cache Redis. Il expose trois opérations : création d'un job pending, stockage d'un succès, stockage d'un échec.

PHP
#[WithMonologChannel('ai')]
final readonly class AiJobResultService
{
    private const int JOB_TTL_SECONDS = 600;
    private const string CACHE_PREFIX = 'ai_job_';

    public function __construct(
        private CacheItemPoolInterface $cache,
    ) {}

    public function createPendingJob(string $jobId): void
    {
        $item = $this->cache->getItem(self::CACHE_PREFIX . $jobId);
        $item->set([
            'status' => AiJobStatusEnum::PENDING->value,
            'result' => null,
            'error'  => null,
        ]);
        $item->expiresAfter(self::JOB_TTL_SECONDS);
        $this->cache->save($item);
    }

    public function storeSuccess(string $jobId, array $result): void { /* ... */ }
    public function storeFailure(string $jobId, string $error): void { /* ... */ }
    public function getJobResult(string $jobId): ?array { /* ... */ }
}

Le TTL de 10 minutes est un compromis : assez long pour que le frontend récupère le résultat, assez court pour ne pas encombrer Redis avec des résultats orphelins.

L'enum AiJobStatusEnum fournit une méthode isTerminal() qui distingue les statuts finaux (completed, failed) du statut intermédiaire (pending) :

PHP
enum AiJobStatusEnum: string
{
    case PENDING   = 'pending';
    case COMPLETED = 'completed';
    case FAILED    = 'failed';

    public function isTerminal(): bool
    {
        return match ($this) {
            self::PENDING                => false,
            self::COMPLETED, self::FAILED => true,
        };
    }
}

Le service de génération SEO

Le DTO de sortie

PHP
final readonly class AiSeoSuggestion
{
    public function __construct(
        public string $seoTitle,
        public string $seoDescription,
        public array $keywords,
    ) {}
}

Le prompt et l'invocation Gemini

Le service SeoGenerationService encapsule l'appel à Gemini. Le system prompt définit le rôle et les contraintes :

Text
Tu es un expert SEO francophone spécialisé dans le développement web
(PHP, Symfony, Docker, DevOps). Tu génères des métadonnées SEO
optimisées pour un blog technique français.

Règles strictes :
- seoTitle : max 60 caractères, mot-clé principal en début de titre
- seoDescription : 150-160 caractères, incitatif, pas de guillemets doubles
- keywords : 3 à 7 mots-clés pertinents en minuscules

Réponds UNIQUEMENT en JSON valide avec les clés : seoTitle, seoDescription, keywords.

L'invocation utilise l'API unifiée de Symfony AI :

PHP
public function generate(
    string $title,
    string $content,
    ?string $categoryTitle = null,
): ?AiSeoSuggestion {
    $limiter = $this->geminiApiLimiter->create('seo_generation');

    if (!$limiter->consume()->isAccepted()) {
        $this->logger->warning('Rate limit exceeded for SEO generation');
        return null;
    }

    try {
        $userPrompt = $this->buildUserPrompt($title, $content, $categoryTitle);

        $messages = new MessageBag(
            Message::forSystem(self::SYSTEM_PROMPT),
            Message::ofUser($userPrompt),
        );

        $response = $this->platform->invoke(self::MODEL, $messages, [
            'response_format' => AiSeoSuggestion::class,
        ]);

        return $response->asObject();
    } catch (\Throwable $e) {
        $this->logger->error('SEO generation failed', [
            'title' => $title,
            'error' => $e->getMessage(),
        ]);
        return null;
    }
}

Message::forSystem() et Message::ofUser() structurent la conversation via un MessageBag. C'est l'abstraction Symfony AI pour représenter les échanges avec le modèle, indépendamment du fournisseur (Gemini, OpenAI, Anthropic).

Le paramètre 'response_format' => AiSeoSuggestion::class utilise la classe PHP comme schéma de réponse structurée. Gemini retourne du JSON typé, désérialisé automatiquement par $response->asObject(). Ce mécanisme élimine le parsing manuel et la validation du JSON côté applicatif.

En cas d'erreur (timeout réseau, réponse malformée, quota dépassé), le service retourne null. Ce fallback permet au SeoAutoFillSubscriber existant — qui génère du SEO basique sans IA — de prendre le relais.

Le contenu envoyé à Gemini

Le user prompt inclut le titre de l'article, son contenu (tronqué à 3 000 caractères pour limiter la consommation de tokens), et la catégorie si elle existe. La catégorie oriente les mots-clés : un article dans "Symfony" produira des mots-clés différents du même article classé dans "DevOps".

Message et Handler

PHP
final readonly class GenerateSeoMessage
{
    public function __construct(
        public string $jobId,
        public string $title,
        public string $content,
        public ?string $category = null,
    ) {}
}
PHP
#[AsMessageHandler]
#[WithMonologChannel('ai')]
final readonly class GenerateSeoHandler
{
    public function __invoke(GenerateSeoMessage $message): void
    {
        try {
            $suggestion = $this->seoGenerationService->generate(
                $message->title,
                $message->content,
                $message->category,
            );

            if (!$suggestion instanceof AiSeoSuggestion) {
                $this->aiJobResultService->storeFailure(
                    $message->jobId,
                    'La génération SEO est temporairement indisponible.',
                );
                return;
            }

            $this->aiJobResultService->storeSuccess($message->jobId, [
                'seoTitle'       => $suggestion->seoTitle,
                'seoDescription' => $suggestion->seoDescription,
                'keywords'       => $suggestion->keywords,
            ]);
        } catch (\Throwable $e) {
            $this->aiJobResultService->storeFailure(
                $message->jobId,
                'La génération SEO a échoué. Veuillez réessayer.',
            );
        }
    }
}

Le handler délègue au service, puis stocke le résultat dans Redis. Le double try/catch (service + handler) garantit qu'un statut terminal est toujours enregistré — le frontend ne reste pas bloqué sur un poll infini.

Les endpoints API

Deux controllers ADR (__invoke()) exposent l'API JSON, tous deux protégés par #[IsGranted('ROLE_ADMIN')].

Déclenchement de la génération

POST /admin/ai/generate-seo/{contentId}
Body: { "title": "...", "content": "...", "category": "..." }
Response: { "jobId": "uuid-v4" }   (HTTP 202 Accepted)
PHP
#[Route('/admin/ai/generate-seo/{contentId}', methods: ['POST'])]
public function __invoke(string $contentId, Request $request): JsonResponse
{
    $jobId = Uuid::v4()->toRfc4122();
    $this->aiJobResultService->createPendingJob($jobId);

    $this->messageBus->dispatch(new GenerateSeoMessage(
        jobId: $jobId,
        title: $title,
        content: $content,
        category: $category,
    ));

    return new JsonResponse(['jobId' => $jobId], Response::HTTP_ACCEPTED);
}

Le HTTP 202 (Accepted) est sémantiquement correct : la requête a été acceptée pour traitement, mais le résultat n'est pas encore disponible.

Polling du statut

GET /admin/ai/job/{jobId}
Response (pending):   { "status": "pending", "result": null, "error": null }
Response (completed): { "status": "completed", "result": { ... }, "error": null }
Response (expired):   HTTP 404 { "error": "Job non trouvé ou expiré." }
PHP
#[Route('/admin/ai/job/{jobId}', methods: ['GET'])]
public function __invoke(string $jobId): JsonResponse
{
    $result = $this->aiJobResultService->getJobResult($jobId);

    if ($result === null) {
        return new JsonResponse(
            ['error' => 'Job non trouvé ou expiré.'],
            Response::HTTP_NOT_FOUND,
        );
    }

    return new JsonResponse($result);
}

Frontend Stimulus : polling et offcanvas de preview

Le template Twig

Le point d'entrée est le template d'édition EasyAdmin, étendu pour ajouter le bouton et la zone de résultat :

Twig
{% extends '@EasyAdmin/crud/edit.html.twig' %}

{% block main %}
    {% set entity = ea.entity.instance %}

    {% if entity is not null and entity.id is not null %}
        <div class="mb-3"
             data-controller="ai-seo"
             data-ai-seo-endpoint-value="{{ path('admin_ai_generate_seo', { contentId: entity.id }) }}"
             data-ai-seo-poll-endpoint-value="/admin/ai/job">

            <button data-action="ai-seo#generate">
                Générer SEO avec l'IA
            </button>

            <div data-ai-seo-target="status" class="d-none"></div>

            <div id="ai-seo-offcanvas" class="offcanvas offcanvas-end"
                 data-ai-seo-target="offcanvas">
                <div class="offcanvas-header">...</div>
                <div class="offcanvas-body" data-ai-seo-target="offcanvasBody"></div>
            </div>
        </div>
    {% endif %}

    {{ parent() }}
{% endblock %}

Les endpoints sont passés via Stimulus Values (data-ai-seo-endpoint-value), ce qui découple le JavaScript des routes Symfony. L'offcanvas est un squelette HTML vide, rempli dynamiquement par le controller.

Le mécanisme de polling

JavaScript
static POLL_INTERVAL_MS = 2000;
static MAX_POLLS = 60;

#startPolling(jobId) {
    let pollCount = 0;
    const pollUrl = `${this.pollEndpointValue}/${jobId}`;

    this.#pollTimer = setInterval(async () => {
        pollCount++;

        if (pollCount > this.constructor.MAX_POLLS) {
            this.#stopPolling();
            this.#showStatus("Délai d'attente dépassé.", "danger");
            this.#setLoading(false);
            return;
        }

        const response = await fetch(pollUrl, {
            headers: { Accept: "application/json" },
        });
        const data = await response.json();

        if (data.status === "completed") {
            this.#stopPolling();
            this.#openOffcanvas(form, data.result);
            this.#setLoading(false);
        } else if (data.status === "failed") {
            this.#stopPolling();
            this.#showStatus(data.error, "danger");
            this.#setLoading(false);
        }
    }, this.constructor.POLL_INTERVAL_MS);
}

Le timeout de 120 secondes (60 polls × 2 secondes) protège contre les jobs qui ne terminent pas. Les erreurs réseau pendant le polling sont ignorées silencieusement — le poll suivant réessaiera.

L'offcanvas de preview

Quand le polling retourne completed, le controller construit un panneau latéral Bootstrap 5 qui affiche pour chaque champ la valeur actuelle du formulaire et la suggestion IA côte à côte :

JavaScript
#openOffcanvas(form, suggestions) {
    const fields = [
        {
            key: "seoTitle",
            label: "Titre SEO",
            current: form.querySelector('input[name$="[seo_title]"]')?.value || "",
            suggested: suggestions.seoTitle || "",
        },
        {
            key: "seoDescription",
            label: "Description SEO",
            current: form.querySelector('textarea[name$="[seo_description]"]')?.value || "",
            suggested: suggestions.seoDescription || "",
        },
        {
            key: "keywords",
            label: "Mots-clés",
            current: this.#getCurrentKeywords(form),
            suggested: suggestions.keywords.join(", "),
        },
    ];

    // Construction des cartes actuel/suggéré avec boutons Accepter/Ignorer
    // ...
    this.#offcanvasInstance = new bootstrap.Offcanvas(this.offcanvasTarget);
    this.#offcanvasInstance.show();
}

Chaque carte offre deux actions. "Accepter" injecte la valeur dans le champ du formulaire et marque la carte visuellement. "Ignorer" la grise sans modifier le formulaire.

Application des valeurs au formulaire

JavaScript
#applyField(fieldKey) {
    const form = document.querySelector("form[name]");

    switch (fieldKey) {
        case "seoTitle": {
            const input = form.querySelector('input[name$="[seo_title]"]');
            input.value = this.#pendingSuggestions.seoTitle;
            input.dispatchEvent(new Event("input", { bubbles: true }));
            input.dispatchEvent(new Event("change", { bubbles: true }));
            break;
        }
        case "seoDescription": { /* idem avec textarea */ }
        case "keywords": {
            this.#applyKeywords(form, this.#pendingSuggestions.keywords);
            break;
        }
    }
}

Le dispatch d'événements input et change après chaque modification est nécessaire pour déclencher la synchronisation avec les Live Components, notamment le score SEO affiché en temps réel. Sans ces événements, le formulaire est mis à jour mais les composants réactifs ne le détectent pas.

Intégration de l'onglet Score SEO

EasyAdmin ne permet d'ajouter que des FormField dans ses onglets. Pour injecter un Live Component (le score SEO temps réel) dans un onglet dédié, un contournement est nécessaire.

Côté PHP, un onglet avec un placeholder non mappé :

PHP
yield FormField::addTab('Score SEO')->setIcon('fa fa-chart-line');
yield HiddenField::new('seoScorePlaceholder')
    ->setFormTypeOptions(['mapped' => false, 'required' => false]);

Côté JavaScript, un controller Stimulus détecte le tab pane (EasyAdmin dérive l'ID du label : "Score SEO" → #tab-score-seo), vide le placeholder, et y déplace le Live Component :

JavaScript
export default class extends Controller {
    connect() {
        const pane = document.getElementById("tab-score-seo");
        if (!pane) return;

        const row = pane.querySelector(".row");
        if (row) row.innerHTML = "";

        const wrapper = document.createElement("div");
        wrapper.className = "col-12 p-3";
        while (this.element.firstChild) {
            wrapper.appendChild(this.element.firstChild);
        }
        (row || pane).appendChild(wrapper);
        this.element.remove();
    }
}

Limites

Pertinence du modèle. Le modèle gemini-2.5-flash est optimisé pour la rapidité. Les titres SEO générés respectent les contraintes de longueur imposées par le prompt, mais peuvent produire des formulations génériques sur des sujets très spécialisés. La preview champ par champ atténue ce risque : le rédacteur garde le contrôle sur chaque valeur.

Polling vs événements serveur. Le polling toutes les 2 secondes est un compromis de complexité. Du Server-Sent Events (via Mercure, par exemple) réduirait la latence perçue, mais ajouterait une couche d'infrastructure pour un gain marginal sur des jobs de 2 à 10 secondes.

TTL du cache. Un résultat non consulté dans les 10 minutes est perdu. En pratique, le frontend poll immédiatement après le dispatch. Le risque concerne les cas où l'utilisateur quitte la page avant la fin du traitement.

Stabilité du bundle. Symfony AI est en version 0.3. Les abstractions MessageBag, Message::forSystem() et le paramètre response_format peuvent évoluer entre les versions mineures.

Le mot de la fin

L'implémentation sépare les responsabilités : l'endpoint HTTP dispatche et retourne un identifiant ; le handler Messenger appelle Gemini et stocke le résultat ; le cache Redis sert de pont entre le worker et le frontend ; le controller Stimulus gère le polling et construit l'interface de preview.

Le response_format de Symfony AI permet de travailler avec un DTO PHP typé plutôt qu'avec du JSON brut — c'est le point d'intégration le plus intéressant du bundle pour ce type de fonctionnalité.

Poursuivre la lecture

Sélectionné avec soin pour vous.

DX

Historique de versions dans EasyAdmin : capturer, comparer et restaurer chaque modification

Implémentez un historique de versions dans EasyAdmin : capture Doctrine automatique, snapshots JSON, diff mot-à-mot avec jfcherng/php-diff et LiveComponent.

13 min de lecture
UX

AssetMapper : le frontend pour ceux qui détestent (vraiment) le frontend

Oubliez Webpack ! AssetMapper utilise les importmap natifs pour gérer vos assets (JS, CSS) dans Symfony. Zéro build, zéro Node.js.

9 min de lecture
PHP

Design patterns en PHP : les héros de l'ombre

Découvrez l'impact réel des Design Patterns en PHP et Symfony. Ces solutions éprouvées structurent et optimisent votre code au quotidien. Simplifiez votre développement!

5 min de lecture