Comment intégrer l'IA dans une application Symfony ?
On intègre l'IA dans une application Symfony comme n'importe quel service externe : un client HTTP configuré, une classe de service à l'interface claire, un traitement en arrière-plan pour le travail lent, et la sécurité, la journalisation et les tests habituels. Symfony fournit déjà toutes les briques. HttpClient dialogue avec l'API OpenAI ou Claude, Messenger exécute les appels longs dans des workers, StreamedResponse diffuse les réponses, Doctrine DBAL interroge pgvector et le composant RateLimiter plafonne l'usage par utilisateur.
Symfony est notre stack back end principale, et ce guide reflète la façon dont nous intégrons des fonctionnalités IA dans des produits Symfony. Si vous voulez qu'une équipe le fasse dans votre base de code, découvrez nos services de développement Symfony ou, pour du code plus ancien ou sans framework, nos services de développement PHP.
Le plan en bref :
- Un HttpClient scopé par fournisseur, avec les clés issues des variables d'environnement.
- Une seule interface de service pour « interroger le modèle », afin de pouvoir changer de fournisseur.
- Des messages et handlers Messenger pour chaque appel non interactif.
- Un endpoint en streaming pour le chat.
- pgvector pour la recherche dans vos données, filtrée par tenant et par permissions.
- Des limites de débit par utilisateur et par tenant, et une table de journalisation des tokens et des coûts.
Quelles fonctionnalités IA conviennent à un produit Symfony ?
Les fonctionnalités IA qui conviennent le mieux à un produit Symfony sont celles qui s'appuient sur des règles métier que vous avez déjà : elles lisent vos entités, respectent vos voters et réécrivent les résultats via vos services. Les produits Symfony sont souvent des back-offices, des ERP, des marketplaces et des plateformes SaaS avec de nombreux rôles, précisément là où ces fonctionnalités font gagner le plus de temps.
| Fonctionnalité | Fonctionnement dans Symfony | Première version typique |
|---|---|---|
| Résumés et brouillons | Handler Messenger, résultat stocké sur l'entité | Résumés de tickets, de commandes ou de rapports relus par l'équipe |
| Classification et routage | Handler Messenger à la création ou à l'import | Demandes entrantes étiquetées et affectées à une file |
| Extraction de documents | Upload, puis un handler qui valide la sortie structurée | Champs de factures ou de formulaires extraits vers un écran de validation |
| Questions sur vos données | Endpoint en streaming avec recherche dans pgvector | Un assistant sur les articles d'aide ou les procédures internes |
| Agent pour un processus | Des outils qui appellent vos services existants, avec étapes de validation | Préparer un rapport hebdomadaire ou trier une boîte mail partagée |
Commencez par l'une d'elles, mesurée sur des exemples réels, avant de construire un assistant généraliste. Chaque ligne réutilise la même plomberie décrite ci-dessous : la deuxième fonctionnalité coûte donc bien moins cher que la première.
Comment appeler l'API OpenAI ou Claude avec Symfony HttpClient ?
Définissez un client scopé par fournisseur dans la configuration, pour que l'URL de base, les en-têtes, le timeout et la politique de nouvelles tentatives soient au même endroit, puis injectez-le par son nom.
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
anthropic.client:
base_uri: 'https://api.anthropic.com'
headers:
x-api-key: '%env(ANTHROPIC_API_KEY)%'
anthropic-version: '2023-06-01'
timeout: 120
retry_failed:
max_retries: 2
openai.client:
base_uri: 'https://api.openai.com'
auth_bearer: '%env(OPENAI_API_KEY)%'
timeout: 120
retry_failed:
max_retries: 2
retry_failed relance les limites de débit et les erreurs serveur avec un délai croissant. Symfony autowire un client scopé par son nom en camelCase : HttpClientInterface $anthropicClient reçoit donc le premier.
namespace App\Ai;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ClaudeModel implements LanguageModel
{
public function __construct(
private HttpClientInterface $anthropicClient,
#[Autowire(env: 'ANTHROPIC_MODEL')] private string $model,
) {}
public function complete(string $system, string $user, int $maxTokens = 4096): ModelResult
{
$data = $this->anthropicClient->request('POST', '/v1/messages', [
'json' => [
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $system,
'messages' => [['role' => 'user', 'content' => $user]],
],
])->toArray(); // lève une exception sur les 4xx et 5xx
$text = implode('', array_column(
array_filter($data['content'], fn (array $b) => $b['type'] === 'text'),
'text'
));
return new ModelResult($text, $data['usage']['input_tokens'], $data['usage']['output_tokens']);
}
}
Une classe OpenAiModel implémente la même interface LanguageModel avec un POST vers /v1/responses. Liez l'interface à une implémentation dans services.yaml, ou choisissez par fonctionnalité avec un petit routeur. C'est cette interface qui fait d'un futur changement de fournisseur, ou d'un test A/B entre modèles, une simple modification de configuration. Notre comparatif Claude ou OpenAI : quelle API choisir explique quand chaque fournisseur convient.
Anthropic publie un SDK PHP officiel, et des clients PHP communautaires existent pour OpenAI. Ce sont de bons choix ; mais avec le HttpClient de Symfony, vous n'en avez souvent pas besoin pour une ou deux fonctionnalités, et vous gardez le profiler, les nouvelles tentatives et les outils de mock de Symfony.
Comment exécuter les appels LLM en asynchrone avec Symfony Messenger ?
Envoyez un message pour chaque tâche IA que l'utilisateur ne suit pas en direct, et laissez un worker dédié la traiter avec une stratégie de nouvelles tentatives. Les requêtes web restent rapides, et les limites de débit du fournisseur deviennent des délais plutôt que des erreurs.
// src/Message/SummarizeDocument.php
final class SummarizeDocument
{
public function __construct(public readonly int $documentId) {}
}
// src/MessageHandler/SummarizeDocumentHandler.php
#[AsMessageHandler]
final class SummarizeDocumentHandler
{
public function __construct(
private LanguageModel $model,
private DocumentRepository $documents,
private PromptLibrary $prompts,
private EntityManagerInterface $em,
) {}
public function __invoke(SummarizeDocument $message): void
{
$document = $this->documents->find($message->documentId)
?? throw new UnrecoverableMessageHandlingException('Document deleted');
$result = $this->model->complete(
$this->prompts->get('document-summary', version: 4),
$document->getText(),
maxTokens: 1024,
);
$document->setSummary($result->text);
$this->em->flush();
}
}
# config/packages/messenger.yaml
framework:
messenger:
transports:
ai:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options: { queue_name: ai }
retry_strategy:
max_retries: 3
delay: 5000
multiplier: 4
routing:
App\Message\SummarizeDocument: ai
Faites tourner le transport ai avec ses propres workers (messenger:consume ai --time-limit=3600) pour qu'un retard de travail IA ne bloque jamais les e-mails ou les paiements. Levez UnrecoverableMessageHandlingException pour les erreurs qu'une nouvelle tentative ne corrigera pas, comme un enregistrement supprimé ou une réponse 400 : le message part alors dans le transport d'échec au lieu d'être relancé pour rien.
Comment diffuser les réponses IA en streaming dans Symfony ?
Utilisez EventSourceHttpClient pour lire les Server-Sent Events du fournisseur et une StreamedResponse pour ne transmettre que le texte au navigateur. Les premiers mots s'affichent en une seconde environ.
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpFoundation\StreamedResponse;
#[Route('/assistant/stream', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function stream(Request $request, HttpClientInterface $anthropicClient): StreamedResponse
{
$question = (string) $request->getPayload()->get('q');
$model = $this->getParameter('app.anthropic_model');
$client = new EventSourceHttpClient($anthropicClient);
return new StreamedResponse(function () use ($client, $question, $model) {
$source = $client->request('POST', '/v1/messages', ['json' => [
'model' => $model,
'max_tokens' => 4096,
'stream' => true,
'messages' => [['role' => 'user', 'content' => $question]],
]]);
foreach ($client->stream($source) as $chunk) {
if (!$chunk instanceof ServerSentEvent) {
continue;
}
$event = json_decode($chunk->getData(), true);
if (($event['type'] ?? null) === 'content_block_delta'
&& ($event['delta']['type'] ?? null) === 'text_delta') {
echo 'data: '.json_encode(['text' => $event['delta']['text']])."\n\n";
flush();
}
}
echo "event: done\ndata: {}\n\n";
flush();
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
Les versions récentes de Symfony ajoutent une classe de réponse dédiée aux flux d'événements : consultez la documentation de votre version. Sous PHP-FPM, chaque flux ouvert occupe un worker pendant toute sa durée : dimensionnez le pool pour les chats simultanés, ou faites tourner l'application sur FrankenPHP avec le composant Runtime. Si vous utilisez déjà Mercure pour le temps réel, comme dans l'ERP sur mesure pour une société américaine de maintenance aéronautique, un worker peut aussi publier les réponses partielles sur un topic Mercure au lieu de garder une connexion HTTP ouverte.
Comment stocker les embeddings avec Doctrine et pgvector ?
Ajoutez l'extension pgvector à PostgreSQL, créez la colonne vectorielle et son index dans une migration Doctrine en SQL brut, et interrogez via la connexion DBAL avec les filtres de tenant dans la même requête.
// dans une migration Doctrine
$this->addSql('CREATE EXTENSION IF NOT EXISTS vector');
$this->addSql('ALTER TABLE chunk ADD embedding vector(1536)'); // à aligner sur votre modèle d'embeddings
$this->addSql('CREATE INDEX chunk_embedding_idx ON chunk USING hnsw (embedding vector_cosine_ops)');
final class ChunkSearch
{
public function __construct(private Connection $connection) {}
/** @param float[] $embedding */
public function nearest(int $tenantId, array $embedding, int $limit = 8): array
{
return $this->connection->fetchAllAssociative(
'SELECT id, document_id, content
FROM chunk
WHERE tenant_id = :tenant
ORDER BY embedding <=> CAST(:q AS vector)
LIMIT '.(int) $limit,
['tenant' => $tenantId, 'q' => '['.implode(',', $embedding).']']
);
}
}
Les embeddings proviennent d'un modèle d'embeddings, comme l'endpoint embeddings d'OpenAI ; Anthropic ne propose pas ses propres modèles d'embeddings, les projets basés sur Claude l'associent donc à OpenAI ou à un autre fournisseur. Générez les embeddings dans un handler Messenger quand les documents changent, et conservez un hash du contenu pour ne pas recalculer les textes inchangés. Les entités Doctrine n'ont même pas besoin de connaître la colonne vectorielle. Pour le découpage, la recherche hybride, le reranking et l'évaluation, lisez notre guide de mise en œuvre du RAG.
Comment limiter le débit des fonctionnalités IA dans Symfony ?
Utilisez le composant RateLimiter avec un limiteur par utilisateur et par tenant, et vérifiez-le avant chaque appel au modèle. Un modèle qui tourne en boucle, ou un utilisateur qui automatise votre chat, doit buter sur une limite, pas sur votre facture.
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
ai_per_user:
policy: 'token_bucket'
limit: 30
rate: { interval: '10 minutes', amount: 30 }
public function __construct(private RateLimiterFactory $aiPerUserLimiter) {}
public function ask(User $user, string $question): ModelResult
{
$limit = $this->aiPerUserLimiter->create((string) $user->getId())->consume(1);
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
// ... appel au modèle
}
Combinez les limites avec les leviers de coût valables pour toute stack : cache de prompt pour les instructions longues et stables, modèle plus petit pour les étapes simples, API batch pour les traitements nocturnes, et une table de journalisation avec feature, prompt_version, model, les compteurs de tokens, la latence et le coût de chaque appel. Un budget mensuel par tenant peut être vérifié dans cette table avant chaque appel.
Comment tester et sécuriser les fonctionnalités IA dans Symfony ?
Testez votre code avec MockHttpClient et des réponses enregistrées du fournisseur, et testez la qualité séparément avec un jeu d'évaluation. Considérez tout ce que le modèle lit comme une entrée non fiable.
$client = new MockHttpClient([
new JsonMockResponse([
'content' => [['type' => 'text', 'text' => 'Invoice 1042 is overdue by 12 days.']],
'usage' => ['input_tokens' => 300, 'output_tokens' => 14],
]),
]);
Déclarez le mock comme client scopé dans l'environnement de test, puis vérifiez ce que votre handler a enregistré, ce qu'il a journalisé et comment il gère une réponse 429 ou un timeout. Pour la qualité, gardez 50 à 200 entrées réelles avec les sorties attendues et lancez-les à chaque changement de prompt ou de modèle.
Les règles de sécurité que nous appliquons à chaque fonctionnalité IA sous Symfony :
- Clés uniquement côté serveur, injectées depuis les variables d'environnement ou les secrets Symfony.
- Voters avant la recherche. Seuls les documents que l'utilisateur peut lire sont interrogés.
- Le texte non fiable reste une donnée. Les saisies utilisateur et les documents récupérés sont clairement délimités dans le prompt et jamais traités comme des instructions.
- Validation de la sortie structurée avec le composant Validator avant qu'elle n'atteigne la base.
- Validation humaine des actions. Tout ce qui envoie, paie ou supprime exige une confirmation humaine.
Que recommandons-nous aux équipes Symfony ?
Notre verdict pour la plupart des produits Symfony : des HttpClient scopés derrière votre propre interface LanguageModel, Messenger pour chaque appel non interactif, un endpoint en streaming pour le chat, pgvector via DBAL et le rate limiter dès le premier jour. Suivez l'initiative IA de Symfony et adoptez ses composants lorsqu'ils seront stables et résoudront un problème que vous avez réellement. Cette configuration n'utilise que des composants que votre équipe connaît déjà, et c'est la principale raison pour laquelle elle tient en production.
Notre propre SaaS IA, AI Resume Master, tourne sur PHP et Symfony avec React, Python et l'API OpenAI ; il a été construit en trois mois environ et a atteint 50 000 utilisateurs actifs mensuels. Si votre application est sous Laravel, vous retrouverez les mêmes principes dans notre guide pour intégrer OpenAI ou Claude dans une application Laravel.
Prochaine étape
Si vous voulez qu'une équipe Symfony ajoute une fonctionnalité IA à votre produit, découvrez nos services de développement Symfony. Chez nous, les fonctionnalités IA dans un produit existant démarrent à 10 000 USD, avec un devis forfaitaire après un court appel de cadrage. Contactez-nous en décrivant la fonctionnalité envisagée et les données dont elle a besoin.