orhanerday/open-ai
A popular PHP SDK for OpenAI's API, orhanerday/open-ai offers simple methods for ChatGPT, GPT-4, DALL-E, Whisper, embeddings, and moderation. Framework-agnostic with Laravel support, streaming, and easy Composer installation.
Install and pin the SDK:
composer require orhanerday/open-ai
The whole surface area is one class: Orhanerday\OpenAi\OpenAi. Read src/OpenAi.php first — every public method is a thin cURL wrapper that takes an associative array and returns a JSON string (or false/null on transport failure). There are no DTOs, no request objects, and no validation. The examples/ directory in the repo and the README are the fastest way to see payload shapes; after that, the OpenAI API reference is your real documentation, because this package passes your array through almost untouched.
Register it once in Laravel so you are not re-reading env() deep in your code:
// config/services.php
'openai' => [
'key' => env('OPENAI_API_KEY'),
'org' => env('OPENAI_ORG'),
],
// App\Providers\AppServiceProvider::register()
use Orhanerday\OpenAi\OpenAi;
$this->app->singleton(OpenAi::class, fn () => new OpenAi(
config('services.openai.key'),
'', // custom base URL
config('services.openai.org') ?? '' // OpenAI-Organization header
));
First use case — a chat completion from a controller or job:
$raw = $open_ai->chat([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => 'You are a terse assistant.'],
['role' => 'user', 'content' => 'Summarise this ticket: ...'],
],
'temperature' => 0.3,
'max_tokens' => 800,
]);
$data = json_decode($raw, true) ?? [];
if (isset($data['error'])) {
throw new RuntimeException($data['error']['message'] ?? 'OpenAI error');
}
$text = $data['choices'][0]['message']['content'] ?? '';
If that call works, everything else (embeddings, images, audio, files, moderation, fine-tunes, assistants/threads) is the same pattern with a different method name and payload.
Wrap the SDK in your own service class. Never let OpenAi leak into controllers. A ChatService with methods like ask(string $prompt): string and stream(ChatRequest $r) gives you one place for retries, logging, token budgeting, and test doubles. Because the package has no mocks of its own, this wrapper is also how you keep your test suite fast.
Chat with persisted history. Store messages as JSON on a conversation model, append the user turn, trim to a token budget you control, then send. The SDK keeps no state between calls — $open_ai is stateless apart from constructor config — so history, tools, and system prompts are entirely your responsibility.
Streaming. chatStream($payload, $callback) is the day-to-day win for chat UIs:
return response()->stream(function () use ($open_ai, $payload) {
$open_ai->chatStream($payload, function ($curlInfo, $data) {
echo $data;
return strlen($data); // must return byte length or the stream stalls
});
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
On the client, parse data: lines, stop on [DONE], and accumulate choices[0].delta.content. If you need to persist the assistant reply, buffer deltas server-side inside the callback and write after the stream closes.
Tool / function calling. Pass tools and tool_choice in the payload, then loop: send → inspect choices[0].message.tool_calls → execute your PHP function → append a role: "tool" message with tool_call_id → resend. Cap the loop (3–5 iterations) and set parallel_tool_calls => false if your handlers are not concurrency-safe. The package gives you nothing here; the loop is yours.
Structured output. Use 'response_format' => ['type' => 'json_object'] (and mention JSON in the prompt), then json_decode and validate with a Laravel FormRequest or a DTO. Anything the model returns is untrusted input — never eval it or bind it straight to an Eloquent fill().
Embeddings and RAG. embedding([...]) returns vectors you store in Postgres/pgvector, Pinecone, or similar. Batch documents (hundreds per request, under the model's input limit) in a queued job, and rate-limit with Redis::throttle so you do not blow through TPM on a backfill.
Vision and audio. Send content as an array of {type: "text"} / {type: "image_url"} parts for vision; use transcribe() with a CURLFile and purpose for audio. Base64 for small local images, a signed URL for anything large.
Queue long work. Chat completions, embeddings backfills, and fine-tune polling belong in jobs. HTTP requests here are blocking cURL with a generous default timeout — a slow request in a web request cycle will hang a PHP-FPM worker.
Config knobs worth setting. setTimeout(60), setProxy(...), setHeader('OpenAI-Project: proj_...'), and setBaseURL('https://your-gateway/v1/') for a proxy, LiteLLM, or Azure-compatible endpoint.
chat() returns a string on success, false/null on transport errors, and a JSON body containing error on API errors. A single helper that decodes, checks error, and throws is the highest-value thing you will write.$open_ai->getCURLInfo() after a failed request exposes HTTP status, timings, and the cURL error. Log it alongside the request payload (minus the API key) — 400s from tool schemas and image formats are otherwise opaque.strlen($data). Returning true, null, or a trimmed string causes truncated or stalled streams that look like network problems.ob_end_flush() / flush() in the callback, X-Accel-Buffering: no for nginx, and set_time_limit(0) for long generations. Laravel's response()->stream() handles most of it, but any middleware that wraps output will swallow the stream.Http::fake() does not intercept this package. It uses raw cURL, not Guzzle, so Laravel's HTTP client fakes are invisible to it. Test through your wrapper interface, or point setBaseURL() at a local stub server.Retry-After when present, use exponential backoff, and only retry idempotent calls (chat and embeddings are safe; file uploads and fine-tune creation less so).yethee/tiktoken (or estimate ~4 chars/token) to trim history and size max_tokens against the model's context window, or you will hit 400s in production with long threads.temprature is simply ignored by the API. Keep payloads in typed builder methods rather than inline arrays scattered across controllers.max_tokens is not universal. Reasoning-era models want max_completion_tokens, and some reject temperature/top_p. Check the model before copying a payload from an older example — the README examples still lean on legacy shapes.Authorization: Bearer header. Azure expects api-key; the common workaround is setHeader('api-key: ...') plus a base URL containing the deployment path, and stripping the bearer header if your gateway rejects dual auth. Verify against your gateway with a trivial listModels() call first.complete(), engine, and the fine-tune endpoints are legacy. They still work, but new code should start at chat() and embedding(). Treat assistants/threads as experimental in this SDK — the API moved to Responses, and the wrapper has not kept up.composer require orhanerday/open-ai:^4.0 with a lockfile is safer than floating.CURLFile instead of base64 where the API allows, and chunk embedding batches.How can I help you explore Laravel packages today?