Claude API Integration with Laravel PHP: A Setup Guide
Integrating Claude into a Laravel application means wiring up HTTP calls to an AI API from a PHP framework that wasn't originally built with LLM streaming or long-running requests in mind. The good news is Laravel's HTTP client, service container, and queue system handle this well once you know the right patterns. This guide walks through a working setup: config, a service class, a controller endpoint, and how to deal with streaming and errors.
There are two ways to approach this. You can call Anthropic's API directly with your own key management, retry logic, and rate limit handling, or you can route requests through a proxy like SubToAPI that gives you an API key, usage metadata, and streaming support without building that infrastructure yourself. Both approaches use the same HTTP request shape in Laravel, so everything below applies regardless of which backend you point at.
Setting Up the Environment
Add your key to .env and expose it through a config file so it's not scattered across the codebase:
// config/services.php
'claude' => [
'key' => env('CLAUDE_API_KEY'),
'base_url' => env('CLAUDE_BASE_URL', 'https://api.anthropic.com/v1'),
'model' => env('CLAUDE_MODEL', 'claude-sonnet-4-20250514'),
],
CLAUDE_API_KEY=sk-ant-xxxx
CLAUDE_BASE_URL=https://api.anthropic.com/v1
If you're using SubToAPI instead, swap the base URL and key for your sub_live_... key from the dashboard, pointed at https://api.subtoapi.app/v1. The request format stays identical since SubToAPI proxies the same messages API shape.
Building a Service Class
Keep the API logic out of your controllers. A dedicated service class makes it testable and reusable across jobs, commands, and controllers:
// app/Services/ClaudeService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ClaudeService
{
public function sendMessage(string $prompt, array $options = []): array
{
$response = Http::withHeaders([
'x-api-key' => config('services.claude.key'),
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(60)
->post(config('services.claude.base_url') . '/messages', [
'model' => $options['model'] ?? config('services.claude.model'),
'max_tokens' => $options['max_tokens'] ?? 1024,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
if ($response->failed()) {
Log::error('Claude API error', ['body' => $response->body()]);
throw new \RuntimeException('Claude API request failed: ' . $response->status());
}
return $response->json();
}
}
If you're calling SubToAPI, the header changes to a standard bearer token, which fits more naturally into Laravel's withToken() helper:
Http::withToken(config('services.claude.key'))
->post(config('services.claude.base_url') . '/messages', $payload);
Registering and Using the Service
Bind it in a service provider or just resolve it via constructor injection, which Laravel handles automatically thanks to auto-wiring:
// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;
use App\Services\ClaudeService;
use Illuminate\Http\Request;
class ChatController extends Controller
{
public function __construct(private ClaudeService $claude) {}
public function reply(Request $request)
{
$validated = $request->validate([
'prompt' => 'required|string|max:4000',
]);
$result = $this->claude->sendMessage($validated['prompt']);
return response()->json([
'reply' => $result['content'][0]['text'] ?? null,
'usage' => $result['usage'] ?? null,
]);
}
}
Add the route:
// routes/api.php
Route::post('/chat', [ChatController::class, 'reply']);
At this point you have a working POST endpoint that accepts a prompt and returns a Claude-generated reply, wrapped in your own JSON contract. See the /docs/messages reference if you're building against SubToAPI and want the exact response shape and field names.
Handling Streaming Responses
Laravel doesn't stream HTTP client responses out of the box the same way Node does, but you can proxy a server-sent event stream to the browser using a streamed response:
public function stream(Request $request)
{
return response()->stream(function () use ($request) {
$response = Http::withToken(config('services.claude.key'))
->withOptions(['stream' => true])
->post(config('services.claude.base_url') . '/messages', [
'model' => config('services.claude.model'),
'max_tokens' => 1024,
'stream' => true,
'messages' => [
['role' => 'user', 'content' => $request->input('prompt')],
],
]);
$body = $response->toPsrResponse()->getBody();
while (!$body->eof()) {
echo $body->read(1024);
ob_flush();
flush();
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
This forwards raw SSE chunks to the frontend as they arrive, which is what you want for a chat UI that renders text token by token. Run this behind php artisan serve with output_buffering = Off in php.ini, or the flush calls won't have any effect. In production behind Nginx, disable buffering on that specific location block too. If you're using SubToAPI, the streaming format is documented at /docs/streaming and works the same way over the proxied endpoint.
Handling Tool Use and Long Requests
If your Laravel app calls Claude with tool definitions — for structured actions like database lookups or calculations — dispatch the actual work as a job rather than blocking the request:
class HandleToolCall implements ShouldQueue
{
public function handle(ClaudeService $claude)
{
$result = $claude->sendMessage($this->prompt, [
'tools' => $this->toolDefinitions,
]);
// parse tool_use blocks, execute, send tool_result back
}
}
This keeps your HTTP workers free and avoids PHP-FPM timeout issues on requests that involve multiple round trips (model calls a tool, you execute it, you send the result back for a final answer). The /docs/tools page covers the request/response shape for tool definitions if you're building this against SubToAPI.
Error Handling and Rate Limits
Wrap calls with retry logic for transient failures, since Laravel's HTTP client supports this natively:
Http::retry(3, 200, throw: false)
->withToken(config('services.claude.key'))
->post($url, $payload);
Check $response->status() for 429 specifically and back off longer than for a generic 5xx. Log the request-id header from every response — it's essential when reporting issues or debugging why a specific call behaved differently.
For teams managing multiple developers or projects, SubToAPI gives each application its own sub_live_... key with per-key usage tracking, so you can see which Laravel service or environment is generating traffic without parsing raw Anthropic logs. Get started at /signup or check /pricing for team seat options.
Questions
Do I need an official Claude PHP SDK? No official SDK exists for PHP. Laravel's built-in HTTP client (Illuminate\Support\Facades\Http) is sufficient — it handles JSON, timeouts, retries, and streaming without extra dependencies.
How do I avoid Laravel request timeouts on long Claude responses? Increase the HTTP client timeout with ->timeout(60) or higher, and for anything involving tool use or multi-step reasoning, move the work to a queued job instead of holding the request open.
Can I use SubToAPI instead of calling Anthropic directly from Laravel? Yes. Swap the base URL to https://api.subtoapi.app/v1 and use your sub_live_... key with withToken(). The request and response format matches the standard messages API, so your service class code doesn't change. See /docs/quickstart for setup details.