De la boîte de chat au registre de workflows : structurer ses appels IA en PHP comme un ingénieur
Quand on intègre l'IA dans une application PHP, le réflexe naturel est d'ouvrir un client HTTP, d'envoyer un prompt et de coller la réponse quelque part dans l'interface. Ça fonctionne pour un prototype. Ça devient ingérable dès que l'on dépasse cinq usages différents.
Un article de Mahmut Gündüzalp publié sur dev.to documente l'architecture qui a émergé après avoir déployé des modules IA sur plus de 200 sites de presse. La leçon principale : l'IA maintenable, ce n'est pas un prompt, c'est un contrat.
Le problème du prompt libre
Une salle de rédaction n'a pas un besoin IA. Elle en a des dizaines, chacun avec sa propre forme d'entrée, son propre format de sortie attendu, et sa propre tolérance à l'erreur :
- Proposer trois titres alternatifs
- Générer un résumé de 280 caractères pour les réseaux sociaux
- Suggérer une catégorie
- Extraire les entités nommées
- Produire un texte alternatif pour l'image principale
- Signaler un risque légal dans le corps de l'article
Si chacun de ces besoins est traité comme un prompt isolé, éparpillé dans les contrôleurs ou les services, on obtient rapidement un code impossible à tester, à monitorer et à faire évoluer. Changer de modèle LLM devient un chantier, et comprendre pourquoi un appel échoue devient une enquête.
La solution : une tâche comme unité de travail
L'architecture proposée repose sur un principe simple : la tâche, pas le prompt, est l'unité réutilisable. Une tâche encapsule trois responsabilités précises :
- Un nom — identifiant stable et lisible (
headline.suggest,image.alt-text) - Un niveau de coût —
cheap,standardoupremiumselon la complexité du modèle requis - Un contrat d'entrée/sortie — la tâche construit les messages à envoyer et garantit la forme de la réponse
interface AiTask
{
public function name(): string; // 'headline.suggest'
public function tier(): string; // 'cheap' | 'standard' | 'premium'
public function build(array $input): array; // messages pour le LLM
}
Chaque implémentation concrète est alors un petit module autonome, testable unitairement sans toucher à l'API LLM. On mocke build(), on vérifie que les messages construits sont corrects, on valide la désérialisation de la réponse — indépendamment du modèle utilisé en production.
Un registre uniforme derrière une interface stable
L'autre décision structurante est de ne pas appeler le LLM directement depuis les services métier. Toutes les tâches passent par un dispatcher central qui connaît le mapping tier → modèle et qui centralise le logging, le retry et la gestion des erreurs.
Ce dispatcher expose une interface unique :
$result = $dispatcher->run('headline.suggest', [
'title' => $article->title,
'body' => $article->body,
'count' => 3,
]);
L'appelant ne sait pas quel modèle a traité la requête. Il ne sait pas si la réponse vient du cache ou d'un appel réseau. Il reçoit un tableau structuré conforme au contrat de la tâche — ou une exception typée si quelque chose a échoué.
Cette séparation des préoccupations a plusieurs conséquences pratiques immédiates :
- Changement de modèle sans régression : modifier le mapping
cheap → gpt-4o-miniencheap → claude-haikune touche aucun code métier. - Observabilité centralisée : latence, tokens consommés, taux d'erreur par tâche — tout est loggé au même endroit.
- Tests déterministes : en injectant un dispatcher de test qui retourne des fixtures, les tests d'intégration ne font aucun appel réseau.
Ce que ça change concrètement en PHP/Symfony
Dans un contexte Symfony, cette architecture se transpose naturellement :
- Les tâches sont des services taggués (
ai.task) autodécouverts par le container. - Le dispatcher est un service unique injecté partout où l'on a besoin d'IA.
- Les contrats d'entrée/sortie peuvent être portés par des DTOs typés avec validation via le composant Validator.
- Le cache de réponses s'appuie sur Symfony Cache avec une TTL configurable par tâche.
#[AsTaggedItem('ai.task')]
final class HeadlineSuggestTask implements AiTask
{
public function name(): string { return 'headline.suggest'; }
public function tier(): string { return 'cheap'; }
public function build(array $input): array
{
return [
['role' => 'system', 'content' => 'Tu es un rédacteur de presse expert en titres accrocheurs.'],
['role' => 'user', 'content' => "Propose {$input['count']} titres pour : {$input['title']}"],
];
}
}
Le registre se construit automatiquement via le tag, sans configuration manuelle. Ajouter une cinquantième tâche revient à créer une classe — rien d'autre ne change.
Conclusion
L'article de Gündüzalp illustre un glissement de posture important : passer du prompting à l'ingénierie logicielle. Ce n'est pas une question de quel modèle choisir ou de comment formuler les instructions. C'est une question de design : interfaces, contrats, séparation des responsabilités.
En PHP et a fortiori en Symfony, les outils pour appliquer ces principes existent déjà — interfaces, injection de dépendances, composants Cache et Validator. Il s'agit simplement de les appliquer aux appels LLM avec la même rigueur qu'on appliquerait à n'importe quel autre système externe.
Une IA bien structurée, c'est une IA qu'on peut monitorer, tester, remplacer et faire évoluer. C'est la condition pour qu'elle reste utilisée au-delà des deux premières semaines.