DEV Community

Cover image for Claude Sonnet 4.5 Retires Nov 30: Put Your PHP App's LLM Behind a Port
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Claude Sonnet 4.5 Retires Nov 30: Put Your PHP App's LLM Behind a Port


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[0] 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.

What a model retirement does to your call sites

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 port lives in the domain

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.

A hexagonal domain core with a single socket on its edge, two different plugs lined up to fit it
One socket in the domain. Any adapter that fits the shape can plug in.

The Anthropic adapter absorbs the churn

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.

A fake you can trust, and the wiring

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.

Two cartridges, one ornate and cloud-shaped and one plain, being tested against the same template gauge
The contract test is the gauge. Both adapters get measured against it.

The contract test both adapters must pass

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.

What the boundary costs

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.


If this was useful

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)