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
// config/bundles.php
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
// 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) :
// 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.
// 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.
#[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) :
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
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 :
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 :
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
final readonly class GenerateSeoMessage
{
public function __construct(
public string $jobId,
public string $title,
public string $content,
public ?string $category = null,
) {}
}
#[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)
#[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é." }
#[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 :
{% 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
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 :
#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
#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é :
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 :
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é.