I'm Aman Kumar. If you write PHP, the usual way to talk to an LLM is openai-php/client, plus openai-php/laravel if you are on Laravel. Both work fine against any OpenAI-compatible server (vLLM, LiteLLM, Ollama's /v1 endpoint, a hosted gateway), but a few defaults behave differently from the Python and Node SDKs, and they fail in ways that look like "the model is wrong".
I tested everything below with openai-php/client 0.21.0 on PHP 8.4, once with Guzzle 7.15.5 and once with Guzzle 8.2.0, against a local mock server that only implements /v1/chat/completions and /v1/models. For the Laravel parts I read the current source of openai-php/laravel (0.21.0 on Packagist), specifically its config file and service provider, rather than guessing. The examples point at APIClaw, an OpenAI-compatible gateway I build, so weigh my opinions accordingly. The base URL is https://apiclaw.biz/v1. I use YOUR_MODEL_ID below; get real IDs from /v1/models or your provider's model list.
The minimal client
composer require openai-php/client guzzlehttp/guzzle
<?php
require 'vendor/autoload.php';
$client = OpenAI::factory()
->withApiKey(getenv('LLM_API_KEY'))
->withBaseUri('https://apiclaw.biz/v1') // no trailing slash
->withHttpClient(new \GuzzleHttp\Client([
'timeout' => 120,
'connect_timeout' => 10,
]))
->make();
$response = $client->chat()->create([
'model' => 'YOUR_MODEL_ID',
'messages' => [
['role' => 'user', 'content' => 'Say hello in one short sentence.'],
],
]);
echo $response->choices[0]->message->content, PHP_EOL;
echo $response->usage->totalTokens, " tokens", PHP_EOL;
OpenAI::client($key) is the shortcut, but it always targets api.openai.com. For any other server you need OpenAI::factory().
The base URI rules (where most 404s come from)
The client builds the base URI with very little cleanup: it appends one /, and if the string has no http:// or https:// it adds https:// in front. Then it sends requests to relative paths like chat/completions. Here is what each form produced on my mock:
withBaseUri(...) |
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/... |
Works for HTTPS hosts, TLS error on a plain-HTTP local server |
The trailing slash is the one that catches people. The Python SDK tolerates it; this one does not, and the double slash is easy to miss in a log. Make sure you keep /v1 and drop the trailing slash. For a local vLLM or Ollama server, write http:// explicitly or you will get cURL error 35: wrong version number.
All of these show up as OpenAI\Exceptions\ErrorException with getStatusCode() returning 404 when the server sends a JSON error body. Before you suspect the model ID, print the base URI.
Timeouts: none by default
If you don't pass an HTTP client, the factory finds one through PSR-18 discovery (Guzzle, if installed) with no timeout configured. I sent a request to a deliberately slow endpoint without one, and it just waited. A gateway that hangs will tie up a PHP-FPM worker until max_execution_time or the web server kills it.
So always pass your own Guzzle client. With 'timeout' => 2 against a 5-second response, I got OpenAI\Exceptions\TransporterException: cURL error 28: Operation timed out after 2001 milliseconds.
Streaming works differently, though, and I explain why in the streaming section.
Errors map to four exception classes
This is the part of the library I like most. What I observed:
-
429 throws
OpenAI\Exceptions\RateLimitException. The raw response is on$e->response, so$e->response->getHeaderLine('Retry-After')works. -
5xx throws
OpenAI\Exceptions\ServerException("Server error (HTTP 502) occurred"). -
Other 4xx with a JSON error body (bad key, unknown model, 404 path) throws
OpenAI\Exceptions\ErrorExceptionwithgetStatusCode(),getErrorMessage(),getErrorType()andgetErrorCode(). Note thatgetErrorCode()returned the string'400'from my mock because that is what the server put incode. Compare on the status code, not the error code. -
Network failures and timeouts throw
OpenAI\Exceptions\TransporterException.
There are no built-in retries. Here is the small wrapper I use. It respects Retry-After and otherwise backs off with jitter:
use OpenAI\Exceptions\RateLimitException;
use OpenAI\Exceptions\ServerException;
use OpenAI\Exceptions\TransporterException;
function withRetry(callable $fn, int $tries = 4): mixed
{
for ($attempt = 1; ; $attempt++) {
try {
return $fn();
} catch (RateLimitException | ServerException | TransporterException $e) {
if ($attempt >= $tries) {
throw $e;
}
$retryAfter = isset($e->response)
? (int) $e->response->getHeaderLine('Retry-After')
: 0;
$sleep = $retryAfter > 0
? $retryAfter
: min(2 ** $attempt, 20) + random_int(0, 1000) / 1000;
usleep((int) ($sleep * 1_000_000));
}
}
}
$response = withRetry(fn () => $client->chat()->create([
'model' => 'YOUR_MODEL_ID',
'messages' => [['role' => 'user', 'content' => 'hi']],
]));
I tested it against an endpoint that returned 429 twice with Retry-After: 2 and then succeeded. It slept 2 seconds twice and returned the third response. Don't retry ErrorException: a 401 or an unknown model won't fix itself.
Streaming
$stream = $client->chat()->createStreamed([
'model' => 'YOUR_MODEL_ID',
'messages' => [['role' => 'user', 'content' => 'Write a haiku about PHP.']],
'stream_options' => ['include_usage' => true],
]);
foreach ($stream as $chunk) {
if (isset($chunk->choices[0])) {
echo $chunk->choices[0]->delta->content ?? '';
}
if ($chunk->usage) {
$total = $chunk->usage->totalTokens; // arrives on the last chunk
}
}
createStreamed() sets stream: true for you. With include_usage, the final chunk has an empty choices array, so check isset($chunk->choices[0]) before you read it.
What surprised me is that 'timeout' => 2 did not stop a stream that took 4 seconds to finish. That was true with both Guzzle 7 and Guzzle 8. The library sends streamed requests with Guzzle's stream option, so the timeout only covers the wait for response headers. To catch a stream that stalls halfway, add read_timeout, which limits how long a single read can wait:
new \GuzzleHttp\Client(['timeout' => 0, 'read_timeout' => 30, 'connect_timeout' => 10]);
When read_timeout fires, the exception is thrown from inside the foreach. It is not an OpenAI exception. Guzzle 7 threw a plain RuntimeException("Unable to read from stream"), and Guzzle 8 threw GuzzleHttp\Psr7\Exception\TimeoutException, which extends RuntimeException. Wrap the loop in catch (\RuntimeException $e). Keep in mind that part of the answer may already be on screen, so don't blindly retry a half-sent stream to a user.
Laravel: what openai-php/laravel actually configures
composer require openai-php/laravel
php artisan openai:install
OPENAI_API_KEY=your-key
OPENAI_BASE_URL=https://apiclaw.biz/v1
OPENAI_REQUEST_TIMEOUT=120
According to the package source, the service provider builds the client from config('openai.*'). It passes base_uri straight to withBaseUri(), so the same trailing-slash rule applies. It also creates a Guzzle client with only ['timeout' => config('openai.request_timeout', 30)]. That gives you three practical points:
-
The default is 30 seconds. Long completions from slower models can hit that, and you'll get a
TransporterExceptionwith cURL error 28. RaiseOPENAI_REQUEST_TIMEOUTfor those routes. -
Streams get no
read_timeout. Because of how streaming works (see above), the 30 seconds doesn't cap a stream, and nothing catches one that stalls. -
If you run
config:cache, changes to.envdon't apply until you cache the config again. I've seen people editOPENAI_BASE_URLon a server and wonder why the requests still go to the old host.
If you need read_timeout, extra headers or a second provider, I find it simpler to bind your own client under a separate name rather than override the package's binding:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$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());
}
// anywhere
$reply = app('llm')->chat()->create([...]);
Extra parameters and model IDs
The client passes the request array through unchanged. I sent top_k and max_tokens and the mock received both, so provider-specific parameters work as long as the server accepts them. To see which models your key can use:
foreach ($client->models()->list()->data as $model) {
echo $model->id, PHP_EOL;
}
Checklist
- Use
OpenAI::factory(), notOpenAI::client(). - Set the base URI to
https://host/v1, with no trailing slash and the scheme written out. - Always pass a Guzzle client with
timeoutandconnect_timeout. - For streams, add
read_timeoutand catch\RuntimeExceptionaround the loop. - Retry only
RateLimitException,ServerExceptionandTransporterException. - On Laravel, raise
OPENAI_REQUEST_TIMEOUTand runconfig:cacheagain after changing.env.
Disclosure: I build APIClaw, the OpenAI-compatible gateway in these examples (flat monthly plans; crypto on the site, other payment methods through support). Everything above applies to any compatible endpoint.
Top comments (0)