Depuis le 22 octobre 2024, claude-3-5-haiku-20241022 est disponible via l'API Anthropic. À 0,80 $ le million de tokens en entrée et 4 $ en sortie, ce modèle change radicalement le calcul économique de l'IA dans un backend PHP : classifier un ticket de support, extraire des entités nommées ou tagger du contenu en masse devient viable sans exploser son budget ni sortir un SDK Python de nulle part. Dans cet article, on construit de zéro un pipeline Symfony 7.2 qui classifie des tickets et en extrait un JSON structuré via l'API Anthropic — avec Guzzle pour les appels HTTP, le mécanisme de tools pour forcer un schéma JSON strict, un décorateur de retry exponentiel pour gérer les erreurs 429 et 529, et Symfony Messenger pour absorber les pics de charge sans perdre aucune requête.
Ce que Claude 3.5 Haiku change pour les backends PHP
Avant Haiku, utiliser l'API Anthropic en production PHP impliquait un arbitrage douloureux. Claude 3.5 Sonnet est excellent, mais son tarif de 3 $ le million de tokens en entrée le réserve aux tâches à haute valeur ajoutée. Claude 3 Haiku était moins cher, mais moins précis sur les instructions structurées complexes. Claude 3.5 Haiku comble ce vide en combinant la qualité de compréhension de Claude 3.5 Sonnet sur les tâches courtes et bien délimitées avec un prix cinq fois inférieur. Pour un CTO gérant une plateforme SaaS B2B, c'est le passage d'un POC coûteux à une feature de production rentable dès le premier mois.
- Latence : réponse médiane inférieure à 1,5 seconde pour un prompt de classification typique (moins de 200 tokens en entrée)
- Coût : 0,80 $ / million de tokens en entrée — 5× moins cher que Claude 3.5 Sonnet
- Fenêtre de contexte : 200 000 tokens, suffisant pour des tickets longs avec historique d'échanges inclus
- Structured outputs : support natif du mécanisme tools avec input_schema JSON Schema — sortie JSON garantie par le protocole
- Aucun SDK requis : API REST pure, un client Guzzle et trois headers suffisent
Un client Anthropic minimal avec Guzzle
L'API Anthropic expose un endpoint REST classique sur https://api.anthropic.com/v1/messages. On installe Guzzle 7, on définit une interface pour pouvoir décorer le client sans couplage fort, puis on crée le client concret avec une seule responsabilité : sérialiser le payload et désérialiser la réponse. La logique de retry, de construction de prompt et de parsing métier vit dans des services dédiés — c'est ce découplage qui rend l'ensemble testable et évolutif.
composer require guzzlehttp/guzzle<?php
// src/Ai/AnthropicClientInterface.php
declare(strict_types=1);
namespace App\Ai;
interface AnthropicClientInterface
{
/**
* @param array<int, array<string, mixed>> $tools
* @return array<string, mixed>
*/
public function complete(string $prompt, array $tools = [], int $maxTokens = 1024): array;
}<?php
// src/Ai/AnthropicClient.php
declare(strict_types=1);
namespace App\Ai;
use GuzzleHttp\Client;
final class AnthropicClient implements AnthropicClientInterface
{
private const string API_URL = 'https://api.anthropic.com/v1/messages';
private const string MODEL = 'claude-3-5-haiku-20241022';
private const string API_VERSION = '2023-06-01';
public function __construct(
private readonly Client $httpClient,
#[\SensitiveParameter]
private readonly string $apiKey,
) {}
public function complete(string $prompt, array $tools = [], int $maxTokens = 1024): array
{
$payload = [
'model' => self::MODEL,
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
];
if ($tools !== []) {
$payload['tools'] = $tools;
$payload['tool_choice'] = ['type' => 'any'];
}
$response = $this->httpClient->post(self::API_URL, [
'headers' => [
'x-api-key' => $this->apiKey,
'anthropic-version' => self::API_VERSION,
'content-type' => 'application/json',
],
'json' => $payload,
]);
return json_decode(
(string) $response->getBody(),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
}
}L'attribut #[\SensitiveParameter] (PHP 8.2+) empêche la clé API d'apparaître dans les stack traces et les logs d'erreur — un réflexe à systématiser sur toute credential injectée dans un constructeur. Le header anthropic-version: 2023-06-01 est obligatoire : sans lui, l'API retourne une erreur 400. On passe tool_choice: {type: "any"} quand des outils sont fournis, ce qui contraint le modèle à en invoquer au moins un au lieu de répondre en texte libre.
JSON structuré garanti avec le mécanisme de tools
L'API Anthropic ne dispose pas d'un « JSON mode » au sens strict. La façon idiomatique d'obtenir une sortie JSON validée par un schéma est le mécanisme de tools : on déclare un outil fictif dont l'input_schema est notre JSON Schema cible, tool_choice: any fait le reste, et le modèle est contraint de produire un objet conforme avant de terminer. Pas de json_decode() fragile sur du texte libre — la structure est garantie par le protocole. On commence par modéliser le résultat attendu sous forme de DTO PHP immutable, ce qui impose un typage fort dans toute l'application en aval.
<?php
// src/Ai/TicketClassification.php
declare(strict_types=1);
namespace App\Ai;
final readonly class TicketClassification
{
public function __construct(
public string $category, // billing | technical | feature_request | other
public string $priority, // low | medium | high | critical
public string $sentiment, // positive | neutral | negative | angry
public string $summary,
public bool $requiresHuman,
) {}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self(
category: $data['category'],
priority: $data['priority'],
sentiment: $data['sentiment'],
summary: $data['summary'],
requiresHuman: (bool) $data['requires_human'],
);
}
}Le pipeline de classification complet
Le service TicketClassifier assemble tout : déclaration du schéma outil, construction du prompt système, appel API, extraction du bloc tool_use dans la réponse et hydratation du DTO. Il type-hinte sur AnthropicClientInterface — le client concret injecté en production sera le décorateur de retry présenté à la section suivante. Le JSON Schema est déclaré directement en PHP sous forme de tableau associatif : pas de fichier JSON externe, pas de bibliothèque de validation tierce.
<?php
// src/Ai/TicketClassifier.php
declare(strict_types=1);
namespace App\Ai;
final readonly class TicketClassifier
{
private const string TOOL_NAME = 'classify_ticket';
public function __construct(
private AnthropicClientInterface $client,
) {}
public function classify(string $ticketBody): TicketClassification
{
$tools = [[
'name' => self::TOOL_NAME,
'description' => 'Classifie un ticket de support et en extrait les métadonnées clés.',
'input_schema' => [
'type' => 'object',
'properties' => [
'category' => [
'type' => 'string',
'enum' => ['billing', 'technical', 'feature_request', 'other'],
'description' => 'Catégorie principale du ticket.',
],
'priority' => [
'type' => 'string',
'enum' => ['low', 'medium', 'high', 'critical'],
'description' => 'Priorité estimée. Réserve critical aux pannes totales ou pertes de données.',
],
'sentiment' => [
'type' => 'string',
'enum' => ['positive', 'neutral', 'negative', 'angry'],
'description' => 'Tonalité dominante du message.',
],
'summary' => [
'type' => 'string',
'description' => 'Résumé en une phrase courte du problème signalé.',
],
'requires_human' => [
'type' => 'boolean',
'description' => 'True si le ticket nécessite une intervention humaine urgente.',
],
],
'required' => ['category', 'priority', 'sentiment', 'summary', 'requires_human'],
],
]];
$prompt = <<<PROMPT
Tu es un agent de triage de tickets de support client.
Utilise l'outil classify_ticket pour analyser le ticket ci-dessous.
Sois précis sur la priorité : réserve "critical" aux pannes totales ou pertes de données.
--- TICKET ---
{$ticketBody}
--- FIN TICKET ---
PROMPT;
$response = $this->client->complete(
prompt: $prompt,
tools: $tools,
maxTokens: 256,
);
$toolUse = null;
foreach ($response['content'] as $block) {
if ($block['type'] === 'tool_use' && $block['name'] === self::TOOL_NAME) {
$toolUse = $block;
break;
}
}
if ($toolUse === null) {
throw new \RuntimeException(sprintf(
'Le modèle n\'a pas invoqué l\'outil "%s". stop_reason: %s',
self::TOOL_NAME,
$response['stop_reason'] ?? 'unknown',
));
}
return TicketClassification::fromArray($toolUse['input']);
}
}Le bloc tool_use dans $response['content'] contient un champ input qui est déjà un tableau PHP associatif — Guzzle et json_decode() ont fait le travail. Le stop_reason: "tool_use" dans la réponse confirme que le modèle a terminé en invoquant l'outil plutôt qu'en générant du texte libre. Si cette assertion échoue en production, c'est un signal que le prompt a dérivé ou que le schéma tools a été mal transmis — deux cas que des tests d'intégration capturent facilement.
Votre équipe utilise Claude Code ?
Découvrir le workshop →Retry exponentiel sur les erreurs 429 et 529
L'API Anthropic retourne deux codes d'erreur transitoires qui nécessitent un retry : 429 Too Many Requests quand on dépasse le rate limit du tier (requêtes par minute ou tokens par minute), et 529 API Overloaded quand les serveurs sont saturés. Les deux se résolvent en attendant. On implémente un décorateur RetryingAnthropicClient qui encapsule le client de base via le patron Decorator — composable, testable séparément, et totalement transparent pour les services qui dépendent de AnthropicClientInterface.
<?php
// src/Ai/RetryingAnthropicClient.php
declare(strict_types=1);
namespace App\Ai;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use Psr\Log\LoggerInterface;
final class RetryingAnthropicClient implements AnthropicClientInterface
{
private const int MAX_RETRIES = 5;
private const int BASE_DELAY_MS = 500;
/** @var list<int> */
private const array RETRYABLE_CODES = [429, 529];
public function __construct(
private readonly AnthropicClient $inner,
private readonly LoggerInterface $logger,
) {}
public function complete(string $prompt, array $tools = [], int $maxTokens = 1024): array
{
$attempt = 0;
while (true) {
try {
return $this->inner->complete($prompt, $tools, $maxTokens);
} catch (ClientException | ServerException $e) {
$statusCode = $e->getResponse()->getStatusCode();
if (!\in_array($statusCode, self::RETRYABLE_CODES, strict: true)
|| $attempt >= self::MAX_RETRIES
) {
throw $e;
}
// Respecte le header Retry-After si l'API l'envoie (valeur en secondes)
$retryAfter = $e->getResponse()->getHeaderLine('retry-after');
$delayMs = ($retryAfter !== '' && ctype_digit($retryAfter))
? (int) $retryAfter * 1000
: self::BASE_DELAY_MS * (2 ** $attempt) + random_int(0, 200);
$this->logger->warning('Anthropic API — retry programmé', [
'attempt' => $attempt + 1,
'delay_ms' => $delayMs,
'status' => $statusCode,
]);
usleep($delayMs * 1_000);
++$attempt;
}
}
}
}Le jitter random_int(0, 200) ajouté au délai de base évite que plusieurs workers partis en retry simultanément frappent l'API exactement au même instant — ce phénomène dit thundering herd aggraverait précisément le 429 qu'on cherche à résoudre. Le header retry-after est prioritaire quand Anthropic l'envoie : la valeur en secondes reflète l'état réel du rate limiter côté serveur, il serait contre-productif de l'ignorer au profit d'un délai calculé localement.
Découpler la classification avec Symfony Messenger
Appeler l'API Anthropic de façon synchrone dans un contrôleur bloque la réponse HTTP pendant 1 à 3 secondes et expose l'application aux timeouts lors de pics de charge. Symfony Messenger résout les deux problèmes : on dispatche un message léger depuis le contrôleur, un worker asynchrone l'exécute en arrière-plan, et les tentatives de retry en cas d'échec sont gérées nativement par le bus. Aucune requête ne se perd, même si l'API Anthropic est temporairement indisponible.
<?php
// src/Message/ClassifyTicketMessage.php
declare(strict_types=1);
namespace App\Message;
final readonly class ClassifyTicketMessage
{
public function __construct(
public int $ticketId,
public string $body,
) {}
}<?php
// src/MessageHandler/ClassifyTicketHandler.php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Ai\TicketClassifier;
use App\Message\ClassifyTicketMessage;
use App\Repository\TicketRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class ClassifyTicketHandler
{
public function __construct(
private TicketClassifier $classifier,
private TicketRepository $tickets,
) {}
public function __invoke(ClassifyTicketMessage $message): void
{
$ticket = $this->tickets->findOrFail($message->ticketId);
$classification = $this->classifier->classify($message->body);
$ticket->applyClassification($classification);
$this->tickets->save($ticket, flush: true);
}
}L'attribut #[AsMessageHandler] suffit pour l'auto-configuration — Symfony détecte et enregistre le handler automatiquement. Pour dispatcher le message depuis un contrôleur ou un service, on injecte MessageBusInterface et on appelle $bus->dispatch(new ClassifyTicketMessage($ticket->getId(), $ticket->getBody())). Le handler et le classifier sont découplés du transport : on peut basculer d'une queue Redis à une queue RabbitMQ sans toucher une ligne de logique métier.
Configuration des services
Deux fichiers de configuration suffisent pour brancher l'ensemble. Dans services.yaml, on déclare l'alias qui fait pointer AnthropicClientInterface vers le décorateur de retry, et on injecte la clé API depuis les variables d'environnement. Dans messenger.yaml, on route le message vers le transport asynchrone et on configure la stratégie de retry au niveau du bus — distincte du retry applicatif géré par RetryingAnthropicClient, qui couvre les erreurs 429/529 à l'intérieur d'un même traitement.
# config/services.yaml (extrait)
services:
App\Ai\AnthropicClient:
arguments:
$apiKey: '%env(ANTHROPIC_API_KEY)%'
App\Ai\RetryingAnthropicClient:
arguments:
$inner: '@App\Ai\AnthropicClient'
App\Ai\AnthropicClientInterface:
alias: App\Ai\RetryingAnthropicClient# config/packages/messenger.yaml (extrait)
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 2000
multiplier: 2
routing:
App\Message\ClassifyTicketMessage: asyncLa variable MESSENGER_TRANSPORT_DSN accepte indifféremment redis://localhost:6379/messages, amqp://... ou doctrine://default pour les équipes qui préfèrent rester sur PostgreSQL en début de projet. En développement local, sync:// exécute les messages de façon synchrone sans démarrer de worker, ce qui simplifie le debug sans modifier le code.
Ce qu'on a construit et ce qui vient ensuite
Le pipeline repose sur quatre responsabilités clairement séparées : AnthropicClient pour l'I/O HTTP brut, RetryingAnthropicClient pour la résilience, TicketClassifier pour la logique de prompt et de parsing, et ClassifyTicketHandler pour l'orchestration asynchrone. Chaque couche est testable isolément — le client avec un mock Guzzle, le classifier avec un faux client qui retourne un tableau statique, le handler avec un faux repository. La prochaine étape naturelle est d'étendre le schéma JSON pour extraire des entités nommées (noms de produit, numéros de commande, adresses e-mail) ou d'implémenter un pipeline multi-étapes où la classification oriente vers un second prompt de réponse automatique — le tout sans toucher à l'infrastructure déjà en place.
Votre équipe utilise Claude Code ?
Workshop intensif : votre équipe opérationnelle en 1 jour.