Claude API PHP Integration Tutorial
PHP doesn't have an official Anthropic SDK, but that's not a blocker — the Claude API is plain HTTPS with JSON, and PHP has had solid HTTP clients for over a decade. This tutorial shows you how to send your first request, handle streaming responses, deal with errors properly, and structure the integration so it's maintainable in a real Laravel or vanilla PHP project.
If you're searching for "claude api php integration tutorial," you most likely want one of two things: a quick working code snippet to paste into a controller, or a fuller picture of how to build this correctly (auth headers, retries, streaming, cost tracking). This article covers both, starting with the minimal version and building up.
What you need before starting
- PHP 8.0+ (for named arguments and union types used in examples)
- An API key — either an Anthropic key (
sk-ant-...) or a SubToAPI key (sub_live_...) if you're already a Claude subscriber and want a standard API key without a separate Anthropic billing account ext-curlenabled, or Composer withguzzlehttp/guzzleinstalled
The minimal cURL request
Here's the smallest working PHP integration, using raw cURL so there are no dependencies:
<?php
$apiKey = getenv('ANTHROPIC_API_KEY');
$payload = [
'model' => 'claude-opus-4-20250514',
'max_tokens' => 1024,
'messages' => [
['role' => 'user', 'content' => 'Explain dependency injection in two sentences.']
],
];
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01',
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($httpCode !== 200) {
throw new RuntimeException('Claude API error: ' . ($data['error']['message'] ?? $response));
}
echo $data['content'][0]['text'];
This works, but writing raw cURL for every endpoint gets repetitive. In a real app you'll want a small wrapper class.
A reusable client class
<?php
final class ClaudeClient
{
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl = 'https://api.anthropic.com/v1',
private readonly string $version = '2023-06-01',
) {}
public function sendMessage(array $messages, string $model = 'claude-opus-4-20250514', int $maxTokens = 1024): array
{
$ch = curl_init($this->baseUrl . '/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: ' . $this->version,
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'max_tokens' => $maxTokens,
'messages' => $messages,
]),
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new RuntimeException("cURL error: $error");
}
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400) {
throw new RuntimeException("API error ($status): " . ($data['error']['message'] ?? 'unknown'));
}
return $data;
}
}
Usage is now:
$client = new ClaudeClient(getenv('ANTHROPIC_API_KEY'));
$result = $client->sendMessage([
['role' => 'user', 'content' => 'List three uses of PHP enums.']
]);
echo $result['content'][0]['text'];
Using Guzzle instead of raw cURL
If your project already depends on Guzzle, it reduces boilerplate and gives you timeouts, retries, and middleware for free:
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$client = new Client(['base_uri' => 'https://api.anthropic.com/v1/']);
try {
$response = $client->post('messages', [
'headers' => [
'x-api-key' => getenv('ANTHROPIC_API_KEY'),
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
],
'json' => [
'model' => 'claude-opus-4-20250514',
'max_tokens' => 1024,
'messages' => [['role' => 'user', 'content' => 'Hello Claude']],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
echo $data['content'][0]['text'];
} catch (RequestException $e) {
error_log($e->getMessage());
}
Handling streaming responses in PHP
Claude's streaming uses server-sent events. PHP's cURL supports this via CURLOPT_WRITEFUNCTION, which fires on every chunk received instead of waiting for the full body:
$buffer = '';
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('ANTHROPIC_API_KEY'),
'anthropic-version: 2023-06-01',
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'claude-opus-4-20250514',
'max_tokens' => 1024,
'stream' => true,
'messages' => [['role' => 'user', 'content' => 'Write a short poem about PHP.']],
]),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer) {
$buffer .= $chunk;
foreach (explode("\n\n", $buffer) as $line) {
if (str_starts_with($line, 'data:')) {
$json = json_decode(trim(substr($line, 5)), true);
if (isset($json['delta']['text'])) {
echo $json['delta']['text'];
flush();
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
PHP's buffering behavior in web servers (especially with Nginx) can delay output, so test with php -S locally first, and disable output_buffering and gzip for the endpoint if you're streaming to a browser.
Error handling and retries
Claude's API returns standard HTTP status codes: 400 for malformed requests, 401 for bad auth, 429 for rate limits, 529 for overloaded servers. A production wrapper should retry on 429/529 with exponential backoff:
function sendWithRetry(ClaudeClient $client, array $messages, int $maxRetries = 3): array
{
$attempt = 0;
while (true) {
try {
return $client->sendMessage($messages);
} catch (RuntimeException $e) {
$attempt++;
if ($attempt >= $maxRetries || !str_contains($e->getMessage(), '429')) {
throw $e;
}
sleep(2 ** $attempt);
}
}
}
Simplifying auth and key management
Raw Anthropic API keys require creating and managing a separate Anthropic billing setup, and there's no built-in per-feature key scoping or team-level usage dashboard. If your PHP app is one of several internal tools calling Claude, or you want usage metadata and multiple application keys without managing Anthropic billing directly, SubToAPI issues standard sub_live_... keys that work with the same request/response shape shown above — you just change the base URL to https://api.subtoapi.app/v1/messages and the Authorization: Bearer $SUBTOAPI_KEY header. Check the quickstart and messages docs for the exact payload format, and streaming docs if you're building the SSE handler above against SubToAPI instead of Anthropic directly.
Putting it together in Laravel
If you're in Laravel, wrap the client in a service class and bind it in a service provider:
$this->app->singleton(ClaudeClient::class, fn () => new ClaudeClient(
config('services.claude.key')
));
Then inject ClaudeClient into controllers or jobs like any other service. Keep the API key in .env, never in source control, and consider queueing long-running Claude calls as Laravel jobs rather than blocking web requests.
questions
Does Anthropic provide an official PHP SDK? No. Anthropic maintains official SDKs for Python and TypeScript/JavaScript. PHP integrations use HTTP clients like cURL or Guzzle directly against the REST API, as shown above.
Can I stream Claude responses to a browser from PHP? Yes, using CURLOPT_WRITEFUNCTION to process server-sent event chunks as they arrive and echoing them with flush(). Disable output buffering on your web server for the streaming route.
What's the difference between calling Anthropic directly and using SubToAPI from PHP? The request format is nearly identical — same JSON body, same headers pattern. SubToAPI adds application-scoped sub_live_... keys, usage metadata, and team seats on top of your existing Claude access; see pricing and signup for details.