[KongchangAI]
· 3 min read· 1,606 words

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

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.

Calling OpenAI-compatible API from PHP

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 pathResult
https://host/v1/v1/chat/completionsWorks
https://host/v1//v1//chat/completions404
https://host/chat/completions404
https://host/v1/chat/completions/v1/chat/completions/chat/completions404
host/v1https://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 the Retry-After header.
  • 5xx throws ServerException.
  • Other 4xx with JSON error bodies (wrong key, unknown model, 404 path) throw ErrorException, providing getStatusCode(), getErrorMessage(), getErrorType(), getErrorCode(). Note that getErrorCode() returns the string the server put in the code field; 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:

  1. Default 30 seconds. Long completions from slow models will hit this, yielding TransporterException and cURL error 28; increase OPENAI_REQUEST_TIMEOUT for the relevant routes.
  2. No read_timeout for streaming. 30 seconds can't control the stream, nor can it stop a stuck stream.
  3. After running config:cache, changing .env has no effect. The author has seen someone change OPENAI_BASE_URL on 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

  1. Use OpenAI::factory(), not OpenAI::client().
  2. Write the base URI as https://host/v1, no trailing slash, with the full protocol.
  3. Always pass a Guzzle client with timeout and connect_timeout set.
  4. Add read_timeout for streaming and catch \RuntimeException outside the loop.
  5. Only retry RateLimitException, ServerException, and TransporterException.
  6. In Laravel, increase OPENAI_REQUEST_TIMEOUT, and remember to re-run config:cache after 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.

#PHP#OpenAI API#Laravel#openai-php/client#streaming#timeout#Guzzle
Share:

Related articles