Claude Sonnet 4.5 wird am 30. November eingestellt
Anthropic schaltet das Modell Claude Sonnet 4.5 zum 30. November ab. Entwickler sollten ihre Anwendungen rechtzeitig auf ein Nachfolgemodell umstellen.

claude-sonnet-4-5-20250929 stellt am 30. November 2026 die Beantwortung von Anfragen ein. Anthropic kündigte die Abkündigung am 30. September an, und nach dem Stichtag schlägt jede Anfrage an das Modell fehl. Als Ersatz wird claude-sonnet-5-5 empfohlen.
Ein Austausch des Modell-Strings klingt nach einer Einzeiler-Änderung. Öffnet man den Migrationsleitfaden zu Sonnet 5.5, ist sie es nicht. Eine nicht standardmäßige Temperatur liefert nun einen 400-Fehler. Ein Assistant Prefill liefert einen 400-Fehler. Antworten können mit Thinking-Blöcken beginnen, sodass Code, der den Inhalt ausliest, bricht. Derselbe Text erzeugt rund 30 % mehr Tokens.
Man durchsuche also seine Laravel- oder Symfony-Anwendung nach messages->create. Lautet die Antwort: ein Controller, zwei Queued Jobs, ein Console Command und eine Service-Klasse, jede mit ihrer eigenen temperature: 0 und ihrem eigenen { Prefill, um JSON zu erzwingen, dann stehen fünf Migrationen vor der Deadline an. Und danach wiederholt man das Ganze beim nächsten Modell.
Dafür gibt es ein besseres Muster, und PHP 8.4 macht es kurz. Ein TextClassifier-Port in der Domain, ein Anthropic-Adapter, ein deterministischer Fake und ein Contract Test, den beide Adapter bestehen müssen.
Modellwechsel sind das normale Tempo dieser Abhängigkeit. Anthropics Deprecation-Tabelle listet neun Abkündigungsankündigungen zwischen Januar 2025 und September 2026 auf, mit einer angegebenen Mindestfrist von 60 Tagen Vorlauf für öffentlich veröffentlichte Modelle.
Auch die SDKs bewegen sich. Das offizielle Anthropic PHP SDK ist in der Beta und bei Major-Version 0, und die Dokumentation stellt unmissverständlich klar, dass sich "APIs jederzeit ändern können". Version 0.57.0 erschien am 9. Oktober 2026. Das Community-Paket openai-php/client ist ebenfalls vor 1.0: v0.21.0 erschien am 17. September 2026.
Selbst die Framework-Abstraktionen wechseln schnell. Laravels First-Party-AI-SDK erreichte 1.0 mit Breaking Changes, darunter die Umbenennung von promptTokens und completionTokens in inputTokens und outputTokens. Symfony AI näherte sich im Juni noch seiner 1.0-Version.
Der Datenbanktreiber ändert sich alle paar Jahre. Der LLM-Stack ändert sich alle paar Monate. Das macht diese Abhängigkeit zu der Stelle, an der eine hexagonale Grenze den größten Nutzen bringt.
Die Domain will nur eine Frage beantwortet haben: In welche Queue gehört dieses Ticket? Sie interessiert sich nicht für Tokens, Modelle oder JSON-Schemas. Der Port spricht daher ausschließlich die Sprache der Domain und nichts anderes.
<?php // src/Domain/Triage/Category.php declare ( strict_types = 1 ); namespace App\Domain\Triage; enum Category: string { case Billing = 'billing'; case Bug = 'bug'; case Account = 'account'; case Other = 'other'; } Enter fullscreen mode Exit fullscreen mode <?php // src/Domain/Triage/TextClassifier.php declare ( strict_types = 1 ); namespace App\Domain\Triage; interface TextClassifier { /** * @throws \InvalidArgumentException on blank text * @throws ClassificationFailed when no valid label comes back */ public function classify ( string $text ): Category; } Enter fullscreen mode Exit fullscreen mode <?php // src/Domain/Triage/ClassificationFailed.php declare ( strict_types = 1 ); namespace App\Domain\Triage; final class ClassificationFailed extends \RuntimeException {} Enter fullscreen mode Exit fullscreen mode Der Use Case hängt ausschließlich am Interface und an nichts aus vendor/:
<?php // src/Domain/Triage/RouteTicket.php declare ( strict_types = 1 ); namespace App\Domain\Triage; final readonly class RouteTicket { public function __construct ( private TextClassifier $classifier, ) {} public function __invoke ( string $body ): string { return match ( $this -> classifier -> classify ( $body )) { Category:: Billing => 'finance', Category:: Bug => 'engineering', Category:: Account => 'identity', Category:: Other => 'triage', }; } } Enter fullscreen mode Exit fullscreen mode Das match deckt jeden Enum-Case ab. Fügt man einen neuen Category-Case hinzu, meldet PHPStan den unbehandelten Zweig; zur Laufzeit wirft PHP stattdessen eine UnhandledMatchError, statt das Ticket stillschweigend falsch einzusortieren. Man beachte, was der Port nicht enthält: keinen Modellnamen, keinen Prompt, keine Temperatur.
Das sind Details des Adapters, und genau diese Details haben sich gerade geändert.
Eine einzige Schnittstelle in der Domain. Jeder Adapter, der in die Form passt, kann angeschlossen werden.
Hier ist der Adapter, geschrieben nach den Regeln von Sonnet 5.5. Er folgt dem Migrationsleitfaden: keine Sampling-Parameter, kein Prefill, Thinking im Vorfeld abgeschaltet, structured outputs über output_config.format und der Textblock wird über den Typ gefunden.
<?php // src/Infra/Ai/AnthropicClassifier.php declare ( strict_types = 1 ); namespace App\Infra\Ai; use Anthropic\Client; use Anthropic\Core\Exceptions\APIException; use Anthropic\Messages\JSONOutputFormat; use Anthropic\Messages\OutputConfig; use App\Domain\Triage\Category; use App\Domain\Triage\ClassificationFailed; use App\Domain\Triage\TextClassifier; final readonly class AnthropicClassifier implements TextClassifier { private const string SYSTEM = 'Classify the support ticket '. 'into exactly one category.'; public function __construct ( private Client $client, private string $model, ) {} public function classify ( string $text ): Category { if ( trim ( $text ) === '' ) { throw new \InvalidArgumentException ( 'Blank ticket.' ); } try { $message = $this -> client -> messages -> create ( maxTokens: 1024, messages: [[ 'role' => 'user', 'content' => $text ]], model: $this -> model, system: self:: SYSTEM, thinking: [ 'type' => 'between_tools' ], outputConfig: OutputConfig:: with ( format: JSONOutputFormat:: with ( schema: self:: schema (), ), ), ); } catch ( APIException $e ) { throw new ClassificationFailed ( $e -> getMessage (), previous: $e, ); } $block = array_find ( $message -> content, static fn ( $b ): bool => $b -> type === 'text', ); $data = json_decode ( $block?-> text?? '', true ); $label = strtolower (( string ) ( $data [ 'category' ]?? '' )); return Category:: tryFrom ( $label )?? throw new ClassificationFailed ( "Bad label: { $label } " ); } Enter fullscreen mode Exit fullscreen mode Das Schema baut sein Enum aus den Category-Cases auf, sodass beide nicht auseinanderlaufen können. Es wird in derselben Klasse abgeschlossen:
private static function schema (): array { return [ 'type' => 'object', 'properties' => [ 'category' => [ 'type' => 'string', 'enum' => array_column ( Category:: cases (), 'value' ), ]], 'required' => [ 'category' ], 'additionalProperties' => false, ]; } } Enter fullscreen mode Exit fullscreen mode Einige Zeilen hier existieren allein wegen des Herstellers. Genau deshalb liegen sie im Adapter.
thinking: between_tools schaltet das Thinking im Vorfeld ab, was eine Ein-Wort-Klassifikation nicht braucht. Der Migrationsleitfaden bezeichnet es als niedrigste Thinking-Stufe bei Sonnet 5.5, wo die Option disabled inzwischen einen 400-Fehler liefert. Ohne Tools enthält die Antwort nur Text, sodass maxTokens: 1024 nicht mit einem Thinking-Budget geteilt wird.
array_find ist neu in PHP 8.4. Die eigene SDK-Dokumentation verwendet es, um den Textblock auszuwählen. Bei Sonnet 5.5 möchte man das ohnehin: Thinking ist standardmäßig aktiviert, wenn das Feld weggelassen wird, und dann ist der erste Block möglicherweise kein Text. Das Auslesen über den Typ hält den Adapter korrekt, falls jemand die Thinking-Zeile später löscht.
strtolower ist vorhanden, weil die Dokumentation zu structured outputs warnt, dass Claude die Groß- und Kleinschreibung von Enum-Werten nicht garantiert. Kleingeschriebene Enum-Backing-Werte plus ein Vergleich in Kleinschreibung machen daraus ein Non-Problem.
Das Catch übersetzt jede SDK-Exception in die ClassificationFailed der Domain. Ohne dieses würde Anthropic\Core\Exceptions\RateLimitException in die Controller durchsickern, und beim nächsten Hersteller käme dessen Exception-Baum neben sie.
Wechselt man später zu Amazon Bedrock, wo laut Migrationsleitfaden structured outputs für Sonnet 5.5 nicht verfügbar sind, ist das eine Entscheidung auf Adapter-Ebene. Die Domain erfährt davon nie.
Der zweite Adapter ist ein Keyword-Klassifikator. Er ist deterministisch, kostenlos und schnell genug für jeden Unit Test, den man ausführt. Er nutzt zudem array_find_key aus PHP 8.4.
<?php // src/Infra/Ai/KeywordClassifier.php declare ( strict_types = 1 ); namespace App\Infra\Ai; use App\Domain\Triage\Category; use App\Domain\Triage\TextClassifier; final class KeywordClassifier implements TextClassifier { private const array RULES = [ 'refund' => Category:: Billing, 'charged' => Category:: Billing, 'invoice' => Category:: Billing, 'error' => Category:: Bug, 'crash' => Category:: Bug, 'password' => Category:: Account, 'log in' => Category:: Account, ]; public function classify ( string $text ): Category { if ( trim ( $text ) === '' ) { throw new \InvalidArgumentException ( 'Blank ticket.' ); } $lower = mb_strtolower ( $text ); $word = array_find_key ( self:: RULES, static fn ( $_, $k ): bool => str_contains ( $lower, $k ), ); return $word === null? Category:: Other: self:: RULES [ $word ]; } } Enter fullscreen mode Exit fullscreen mode Der Composition Root ist die einzige Stelle, die weiß, welcher Adapter läuft. In Laravel ist das ein Service Provider:
// app/Providers/AppServiceProvider.php public function register (): void { $this -> app -> singleton ( TextClassifier:: class, fn () => new AnthropicClassifier ( new Client ( apiKey: config ( 'services.anthropic.key' )), config ( 'services.anthropic.classifier_model' ), ), ); } // config/services.php 'anthropic' => [ 'key' => env ( 'ANTHROPIC_API_KEY' ), 'classifier_model' => env ( 'CLASSIFIER_MODEL', 'claude-sonnet-5-5', ), ], In einem Feature-Test tauscht $this->app->instance(TextClassifier::class, new KeywordClassifier()) den Fake ein. In Symfony ist dieselbe Idee ein Alias in der config/services.yaml, der für die Test-Umgebung überschrieben wird:
services: _defaults: autowire: true Anthropic\Client: arguments: $apiKey: ' %env(ANTHROPIC_API_KEY)%' App\Infra\Ai\AnthropicClassifier: arguments: $model: ' %env(CLASSIFIER_MODEL)%' App\Domain\Triage\TextClassifier: alias: App\Infra\Ai\AnthropicClassifier when@test: services: App\Infra\Ai\KeywordClassifier: ~ App\Domain\Triage\TextClassifier: alias: App\Infra\Ai\KeywordClassifier Die Modell-ID ist nun eine Umgebungsvariable. Der Wechsel weg von Sonnet 4.5 ist eine Konfigurationsänderung plus alles, was der Adapter benötigt, und der Adapter ist eine einzige Datei.
Der Contract-Test ist das Messinstrument. Beide Adapter werden daran gemessen.
Ein Fake ist nur dann nützlich, wenn er sich in den relevanten Fällen wie das echte System verhält. Andernfalls sind Ihre Unit-Tests grün gegen einen Klassifikator, der gar nicht existiert. Die Lösung ist ein einziger abstrakter PHPUnit-Testfall, den jeder Adapter erweitert:
<?php // tests/Contract/TextClassifierContract.php declare ( strict_types = 1 ); namespace Tests\Contract; use App\Domain\Triage\Category; use App\Domain\Triage\TextClassifier; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; abstract class TextClassifierContract extends TestCase { abstract protected function classifier (): TextClassifier; public static function obviousTickets (): array { return [ 'billing' => [ 'I was charged twice this month.', Category:: Billing, ], 'bug' => [ 'The export button throws an error.', Category:: Bug, ], 'account' => [ "I can't log in after resetting my password.", Category:: Account, ], 'other' => [ 'Is a dark mode on your roadmap?', Category:: Other, ], ]; } #[DataProvider('obviousTickets')] public function testObviousTickets ( string $text, Category $expected, ): void { self:: assertSame ( $expected, $this -> classifier () -> classify ( $text ), ); } public function testBlankTextIsRejected (): void { $this -> expectException ( \InvalidArgumentException:: class ); $this -> classifier () -> classify ( ' ' ); } } Zwei kleine Unterklassen binden die Adapter ein. Die Live-Variante überspringt sich selbst, wenn kein Schlüssel gesetzt ist, und liegt in einer eigenen Gruppe, sodass sie nie versehentlich bei jedem Push ausgeführt wird:
<?php // tests/Contract/KeywordClassifierTest.php final class KeywordClassifierTest extends TextClassifierContract { protected function classifier (): TextClassifier { return new KeywordClassifier (); } } <?php // tests/Contract/AnthropicClassifierTest.php #[Group('live')] final class AnthropicClassifierTest extends TextClassifierContract { protected function classifier (): TextClassifier { $key = getenv ( 'ANTHROPIC_API_KEY' ); if ( $key === false || $key === '' ) { self:: markTestSkipped ( 'ANTHROPIC_API_KEY not set.' ); } return new AnthropicClassifier ( new Client ( apiKey: $key ), getenv ( 'CLASSIFIER_MODEL' )?: 'claude-sonnet-5-5', ); } } (Namespace- und use-Zeilen wurden gekürzt; sie entsprechen der abstrakten Klasse plus den Imports für den Adapter und das Group-Attribut.)
Ihre normale Testsuite läuft mit vendor/bin/phpunit --exclude-group live und berührt nie das Netzwerk. Bevor Sie das Modell in der Produktion umstellen, führen Sie die Live-Gruppe gegen den Kandidaten aus:
CLASSIFIER_MODEL = claude-sonnet-5-5 \ vendor/bin/phpunit --group live Das ist Ihr Migrationstest. Wenn das neue Modell einen 400 zurückgibt, weil etwas im Adapter veraltet ist, oder Passwort-Resets plötzlich als "other" einstuft, finden Sie das über vier Testfälle heraus und nicht über die Support-Warteschlange. Wenn der Fake und der Live-Adapter bei einem neuen Fall auseinanderlaufen, haben Sie eine Lücke im Fake gefunden, und Sie ergänzen das Schlüsselwort.
Es kostet Dateien. Eine Handvoll kleiner Dateien anstelle eines einzigen $client->messages->create in einem Controller. Für ein Einmal-Skript lohnt sich das nicht.
Außerdem begrenzt es, was die Domain verlangen kann. Dieser Port bildet die Single-Label-Klassifikation ab. Streaming-Chat, Tool Calling oder Multi-Turn-Agents benötigen einen anderen Port mit einer anderen Form, und dessen Design ist echte Arbeit. Dehnen Sie TextClassifier nicht darauf aus.
Und es konkurriert nicht mit laravel/ai oder der Platform-Komponente von Symfony AI. Diese legen viele Provider hinter eine API, und der Symfony-Beitrag nennt das "eine architektonische Grenze". Es ist eine Grenze zwischen Ihrem Code und den Anbietern, die vom Framework kontrolliert wird. Ihre Domain sollte sie trotzdem nicht importieren. Schreiben Sie einen SymfonyAiClassifier-Adapter, der die Platform nutzt, und der Contract-Test zeigt Ihnen, ob er sich wie die anderen beiden verhält.
Der 30. November ist weniger als sieben Wochen entfernt. Suchen Sie nach messages->create, zählen Sie die Aufrufstellen und hängen Sie die erste in dieser Woche hinter den Port.
Ports, Adapter, Composition Roots und Contract-Tests bilden das Rückgrat von Decoupled PHP. Das Buch wendet sie auf Datenbanken, Queues, Zahlungen und Framework-Upgrades an, und dieselben Regeln lassen sich auf die LLM-Schicht übertragen. Wenn Sie dieses Refactoring mit einem AI-Coding-Agent durchführen, ist Hermes IDE die IDE, die ich genau für diesen Workflow baue; bewerten Sie die Erwähnung vor diesem Hintergrund.
Erhältlich als Kindle-, Paperback- und Hardcover-Ausgabe. Die Ausgaben auf Englisch, Deutsch und Japanisch sind bereits erhältlich, Portugiesisch und Spanisch folgen in Kürze.
Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.
Original (Englisch) anzeigen
claude-sonnet-4-5-20250929 stops answering on November 30, 2026. Anthropic announced the retirement on September 30, and after the cutoff every request to it fails. The recommended replacement is claude-sonnet-5-5.
Swapping the model string sounds like a one-line change. Open the Sonnet 5.5 migration guide and it isn't. A non-default temperature now returns a 400. An assistant prefill returns a 400. Responses can start with thinking blocks, so code that reads content breaks. The same text produces about 30% more tokens.
Now grep your Laravel or Symfony app for messages->create. If the answer is a controller, two queued jobs, a console command and a service class, each with its own temperature: 0 and its own { prefill to force JSON, you have five migrations to do before the deadline. Then you'll do it again for the next model.
There's a better shape for this, and PHP 8.4 makes it short. A TextClassifier port in the domain, an Anthropic adapter, a deterministic fake, and one contract test both adapters have to pass.
Model churn is the normal pace of this dependency. Anthropic's deprecation table lists nine retirement announcements between January 2025 and September 2026, with a stated minimum of 60 days' notice for publicly released models.
The SDKs move too. The official Anthropic PHP SDK is in beta and on major version 0, and its docs say plainly that "APIs might change at any time." Version 0.57.0 shipped on October 9, 2026. The community openai-php/client is also pre-1.0: v0.21.0 shipped on September 17, 2026.
Even the framework abstractions churn. Laravel's first-party AI SDK hit 1.0 with breaking changes, including promptTokens and completionTokens renamed to inputTokens and outputTokens. Symfony AI was still "approaching its 1.0 release" in June.
Your database driver changes every few years. Your LLM stack changes every few months. That makes it the dependency where a hexagonal boundary earns the most.
The domain wants an answer to one question: which queue does this ticket go to? It doesn't care about tokens, models, or JSON schemas. So the port speaks the domain's language and nothing else.
<?php // src/Domain/Triage/Category.php declare ( strict_types = 1 ); namespace App\Domain\Triage; enum Category: string { case Billing = 'billing'; case Bug = 'bug'; case Account = 'account'; case Other = 'other'; } Enter fullscreen mode Exit fullscreen mode <?php // src/Domain/Triage/TextClassifier.php declare ( strict_types = 1 ); namespace App\Domain\Triage; interface TextClassifier { /** * @throws \InvalidArgumentException on blank text * @throws ClassificationFailed when no valid label comes back */ public function classify ( string $text ): Category; } Enter fullscreen mode Exit fullscreen mode <?php // src/Domain/Triage/ClassificationFailed.php declare ( strict_types = 1 ); namespace App\Domain\Triage; final class ClassificationFailed extends \RuntimeException {} Enter fullscreen mode Exit fullscreen mode The use case depends on the interface and nothing from vendor/:
<?php // src/Domain/Triage/RouteTicket.php declare ( strict_types = 1 ); namespace App\Domain\Triage; final readonly class RouteTicket { public function __construct ( private TextClassifier $classifier, ) {} public function __invoke ( string $body ): string { return match ( $this -> classifier -> classify ( $body )) { Category:: Billing => 'finance', Category:: Bug => 'engineering', Category:: Account => 'identity', Category:: Other => 'triage', }; } } Enter fullscreen mode Exit fullscreen mode The match covers every enum case. Add a Category case and PHPStan reports the unhandled arm; at runtime PHP throws UnhandledMatchError instead of quietly misrouting the ticket. Note what the port doesn't contain: no model name, no prompt, no temperature.
Those are adapter details, and they're the details that just changed.
One socket in the domain. Any adapter that fits the shape can plug in.
Here's the adapter, written against Sonnet 5.5's rules. It follows the migration guide: no sampling parameters, no prefill, up-front thinking off, structured outputs via output_config.format, and the text block found by type.
<?php // src/Infra/Ai/AnthropicClassifier.php declare ( strict_types = 1 ); namespace App\Infra\Ai; use Anthropic\Client; use Anthropic\Core\Exceptions\APIException; use Anthropic\Messages\JSONOutputFormat; use Anthropic\Messages\OutputConfig; use App\Domain\Triage\Category; use App\Domain\Triage\ClassificationFailed; use App\Domain\Triage\TextClassifier; final readonly class AnthropicClassifier implements TextClassifier { private const string SYSTEM = 'Classify the support ticket '. 'into exactly one category.'; public function __construct ( private Client $client, private string $model, ) {} public function classify ( string $text ): Category { if ( trim ( $text ) === '' ) { throw new \InvalidArgumentException ( 'Blank ticket.' ); } try { $message = $this -> client -> messages -> create ( maxTokens: 1024, messages: [[ 'role' => 'user', 'content' => $text ]], model: $this -> model, system: self:: SYSTEM, thinking: [ 'type' => 'between_tools' ], outputConfig: OutputConfig:: with ( format: JSONOutputFormat:: with ( schema: self:: schema (), ), ), ); } catch ( APIException $e ) { throw new ClassificationFailed ( $e -> getMessage (), previous: $e, ); } $block = array_find ( $message -> content, static fn ( $b ): bool => $b -> type === 'text', ); $data = json_decode ( $block?-> text?? '', true ); $label = strtolower (( string ) ( $data [ 'category' ]?? '' )); return Category:: tryFrom ( $label )?? throw new ClassificationFailed ( "Bad label: { $label } " ); } Enter fullscreen mode Exit fullscreen mode The schema builds its enum from the Category cases, so the two can't drift. It closes out the same class:
private static function schema (): array { return [ 'type' => 'object', 'properties' => [ 'category' => [ 'type' => 'string', 'enum' => array_column ( Category:: cases (), 'value' ), ]], 'required' => [ 'category' ], 'additionalProperties' => false, ]; } } Enter fullscreen mode Exit fullscreen mode A few lines here exist only because of the vendor. That's why they live in the adapter.
thinking: between_tools turns off up-front thinking, which a one-word classification doesn't need. The migration guide calls it the lowest thinking setting on Sonnet 5.5, where disabled now returns a 400. Without tools, the response contains only text, so maxTokens: 1024 isn't shared with a thinking budget.
array_find is new in PHP 8.4. The SDK's own docs use it to pick the text block. On Sonnet 5.5 you want it anyway: thinking is on by default for requests that omit the field, and then the first block may not be text. Reading by type keeps the adapter correct if someone deletes the thinking line later.
strtolower is there because the structured outputs docs warn that Claude doesn't guarantee the capitalization of enum values. Lowercase enum backing values plus a lowercase compare makes that a non-issue.
The catch translates every SDK exception into the domain's ClassificationFailed. Without it, Anthropic\Core\Exceptions\RateLimitException leaks into your controllers, and the next vendor's exception tree leaks in beside it.
If you later move to Amazon Bedrock, where the migration guide says structured outputs aren't available for Sonnet 5.5, that's an adapter-level decision. The domain never hears about it.
The second adapter is a keyword classifier. It's deterministic, free, and fast enough for every unit test you run. It also uses PHP 8.4's array_find_key.
<?php // src/Infra/Ai/KeywordClassifier.php declare ( strict_types = 1 ); namespace App\Infra\Ai; use App\Domain\Triage\Category; use App\Domain\Triage\TextClassifier; final class KeywordClassifier implements TextClassifier { private const array RULES = [ 'refund' => Category:: Billing, 'charged' => Category:: Billing, 'invoice' => Category:: Billing, 'error' => Category:: Bug, 'crash' => Category:: Bug, 'password' => Category:: Account, 'log in' => Category:: Account, ]; public function classify ( string $text ): Category { if ( trim ( $text ) === '' ) { throw new \InvalidArgumentException ( 'Blank ticket.' ); } $lower = mb_strtolower ( $text ); $word = array_find_key ( self:: RULES, static fn ( $_, $k ): bool => str_contains ( $lower, $k ), ); return $word === null? Category:: Other: self:: RULES [ $word ]; } } Enter fullscreen mode Exit fullscreen mode The composition root is the only place that knows which adapter runs. In Laravel, that's a service provider:
// app/Providers/AppServiceProvider.php public function register (): void { $this -> app -> singleton ( TextClassifier:: class, fn () => new AnthropicClassifier ( new Client ( apiKey: config ( 'services.anthropic.key' )), config ( 'services.anthropic.classifier_model' ), ), ); } Enter fullscreen mode Exit fullscreen mode // config/services.php 'anthropic' => [ 'key' => env ( 'ANTHROPIC_API_KEY' ), 'classifier_model' => env ( 'CLASSIFIER_MODEL', 'claude-sonnet-5-5', ), ], Enter fullscreen mode Exit fullscreen mode In a feature test, $this->app->instance(TextClassifier::class, new KeywordClassifier()) swaps the fake in. In Symfony, the same idea is an alias in config/services.yaml, overridden for the test environment:
services: _defaults: autowire: true Anthropic\Client: arguments: $apiKey: ' %env(ANTHROPIC_API_KEY)%' App\Infra\Ai\AnthropicClassifier: arguments: $model: ' %env(CLASSIFIER_MODEL)%' App\Domain\Triage\TextClassifier: alias: App\Infra\Ai\AnthropicClassifier when@test: services: App\Infra\Ai\KeywordClassifier: ~ App\Domain\Triage\TextClassifier: alias: App\Infra\Ai\KeywordClassifier Enter fullscreen mode Exit fullscreen mode The model ID is now an environment variable. Moving off Sonnet 4.5 is a config change plus whatever the adapter needs, and the adapter is one file.
The contract test is the gauge. Both adapters get measured against it.
A fake is only useful if it behaves like the real thing on the cases you care about. Otherwise your unit tests are green against a classifier that doesn't exist. The fix is one abstract PHPUnit test case that every adapter extends:
<?php // tests/Contract/TextClassifierContract.php declare ( strict_types = 1 ); namespace Tests\Contract; use App\Domain\Triage\Category; use App\Domain\Triage\TextClassifier; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; abstract class TextClassifierContract extends TestCase { abstract protected function classifier (): TextClassifier; public static function obviousTickets (): array { return [ 'billing' => [ 'I was charged twice this month.', Category:: Billing, ], 'bug' => [ 'The export button throws an error.', Category:: Bug, ], 'account' => [ "I can't log in after resetting my password.", Category:: Account, ], 'other' => [ 'Is a dark mode on your roadmap?', Category:: Other, ], ]; } #[DataProvider('obviousTickets')] public function testObviousTickets ( string $text, Category $expected, ): void { self:: assertSame ( $expected, $this -> classifier () -> classify ( $text ), ); } public function testBlankTextIsRejected (): void { $this -> expectException ( \InvalidArgumentException:: class ); $this -> classifier () -> classify ( ' ' ); } } Enter fullscreen mode Exit fullscreen mode Two small subclasses plug the adapters in. The live one skips itself when no key is set and sits in its own group, so it never runs on every push by accident:
<?php // tests/Contract/KeywordClassifierTest.php final class KeywordClassifierTest extends TextClassifierContract { protected function classifier (): TextClassifier { return new KeywordClassifier (); } } Enter fullscreen mode Exit fullscreen mode <?php // tests/Contract/AnthropicClassifierTest.php #[Group('live')] final class AnthropicClassifierTest extends TextClassifierContract { protected function classifier (): TextClassifier { $key = getenv ( 'ANTHROPIC_API_KEY' ); if ( $key === false || $key === '' ) { self:: markTestSkipped ( 'ANTHROPIC_API_KEY not set.' ); } return new AnthropicClassifier ( new Client ( apiKey: $key ), getenv ( 'CLASSIFIER_MODEL' )?: 'claude-sonnet-5-5', ); } } Enter fullscreen mode Exit fullscreen mode (Namespace and use lines trimmed; they match the abstract class plus the adapter and Group attribute imports.)
Your normal suite runs vendor/bin/phpunit --exclude-group live and never touches the network. Before you flip the model in production, run the live group against the candidate:
CLASSIFIER_MODEL = claude-sonnet-5-5 \ vendor/bin/phpunit --group live Enter fullscreen mode Exit fullscreen mode That's your migration test. If the new model returns a 400 because something in the adapter is stale, or starts calling password resets "other", you find out from four test cases instead of from the support queue. When the fake and the live adapter disagree on a new case, you've found a gap in the fake, and you add the keyword.
It costs files. A handful of small ones in place of a single $client->messages->create in a controller. For a one-off script, that's not worth it.
It also caps what the domain can ask for. This port does single-label classification. Streaming chat, tool calling or multi-turn agents need a different port with a different shape, and designing that is real work. Don't stretch TextClassifier to cover it.
And it doesn't compete with laravel/ai or Symfony AI's Platform component. Those put many providers behind one API, and the Symfony post calls that "an architectural boundary." It's a boundary between your code and the vendors, owned by the framework. Your domain still shouldn't import it. Write a SymfonyAiClassifier adapter that uses the Platform, and the contract test tells you whether it behaves like the other two.
November 30 is under seven weeks away. Grep for messages->create, count the call sites, and move the first one behind the port this week.
Ports, adapters, composition roots and contract tests are the backbone of Decoupled PHP. The book applies them to databases, queues, payments and framework upgrades, and the same rules carry over to the LLM layer. If you're doing this refactor with an AI coding agent, Hermes IDE is the IDE I build for that workflow, so weigh the mention with that in mind.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.