uttapen

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_UNICODE to json_encode so non-ASCII tool content stays readable and cheap in tokens.
  • Raise max_execution_time for 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, not strlen/substr.
  • In Laravel, read the key in config/services.php through env() and leave the value empty in .env.example; do not forget php 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.