PHP SDK (Laravel and openai-php)
openai-php/client and Guzzle with uttapen in PHP and Laravel: client, chat, cost headers, streaming, tool calling, embeddings and error handling.
Updated: September 7, 2026
The openai-php/client package (these examples were tested with 0.20) connects to uttapen by changing withBaseUri. For Laravel the same package is available through openai-php/laravel, where only OPENAI_BASE_URL in .env has to change.
Install and client
composer require openai-php/client guzzlehttp/guzzle
<?php
require 'vendor/autoload.php';
$client = OpenAI::factory()
->withBaseUri('https://api.uttapen.ir/v1')
->withApiKey(getenv('UTTAPEN_API_KEY')) // sk-up-...
->make();
Laravel: after composer require openai-php/laravel and php artisan openai:install, in .env:
OPENAI_API_KEY=sk-up-...
OPENAI_BASE_URL=https://api.uttapen.ir/v1
Then call OpenAI::chat()->create([...]) through the facade. If OPENAI_BASE_URL is missing from config/openai.php (older versions), add the base_uri key by hand.
Chat
$result = $client->chat()->create([
'model' => 'openai/gpt-5-mini',
'messages' => [
['role' => 'system', 'content' => 'Answer briefly.'],
['role' => 'user', 'content' => 'What is the difference between Eloquent and the Query Builder?'],
],
'max_tokens' => 300,
]);
echo $result->choices[0]->message->content, PHP_EOL;
echo $result->usage->promptTokens, '/', $result->usage->completionTokens, PHP_EOL;
// cost headers
$headers = $result->meta()->toArray()['custom'];
echo $headers['x-uttapen-cost-toman'], ' toman — balance ', $headers['x-uttapen-balance-toman'], PHP_EOL;
This client discards fields it does not know about, so read anything outside the standard OpenAI contract from the meta() headers, not from the body.
Streaming
$stream = $client->chat()->createStreamed([
'model' => 'openai/gpt-5-mini',
'messages' => [['role' => 'user', 'content' => 'Write a Validator class for Iranian national IDs.']],
]);
foreach ($stream as $response) {
echo $response->choices[0]->delta->content ?? '';
flush();
}
Do not send the X-Uttapen-Include-Meta header with createStreamed. This client's parser expects every chunk to have choices and dies with a TypeError on the uttapen.meta event. If you need the toman cost of a stream, read the stream with raw Guzzle (example below), or fetch it afterwards from GET /v1/uttapen/usage.
Raw streaming with Guzzle, including uttapen.meta:
$http = new \GuzzleHttp\Client(['base_uri' => 'https://api.uttapen.ir/v1/']);
$res = $http->post('chat/completions', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('UTTAPEN_API_KEY'),
'X-Uttapen-Include-Meta' => '1',
],
'json' => ['model' => 'openai/gpt-5-mini', 'messages' => [['role' => 'user', 'content' => 'Hello']], 'stream' => true],
'stream' => true,
]);
$body = $res->getBody();
$buffer = '';
while (!$body->eof()) {
$buffer .= $body->read(1024);
while (($pos = strpos($buffer, "\n\n")) !== false) {
$event = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 2);
if (!str_starts_with($event, 'data: ')) continue;
$payload = substr($event, 6);
if ($payload === '[DONE]') break 2;
$json = json_decode($payload, true);
if (($json['object'] ?? '') === 'uttapen.meta') {
echo "\n[cost: {$json['cost_toman']} toman]\n";
} else {
echo $json['choices'][0]['delta']['content'] ?? '';
}
}
}
To relay this to the browser in Laravel, put the same loop inside response()->stream() (example).
Tool calling
$tools = [[
'type' => 'function',
'function' => [
'name' => 'get_weather',
'description' => 'Current temperature of a city',
'parameters' => ['type' => 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']],
],
]];
$messages = [['role' => 'user', 'content' => 'Weather in Tehran?']];
$r1 = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages, 'tools' => $tools]);
$msg = $r1->choices[0]->message;
$messages[] = $msg->toArray();
foreach ($msg->toolCalls as $call) {
$args = json_decode($call->function->arguments, true);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call->id,
'content' => json_encode(['city' => $args['city'], 'temp_c' => 31], JSON_UNESCAPED_UNICODE),
];
}
$r2 = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages, 'tools' => $tools]);
echo $r2->choices[0]->message->content;
$msg->toArray() puts the assistant message, tool_calls and all, into the history in the right shape. Details in Tool calling.
Embeddings
$emb = $client->embeddings()->create([
'model' => 'openai/text-embedding-3-small',
'input' => ['lease agreement', 'residential property rental contract'],
]);
foreach ($emb->embeddings as $item) {
echo $item->index, ': ', count($item->embedding), ' dims', PHP_EOL;
}
Error handling
use OpenAI\Exceptions\ErrorException;
use OpenAI\Exceptions\TransporterException;
try {
$result = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages]);
} catch (ErrorException $e) {
match ($e->getStatusCode()) {
401 => Log::error('uttapen API key is invalid'),
402 => Log::warning('insufficient balance: ' . $e->getMessage()),
429 => sleep(5),
default => Log::error("{$e->getStatusCode()} {$e->getErrorCode()}: {$e->getMessage()}"),
};
} catch (TransporterException $e) {
Log::error('could not connect to uttapen: ' . $e->getMessage());
}
getErrorCode() is our error.code. The uttapen block (with required_toman and friends) is not preserved on this exception; if you need it, make the request with Guzzle and json_decode the error body yourself. The full table is in Errors.
PHP notes
- Pass
JSON_UNESCAPED_UNICODEtojson_encodeso non-ASCII tool content stays readable and cheap in tokens. - Raise
max_execution_timefor long streams, or drop the limit entirely (set_time_limit(0)); the 30-second default is not enough for reasoning models. - Use the
mb_*functions to measure and slice multibyte text, notstrlen/substr. - In Laravel, read the key in
config/services.phpthroughenv()and leave the value empty in.env.example; do not forgetphp artisan config:cache.
Long-running requests in Laravel
A call to a reasoning model, or one carrying a long document, can take more than a minute — do not run it inside the normal PHP-FPM request cycle. Create a queued job, give it a generous timeout, and store the result against the request id so the front end can pick it up by polling or broadcasting.
class SummarizeDocument implements ShouldQueue
{
public int $timeout = 300;
public int $tries = 2;
public function handle(): void
{
$result = OpenAI::chat()->create([
'model' => 'anthropic/claude-sonnet-4.5',
'messages' => [['role' => 'user', 'content' => $this->text]],
'max_tokens' => 1500,
]);
$requestId = $result->meta()->toArray()['custom']['x-uttapen-request-id'] ?? null;
$this->document->update(['summary' => $result->choices[0]->message->content, 'llm_request_id' => $requestId]);
}
}
Keep tries at 2 or below, and in failed() notify an admin about 402 errors; retrying an insufficient balance only fills the queue. Set the client's own timeout with a custom Guzzle instance too: OpenAI::factory()->withHttpClient(new \GuzzleHttp\Client(['timeout' => 180])).
Caching repeated responses
If the same prompt goes out again and again (a product description, translating a set of labels), keep the answer with Cache::remember under a key hashed from the model and the messages. It is the simplest saving there is and it costs you nothing in quality. For long prompts with a fixed prefix (a system prompt of several thousand tokens), some models bill cached tokens at a lower rate; put the fixed prefix first and the variable part last so the cache can be used. The cached_tokens value in usage->toArray()['prompt_tokens_details'] shows how much of it was hit.