mozex/anthropic-php
Community-maintained PHP SDK for the Anthropic API: send messages, stream responses, call tools, use extended thinking, web search, code execution, batches, and files. Works with any PSR-18 HTTP client, plus a Laravel wrapper. Full docs at mozex.dev.
Decide wrapper vs. SDK first. This is the framework-agnostic SDK. If the whole app talks to Claude, install mozex/anthropic-laravel instead — it gives you container bindings, config-based setup, and a facade on top of this package. Install the plain SDK when you want explicit control, when only a subset of code touches Anthropic, or when you're writing a package/library that shouldn't depend on Laravel.
composer require mozex/anthropic-php
Requires PHP 8.2+. php-http/discovery will pick up whatever PSR-18 client you already have (Guzzle via Laravel's HTTP stack is the common case). If nothing is installed, add one explicitly — don't rely on discovery in CI, it can resolve differently than your local machine.
Where to look first. Skip the README for anything beyond hello-world; go to https://mozex.dev/docs/anthropic-php/v1 and read, in this order: Configuration → Messages → Error Handling → Testing. Those four pages cover 90% of day-to-day work. The README's TOC maps the rest.
Bind one configured client in a service provider. Don't scatter Anthropic::client() calls through controllers.
// app/Providers/AnthropicServiceProvider.php
use Anthropic\Anthropic;
use Anthropic\Client;
public function register(): void
{
$this->app->singleton(Client::class, fn () => Anthropic::factory()
->withApiKey(config('services.anthropic.key'))
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120, // thinking + long completions need headroom
'connect_timeout' => 10,
]))
->make());
}
// config/services.php
'anthropic' => [
'key' => env('ANTHROPIC_API_KEY'),
],
First real use case: a queued "draft reply" job. The pattern you'll reuse forever — a job that takes a domain object, builds the payload, and stores the output.
final class DraftSupportReply implements ShouldQueue
{
use Queueable;
public int $timeout = 180;
public int $tries = 3;
public function __construct(public int $ticketId) {}
public function handle(Client $client): void
{
$ticket = Ticket::findOrFail($this->ticketId);
$response = $client->messages()->create([
'model' => 'claude-sonnet-4-6',
'max_tokens' => 2048,
'system' => 'You draft concise, friendly support replies. No greeting boilerplate.',
'messages' => [
['role' => 'user', 'content' => $ticket->body],
],
]);
$ticket->update([
'draft_reply' => $response->content[0]->text,
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
]);
}
}
Persist usage from day one. It's the only way to answer "what is this feature costing us?" three months later, and retrofitting it is annoying.
Controllers and jobs should not know about messages()->create() payload shapes. One class per capability keeps prompts versioned and testable.
final class TicketSummarizer
{
public function __construct(private Client $client) {}
public function summarize(Ticket $ticket): string
{
$response = $this->client->messages()->create([
'model' => 'claude-sonnet-4-6',
'max_tokens' => 512,
'system' => [['type' => 'text', 'text' => PromptLibrary::SUMMARIZE, 'cache_control' => ['type' => 'ephemeral']]],
'messages' => [['role' => 'user', 'content' => $ticket->body]],
]);
return collect($response->content)
->firstWhere('type', 'text')
?->text ?? '';
}
}
Cache-control on a long, stable system prompt is the single highest-leverage cost optimization; read usage->cacheReadInputTokens / cacheCreationInputTokens to confirm it's hitting.
createStreamed() returns an iterable of typed events — you are responsible for framing them. Use SSE with an explicit Content-Type and disable output buffering/gzip for that route.
Route::get('/tickets/{ticket}/stream', function (Ticket $ticket, Client $client) {
return response()->stream(function () use ($ticket, $client) {
$stream = $client->messages()->createStreamed([
'model' => 'claude-sonnet-4-6',
'max_tokens' => 2048,
'messages' => [['role' => 'user', 'content' => $ticket->body]],
]);
foreach ($stream as $event) {
if ($event->type === 'content_block_delta' && $event->delta->type === 'text_delta') {
echo 'data: ' . json_encode(['t' => $event->delta->text]) . "\n\n";
@ob_flush(); flush();
}
}
echo "data: [DONE]\n\n";
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
})->middleware('auth');
Because streams bypass the normal request lifecycle, the 120s Guzzle timeout above applies to the whole stream — set it explicitly rather than inheriting a 30s default that truncates mid-sentence.
Tool use is a multi-turn conversation. Factor it into a loop that owns the message array, executes the tool, and re-sends.
final class ToolRunner
{
public function __construct(private Client $client, private ToolRegistry $registry) {}
public function run(array $messages, array $tools, int $maxRounds = 5): array
{
for ($i = 0; $i < $maxRounds; $i++) {
$response = $this->client->messages()->create([
'model' => 'claude-sonnet-4-6',
'max_tokens' => 2048,
'tools' => $tools,
'messages' => $messages,
]);
$messages[] = ['role' => 'assistant', 'content' => $response->content];
if ($response->stopReason !== 'tool_use') {
return $messages;
}
$results = [];
foreach ($response->content as $block) {
if ($block->type !== 'tool_use') {
continue;
}
$results[] = [
'type' => 'tool_result',
'tool_use_id' => $block->id,
'content' => json_encode($this->registry->call($block->name, $block->input)),
];
}
$messages[] = ['role' => 'user', 'content' => $results];
}
throw new RuntimeException('Tool loop did not converge.');
}
}
Always bound the loop. A model that keeps calling a failing tool will happily burn your budget otherwise. Validate input against your own rules before executing — input_schema is a suggestion to the model, not a contract enforced by the API.
Nightly re-summarization, backfills, bulk classification: Message Batches cost a fraction of sync requests and this SDK exposes the whole lifecycle (create, retrieve, list, cancel, delete, stream results).
// dispatch
$batch = $client->messageBatches()->create([
'requests' => $tickets->map(fn ($t) => [
'custom_id' => "ticket-{$t->id}",
'params' => [/* same shape as messages()->create() */],
])->all(),
]);
DB::table('anthropic_batches')->insert([
'batch_id' => $batch->id,
'status' => $batch->processingStatus,
'created_at' => now(),
]);
// separate scheduled command, polls and ingests
foreach ($client->messageBatches()->results($batchId) as $result) {
$ticketId = (int) str_replace('ticket-', '', $result->customId);
if ($result->result->type === 'succeeded') {
Ticket::whereKey($ticketId)->update(['summary' => $result->result->message->content[0]->text]);
}
}
Store custom_id mappings in your database, not in memory — batches complete asynchronously (typically well under 24h), so the creating process is long gone.
For user-supplied documents, call countTokens first and reject or truncate client-side rather than eating a 400 or a large bill.
ClientFake, not HTTP mocksuse Anthropic\Testing\ClientFake;
use Anthropic\Resources\Messages;
use Anthropic\Responses\Messages\CreateResponse;
public function test_it_drafts_a_reply(): void
{
$this->app->instance(\Anthropic\Client::class, new ClientFake([
CreateResponse::fake([
'content' => [['type' => 'text', 'text' => 'We shipped it Tuesday.']],
]),
]));
$this->post("/tickets/{$this->ticket->id}/draft")->assertOk();
$fake = app(\Anthropic\Client::class);
$fake->assertSent(Messages::class, fn (string $method, array $params) => $params['model'] === 'claude-sonnet-4-6');
}
This is the reason to prefer this SDK over hand-rolling Http::post() calls: assertions run against your actual payloads, so a prompt or model change is a test failure rather than a silent regression. Add at least one assertion per test that the model name and prompt version are what you expect.
max_tokens is required and is not a target. Set it to your real ceiling; too-low silently truncates output with stopReason: 'max_tokens'. Log stopReason and alert on it, or you'll ship half-answers.$response->content[0] is text for a simple prompt but thinking when thinking is enabled, and can be a tool_use block. Always filter by type — never index positionally in production code.readonly typed objects, not arrays. $response['usage']['input_tokens'] will fail. Use $response->usage->inputTokens. Same for stream events. Budget a short adjustment period; IDE autocompletion makes this painless within a day.RateLimitException (HTTP 429) extends ErrorException and preserves the API's message; every ErrorException and UnserializableResponse exposes the PSR-7 response via $e->response, so you can read headers on failure. In jobs, catch RateLimitException specifically and use $response->meta()->retryAfter-style rate-limit info rather than a flat backoff.for loop. $client->messages()->create() inside a retry loop on a request path will multiply your latency and your bill. Let $tries + backoff() do it, and make the job idempotent (check for an existing draft before regenerating).$response->meta() on every response. It gives request/token limits and reset times with essentially no cost. Feed it into a small metrics table or log it — it's the difference between "we got rate limited" and "we knew we were at 94% of our token limit."max_tokens routinely exceeds PHP's max_execution_time and default HTTP timeouts. Long generations belong in a job with $timeout > the HTTP timeout, and both should be explicit.client bound server-side; the key should never appear in a VITE_*/NEXT_PUBLIC_* variable. This is the most common mistake when copying quick-start snippets."mozex/anthropic-php": "^1.3") and read the changelog on upgrade; 1.3.0 notably moved previously-generic rate-limit headers into dedicated inputTokenLimit / outputTokenLimit properties, so code reading the custom bucket for those will quietly stop finding them.TransporterContract::addHeader() for runtime header injection (beta flags, per-tenant routing), withBaseUri() for proxies/gateways, and OverrideStrategy to control how fake() merges overrides when building test responses.How can I help you explore Laravel packages today?