Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Anthropic Php Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

Wrap the client in a thin domain service

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.

Streaming into a browser response

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: run the loop, don't hand-write it per call

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.

Batches for anything that isn't interactive

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.

Count tokens before you commit to a request

For user-supplied documents, call countTokens first and reject or truncate client-side rather than eating a 400 or a large bill.

Testing: use ClientFake, not HTTP mocks

use 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.

Gotchas and Tips

  • 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.
  • Content is an array of blocks, not a string. $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.
  • Responses are 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.
  • Parameters are passed through as-is — that cuts both ways. A brand-new Anthropic parameter works the day it ships, with zero SDK release. It also means typos and wrong types are rejected by the API, not by the SDK or your static analysis. Consider a small DTO or builder around your most-used payloads so at least your side is checked.
  • Handle errors by class, not by string. 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.
  • Retries belong in the queue, not in a 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).
  • Read $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."
  • Streaming + tool use is the hard combination. Tool inputs arrive as partial JSON deltas that you must concatenate per block index before decoding. If you don't specifically need to stream a tool-using response, use the non-streamed call and stream only the final text to the user.
  • Set timeouts on the HTTP client, not on the model. Extended thinking with large 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.
  • Don't ship the API key to the browser. Any "chat in the UI" feature routes through your controller with a 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.
  • Small ecosystem, active maintainer. Zero dependents and a low star count mean fewer Stack Overflow answers — but the docs site is thorough and versioned, and the changelog is genuinely useful. Pin to a minor ("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.
  • Extension points if you need to go off-road: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator