Calling OpenAI-Compatible APIs from PHP: A Guide to Laravel Timeout and Streaming Pitfalls

A tested guide to OpenAI-compatible APIs in PHP: base URI, timeouts, exceptions, and streaming.
Based on hands-on testing with openai-php/client 0.21.0 and PHP 8.4, this article systematically covers key differences when connecting PHP projects to OpenAI-compatible endpoints (vLLM, LiteLLM, Ollama, or hosted gateways). Core findings: use OpenAI::factory() rather than client(); the base URI must be https://host/v1 with no trailing slash to avoid double-slash 404s; without an HTTP client the library sets no timeout, so explicitly configure timeout and connect_timeout to avoid exhausting PHP-FPM workers; exceptions fall into RateLimitException, ServerException, ErrorException, and TransporterException, with no built-in retry, and retries should only cover the first three; in streaming, timeout only covers waiting for response headers, so read_timeout is additionally needed, and its exception is Guzzle's RuntimeException. The Laravel integration section also notes the default 30-second timeout, no read_timeout for streaming, and .env changes not taking effect after config:cache.
Why PHP Developers Need This Guide
If you write business logic in PHP and want to integrate an LLM, the handiest library is openai-php/client; for Laravel projects, add openai-php/laravel. Both packages can connect to vLLM, LiteLLM, Ollama's /v1 endpoints, or any hosted gateway, but their default behavior differs from the Python/Node SDKs in a few ways. When things go wrong, it often looks like "the model seems off." In reality, it's usually the base URI, timeouts, or streaming handling causing trouble.
The original author, Aman Kumar, tested with openai-php/client 0.21.0, PHP 8.4, Guzzle 7.15.5 and 8.2.0, against a local mock service that only implements /v1/chat/completions and /v1/models. The conclusions below come from actual testing, not documentation speculation.
Minimal Client: Starting from the factory
$client = OpenAI::factory()
->withApiKey(getenv('LLM_API_KEY'))
->withBaseUri('https://your-gateway.example/v1')
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120,
'connect_timeout' => 10,
]))
->make();
Note that OpenAI::client($key) is just a shortcut that always points to api.openai.com. To connect to third-party compatible services, you must use the factory() chain.

PSR-18 (PHP Standards Recommendation 18) is a common HTTP client interface specification by PHP-FIG, defining methods like ClientInterface::sendRequest(). Guzzle 7+, Symfony HttpClient, php-http, and other libraries implement it, so business code can program against the interface without binding to a specific implementation. openai-php/client relies on this mechanism: when the caller doesn't explicitly pass an HTTP client, it tries to discover an available client via implementations of Psr\Http\Client\ClientInterface. If multiple PSR-18 implementations are installed in the project, or the host environment doesn't properly register a discoverer, you might end up thinking you're using client A while actually using client B. This is one of the underlying reasons the article recommends always explicitly calling withHttpClient()—it gives you timeout control and eliminates implementation ambiguity.
Base URI: The Number One Source of 404 Errors
The client does very little cleanup of the base URI: it appends a trailing /, adds https:// if no protocol is given, then appends relative paths like chat/completions. Actual test results for several forms:
withBaseUri(...) | Actual request path | Result |
|---|---|---|
https://host/v1 | /v1/chat/completions | Works |
https://host/v1/ | /v1//chat/completions | 404 |
https://host | /chat/completions | 404 |
https://host/v1/chat/completions | /v1/chat/completions/chat/completions | 404 |
host/v1 | https://host/v1/... | HTTPS works; TLS error for plain HTTP local services |
The trailing slash is the easiest pitfall to hit. The Python SDK tolerates it; this library doesn't, and the double slash in logs is easy to overlook. When running vLLM or Ollama locally, always include the full http://, otherwise you'll see cURL error 35: wrong version number. All 404s are thrown as OpenAI\Exceptions\ErrorException, with getStatusCode() returning 404. Before suspecting the model ID, print out the base URI first.
Timeouts: Surprisingly, None by Default
When no HTTP client is passed, the factory discovers Guzzle via the PSR-18 discovery mechanism and sets no timeout at all. The author sent a request to a deliberately slow endpoint and just waited indefinitely. In production, a hanging gateway will occupy a PHP-FPM worker until max_execution_time or the web server kills it.
So always pass your own Guzzle client. Setting timeout to 2 seconds when requesting an endpoint that takes 5 seconds to respond yields TransporterException: cURL error 28.
PHP-FPM uses a process pool model, where each worker handles only one request at a time, and the number of workers is determined by pm.max_children. If a request hangs because the downstream gateway is unresponsive, that worker is occupied exclusively. When the number of hanging requests approaches pm.max_children, new requests queue up, manifesting as overall site slowness or even 502/504 errors. max_execution_time only counts CPU time and typically doesn't include time blocked on socket reads, so it alone cannot release workers in a timely manner. What actually provides a fallback is the web server (e.g., Nginx's fastcgi_read_timeout) or FPM's request_terminate_timeout. This is why the article emphasizes explicitly setting Guzzle's timeout: actively failing at the application layer is more controllable and observable than passively waiting for the process to be killed.
Four Exception Types to Catch
This is the part the author appreciates most—the exception mapping is very clear:
- 429 throws
RateLimitException, with the original response on$e->response, where you can read theRetry-Afterheader. - 5xx throws
ServerException. - Other 4xx with JSON error bodies (wrong key, unknown model, 404 path) throw
ErrorException, providinggetStatusCode(),getErrorMessage(),getErrorType(),getErrorCode(). Note thatgetErrorCode()returns the string the server put in thecodefield; when deciding, compare status codes rather than this value. - Network failures and timeouts throw
TransporterException.
The library itself has no retry. The author provides a small wrapper function: prioritize honoring Retry-After, otherwise use exponential backoff with jitter, and only retry RateLimitException, ServerException, and TransporterException. Retrying ErrorException like 401 or unknown model is useless, so don't waste attempts. Testing against an endpoint that returned two 429s with Retry-After: 2 and succeeded on the third try, it slept for two rounds of 2 seconds and then got the result.
Streaming: timeout Doesn't Cover It
$stream = $client->chat()->createStreamed([
'model' => 'YOUR_MODEL_ID',
'messages' => [[...]],
'stream_options' => ['include_usage' => true],
]);
foreach ($stream as $chunk) {
if (isset($chunk->choices[0])) {
echo $chunk->choices[0]->delta->content ?? '';
}
}
createStreamed() automatically sets stream: true. With include_usage enabled, the last chunk's choices is an empty array, so you must check with isset before reading.
The most counterintuitive finding: timeout => 2 simply cannot stop a stream that runs for 4 seconds—the same in Guzzle 7 and 8. The reason is that the library sends requests using Guzzle's stream option, and timeout only covers the time waiting for response headers. To catch "stuck mid-stream" situations, you need to add read_timeout:
new \GuzzleHttp\Client(['timeout' => 0, 'read_timeout' => 30, 'connect_timeout' => 10]);
The exception thrown when read_timeout triggers is not defined by the OpenAI package: Guzzle 7 throws a bare RuntimeException("Unable to read from stream"), while Guzzle 8 throws GuzzleHttp\Psr7\Exception\TimeoutException (inheriting from RuntimeException). So wrap the foreach with catch (\RuntimeException $e). Also, at this point the user may have already seen half the answer—don't blindly retry and resend half the stream.
Guzzle's stream request option causes the client to return a PSR-7 stream as soon as response headers are received, rather than reading the entire response body first; the caller then reads it chunk by chunk through iteration. This is exactly the foundation that makes SSE (Server-Sent Events)-style token streaming work. Consequently, Guzzle's timeout semantics are split into two parts: timeout controls the total duration from sending the request to receiving response headers, connect_timeout controls TCP connection establishment, and read_timeout controls the maximum wait between two reads. Many people mistakenly believe timeout is the overall upper limit, leading to the confusion of "set 2 seconds, but the stream ran for 4 seconds." After understanding this layering, in streaming scenarios you should set timeout to 0 (unlimited) or a large value, and use read_timeout to detect "stuck mid-stream" conditions.
Laravel Integration: What the Package Actually Configures
composer require openai-php/laravel
php artisan openai:install
OPENAI_API_KEY=your-key
OPENAI_BASE_URL=https://your-gateway.example/v1
OPENAI_REQUEST_TIMEOUT=120
Looking at the source of openai-php/laravel 0.21.0, the service provider builds the client from config('openai.*'), and base_uri is passed directly to withBaseUri(), so the trailing slash rules still apply. The Guzzle client it creates only configures ['timeout' => config('openai.request_timeout', 30)]. Three practical implications:
- Default 30 seconds. Long completions from slow models will hit this, yielding
TransporterExceptionand cURL error 28; increaseOPENAI_REQUEST_TIMEOUTfor the relevant routes. - No
read_timeoutfor streaming. 30 seconds can't control the stream, nor can it stop a stuck stream. - After running
config:cache, changing.envhas no effect. The author has seen someone changeOPENAI_BASE_URLon the server and wonder why requests were still going to the old host—re-caching the config solved it.
When you need read_timeout, extra headers, or a second provider, the author suggests registering your own client under a separate binding name rather than overriding the package's existing binding:
$this->app->singleton('llm', fn () => \OpenAI::factory()
->withApiKey(config('services.llm.key'))
->withBaseUri(config('services.llm.base_url'))
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120, 'read_timeout' => 30, 'connect_timeout' => 10,
]))->make());
php artisan config:cache evaluates and serializes all configuration files under config/ into bootstrap/cache/config.php. After that, the framework no longer reads config/*.php and no longer reads .env through env(). This means that on a server with cached config, modifying .env won't take effect; you must re-run config:cache (or first config:clear). This is a common pitfall in Laravel deployments, especially in scenarios with rolling releases across multiple machines where only the environment variable file is updated but the cache isn't rebuilt. The article's "changed OPENAI_BASE_URL but requests still go to the old host" is a direct consequence of this mechanism; when troubleshooting, first confirm whether bootstrap/cache/config.php exists and what values it contains.
Parameter Passthrough and Model Listing
The client passes the request array through as-is. The author sent top_k and max_tokens, and the mock received both, so vendor-specific parameters work as long as the server accepts them. To check which models are available for the current key:
foreach ($client->models()->list()->data as $model) {
echo $model->id, PHP_EOL;
}
Deployment Checklist
- Use
OpenAI::factory(), notOpenAI::client(). - Write the base URI as
https://host/v1, no trailing slash, with the full protocol. - Always pass a Guzzle client with
timeoutandconnect_timeoutset. - Add
read_timeoutfor streaming and catch\RuntimeExceptionoutside the loop. - Only retry
RateLimitException,ServerException, andTransporterException. - In Laravel, increase
OPENAI_REQUEST_TIMEOUT, and remember to re-runconfig:cacheafter changing.env.
The original author discloses that they developed the gateway APIClaw used in the article's examples; weigh their opinions accordingly. The above rules apply to any OpenAI-compatible endpoint.
Related articles

Chain of Thought Isn't Just for AI: How Humans Can Use CoT to Improve Thinking Quality
Chain of Thought isn't just a prompt trick for large models. Starting from a Hacker News discussion, this article analyzes why CoT works for human thinking too, breaks down actionable ways to transfer it, and highlights the key differences between human CoT and machine CoT.
Cloudflare Teams Up with OpenAI: Closi…
Cloudflare Teams Up with OpenAI: Closing the Loop from Vulnerability Discovery to Defense with AI
Cloudflare uses OpenAI Daybreak to investigate security vulnerabilities and generate reviewable remediation plans, while developers use Codex to boost coding efficiency and quality.

Orca Multi-Agent Orchestration in Practice: A Guide to Parallel Collaboration with Claude and Codex
Orca is Stable AI's free open-source agent workbench supporting 34 agents like Claude Code and Codex in parallel. Learn its Git worktree isolation, multi-model orchestration, and mobile management.