What does a production-ready OpenAI or Claude call in PHP need?
A production-ready LLM call in PHP needs more than the ten lines that send a prompt. It needs a single service class that owns the call, an explicit timeout for that feature, retries that actually fire on the errors these APIs return, streaming when a person is waiting, a stable prompt prefix so caching works, and a log line with model, tokens and cost for every request.
This checklist is framework-agnostic: it applies to Symfony, Laravel, Slim or plain PHP. It is written for PHP developers who own an AI feature and for CTOs who review the plan. If you work in a specific framework, our guides on adding OpenAI or Claude to Laravel and adding AI to a Symfony app go deeper into queues, controllers and tests there.
Every version number and default below was checked on 29 October 2026 against Packagist, the providers' documentation and the libraries' source code.
The checklist in short:
- Pick the client on purpose: official SDK, community SDK or your own HTTP client.
- Put every provider call behind one class and keep keys and model names in config.
- Set a timeout per feature, and make the web stack allow it.
- Retry 429, 5xx, 529 and connection errors, with backoff that honors Retry-After.
- Stream answers that a person waits for, with output buffering off.
- Ask for structured output instead of parsing free text.
- Keep the prompt prefix stable so provider caching applies.
- Send work nobody waits for through the Batch APIs.
- Log model, tokens, latency and cost for every call.
- Treat model output as untrusted input.
Where this checklist comes from
Our own project board, the one we run client work on, calls the OpenAI API from a Symfony back end for five features: it transcribes voice notes with Whisper, turns dictated text into a formatted task description, writes a business-analyst draft from a raw client request, and generates a spoken morning briefing and a daily report. It uses Symfony HttpClient directly, without an SDK, with gpt-4o-mini for text.
Writing this guide, we reviewed that client against the checklist. Some items it gets right, and we show them below. One it does not: it has no retry layer yet, so a single 429 or 503 surfaces as an error to the user. We point out where, because the gap is typical of internal tools that grew from a prototype.
Before that, we built AI Resume Master, an AI SaaS on PHP and the OpenAI API, which reached 50,000 monthly active users.
Which PHP client should you use for OpenAI and Claude?
Use the official Anthropic SDK for Claude-heavy work, the openai-php/client package for OpenAI-heavy work, and your own HTTP client when you have one or two simple features and want no extra dependency. If you need several providers behind one API, see our comparison of Laravel AI SDK, Prism, Symfony AI and LLPhant.
| Client | Maintainer | Latest version (checked 29.10.2026) | PHP | Installs per month | Retries built in |
|---|---|---|---|---|---|
anthropic-ai/sdk |
Anthropic (official) | v1.3.0, 23 October 2026 | 8.1+ | about 1.4 million | Yes: 2 retries by default |
openai-php/client |
Community (Nuno Maduro and contributors) | v0.21.0, 17 September 2026 | 8.2+ | about 2.9 million | No |
openai-php/laravel / openai-php/symfony |
Community | v0.21.0 / v0.19.0 | 8.2+ | about 1.1 million / 91,000 | No (same client) |
Your own HTTP client (Symfony HttpClient, Guzzle, Laravel Http) |
You | - | - | - | Only what you add |
OpenAI does not publish an official PHP SDK. openai-php/client is still pre-1.0, so pin the minor version in composer.json and read the changelog before upgrading.
Both SDKs send requests through a PSR-18 HTTP client that they discover automatically. That matters for the next two sections: timeouts and, for openai-php, retries are decided by that HTTP client, not by the SDK.
How do you set timeouts for LLM calls in PHP?
Give every feature its own timeout on the HTTP client, sized to the slowest normal answer for that feature, and make sure PHP-FPM, the web server and any load balancer allow at least as long.
Our board does the first part. Each method passes its own timeout to Symfony HttpClient: 30 seconds for the short spoken briefing and daily report, 60 for transcription and formatting, 90 for the business-analyst draft, which writes the longest text.
$response = $this->http->request('POST', 'https://api.openai.com/v1/chat/completions', [
'headers' => ['Authorization' => 'Bearer ' . $this->apiKey],
'json' => $payload,
'timeout' => 90, // idle timeout for this feature
'max_duration' => 120, // hard cap on the whole request
]);In Symfony HttpClient, timeout is the maximum idle time between bytes, and max_duration is the cap on the whole request. For LLM calls you want both: a slow model that keeps sending tokens should not be killed, and a request that hangs forever should be.
Two traps we checked in the source code:
- The Anthropic SDK's
timeoutoption does not enforce anything by itself. The SDK documents an intended default of 600 seconds, but a comment in itsRequestOptionsclass says plainly that the timeout is enforced by the HTTP client you pass in, not by the SDK. If you rely on auto-discovery, you get whatever that client's default is. Pass a configured client instead:
use Anthropic\Client;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
$http = new Psr18Client(HttpClient::create(['timeout' => 60, 'max_duration' => 120]));
$claude = new Client(requestOptions: ['transporter' => $http]);- The rest of the stack has its own limits. PHP-FPM's
request_terminate_timeout, nginx'sfastcgi_read_timeout(60 seconds by default) and a load balancer's idle timeout all apply. If any of them is shorter than your API timeout, the user gets a 502 or 504 while PHP is still waiting for the model. For anything that can take longer than about a minute, move the call into a queued job and show progress, or stream.
How do you retry OpenAI and Claude API errors from PHP?
Retry on 429 (rate limit), 5xx (provider errors), Anthropic's 529 (overloaded) and connection errors, with exponential backoff that honors the Retry-After header, and stop after two or three attempts. Do not retry 400, 401 or 404: the same request will fail the same way.
What each option does out of the box:
| Layer | What it retries by default | Backoff | Honors Retry-After |
|---|---|---|---|
| Anthropic PHP SDK | 408, 409, 429, every 5xx (including 529) and transport failures, up to 2 retries | 0.5 s initial, 8 s cap | Yes |
| openai-php/client | Nothing | - | - |
Symfony RetryableHttpClient (default strategy) |
423, 425, 429, 502, 503 for any method; 500, 504, 507, 510 and transport errors only for idempotent methods | 1 s, multiplier 2, up to 3 retries | Yes |
| Your own code | Whatever you write | - | - |
The Symfony row is the one that surprises people. Every LLM call is a POST, and POST is not idempotent, so with the default strategy a 500, a 504 or a dropped connection is not retried at all, and 529 is not on the list either. We confirmed this in GenericRetryStrategy (symfony/http-client 7.3). To retry LLM calls with Symfony HttpClient, pass your own status code map:
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\RetryableHttpClient;
$retryOn = [0, 408, 429, 500, 502, 503, 504, 529]; // 0 = transport errors, any method
$http = new RetryableHttpClient(
HttpClient::create(),
new GenericRetryStrategy($retryOn, delayMs: 1000, multiplier: 2.0, maxDelayMs: 10000),
maxRetries: 3,
);Retrying a POST is safe here because a chat or messages call creates nothing on the provider's side; the worst case is paying for one extra generation. That is not true for every endpoint: do not blindly retry calls that create files, batches or fine-tuning jobs.
Two more rules from experience:
- Keep the total retry time below the request timeout of whoever waits for the answer. Three retries with a 60-second timeout each can hold a web request for four minutes.
- Log every retry with the status code. A slow rise in 429s is the first sign you need a higher rate-limit tier or a cheaper model for part of the traffic.
Both providers return rate-limit headers you can watch before you hit the wall. openai-php/client exposes them through $response->meta(), for example requestLimit->remaining and tokenLimit->remaining. Anthropic returns retry-after and anthropic-ratelimit-* headers.
How do you stream OpenAI or Claude responses from PHP?
Use the SDK's streaming method or read the provider's server-sent events yourself, write each text chunk to the response and flush it, with output buffering turned off in PHP and in the web server.
With openai-php/client and the Responses API:
$stream = $client->responses()->createStreamed([
'model' => $model,
'input' => $question,
]);
foreach ($stream as $event) {
if ($event->event === 'response.output_text.delta') {
echo 'data: ' . json_encode(['text' => $event->response->delta]) . "\n\n";
flush();
}
}With the Anthropic SDK:
use Anthropic\Messages\RawContentBlockDeltaEvent;
use Anthropic\Messages\TextDelta;
$stream = $claude->messages->createStream(
model: $model,
maxTokens: 4096,
messages: [['role' => 'user', 'content' => $question]],
);
foreach ($stream as $event) {
if ($event instanceof RawContentBlockDeltaEvent && $event->delta instanceof TextDelta) {
echo 'data: ' . json_encode(['text' => $event->delta->text]) . "\n\n";
flush();
}
}Event class and field names differ between SDK versions, so check the version you installed before copying them. The streaming part that breaks most often is not the SDK but buffering:
- Call
ob_end_flush()(or disableoutput_buffering) before the loop, andflush()after each chunk. - Behind nginx, send the
X-Accel-Buffering: noheader, or setfastcgi_buffering offfor that location. - Send
Content-Type: text/event-streamandCache-Control: no-cache. - One streaming request holds one PHP-FPM worker for the whole answer. Size
pm.max_childrenfor the number of people who can stream at once, or serve streams from a separate pool.
To log tokens for a streamed call, ask for usage in the stream (OpenAI's Chat Completions needs stream_options with include_usage; the Responses API and Claude send usage in their final events) and write the log line when the stream ends.
How do you get reliable JSON out of an LLM in PHP?
Ask the API for structured output against a JSON schema instead of asking for JSON in the prompt and parsing free text. OpenAI supports JSON schema output with strict mode, and Claude supports structured outputs through output_config.format and strict tool definitions; the Anthropic SDK also has helpers that validate the answer against a schema class.
Even with a schema, validate the decoded data in PHP before it touches your database: check required keys, types and value ranges, and treat a failure like any other API error, with one retry and then a fallback.
What do you do with model output before you show or store it?
Treat it as untrusted input, the same as a form field. Our board asks the model for HTML that goes into a rich-text editor, and it never trusts that the model followed the instruction: it strips everything outside a short allowlist of tags (<p>, <h2>, <h3>, lists, <strong>, <em>, <code>, <br>) before saving. The same applies to Markdown that you render, SQL or code that a model suggests, and URLs it returns.
How do you cut OpenAI and Claude API costs from PHP?
Cache what you can, batch what nobody waits for, and choose the smallest model that passes your quality bar. The three levers, as documented by the providers on 29 October 2026:
| Lever | OpenAI | Claude |
|---|---|---|
| Prompt caching | Automatic for supported models; on current models the prompt needs at least 1,024 tokens; cached input is discounted up to 95% | You mark the prefix with cache_control; minimum 512 to 4,096 tokens depending on the model; cache reads cost about 0.1x of input on most models, writes 1.25x (5-minute TTL) or 2x (1-hour TTL) |
| Batch API | 50% off, results within 24 hours, up to 50,000 requests per batch | 50% off every token, including cache reads and writes, results within 24 hours |
| Smaller model | Route easy requests to a mini model | Route easy requests to Haiku |
Caching only works if the start of the prompt is byte-for-byte identical across requests. In practice that means: system prompt and tool definitions first and never changing, no timestamps or request IDs in them, the user's input last. Check that it works by reading the cached token count in usage: input_tokens_details.cached_tokens on OpenAI, cacheReadInputTokens in the Anthropic PHP SDK.
Our board has one cache of its own: text-to-speech audio is stored on disk under a key made from the text and the voice, so the same briefing is not paid for twice. A cache in your own code like this makes sense when the same input produces the same output and the output is expensive.
To estimate what a feature will cost per month before you build it, use our LLM API cost calculator, and for the pricing details behind it see OpenAI and Claude API cost.
What should you log for every LLM call?
Log one row per call with the feature name, model, prompt version, input tokens, cached tokens, output tokens, latency, status code, retry count and the user or tenant it belongs to. Do not log full prompts and answers by default if they contain personal data; log a hash or a sampled copy instead.
That row answers the questions that come up in the first month: which feature costs the most, whether caching works, whether a model change made answers slower, and which customer is responsible for a cost spike. With openai-php/client the token counts are in $response->usage; with the Anthropic SDK they are in $message->usage. Store the provider's request ID too (the x-request-id header on OpenAI, request-id on Anthropic), because it is the first thing provider support asks for.
The Anthropic PHP SDK also reports every call as an OpenTelemetry span when your application registers a tracer provider, which is enough if you already run tracing.
Lessons from our own PHP client
Three things from the board's code that are not in the API documentation:
- Whisper needs a file name with an extension. An uploaded file sits in PHP's temporary directory without an extension, and Whisper rejected it with HTTP 400 "Unrecognized file format". The fix was to pass the original file name and MIME type explicitly when building the multipart request.
- Keep the model name in configuration. The model comes from an environment variable with a default in code, so moving a feature to a newer or cheaper model is a config change and a deploy, not a code change.
- One class, one place to fix. All five features go through one client class. When we add the retry layer described above, it is one constructor change, not five.
Which approach do we recommend?
For most PHP teams, our verdict is:
- Claude-first features: use the official
anthropic-ai/sdk, pass it a configured HTTP client with your timeouts, and keep its default retries. - OpenAI-first features: use
openai-php/clientwith a retrying PSR-18 client that retries POST on 429, 5xx and connection errors. - One or two simple features: your framework's HTTP client behind one service class is fine, but add retries before launch, not after the first incident.
- Several providers or agents with tools: look at a library, compared in Laravel AI SDK vs Prism vs Symfony AI.
Whatever you choose, the checklist above does not change: timeouts per feature, retries that fire on POST, streaming without buffering, a stable prompt prefix, batch for offline work and one log row per call.
Next step
If you want a senior PHP team to add an OpenAI or Claude feature to your product, or to review an existing integration against this checklist, see our AI integration services. First features usually take 3 to 8 weeks and start from $10,000, with a fixed quote after a short scoping call. Contact us with a description of the feature and the data it uses.