lucianotonet/groq-php
A PHP SDK for the GroqCloud API that lets developers add fast LLM inference to any PHP app, with chat completions, tool calling, vision, reasoning, audio transcription, text-to-speech, batch processing, and RAG support.
Install and key up
composer require lucianotonet/groq-php
GROQ_API_KEY=gsk_...
# GROQ_API_BASE=https://api.groq.com/openai/v1 # only if proxying
The SDK reads GROQ_API_KEY from the environment itself (the README uses getenv()), which means it works the moment you drop the key in .env — no config publishing, no service provider, no facade. That's the first thing to internalize: this is a framework-agnostic PHP SDK, not a Laravel package. There is a separate lucianotonet/groq-laravel wrapper if you want a facade and config file, but for a codebase you own, a hand-rolled binding is usually less risk than a second low-adoption dependency.
Make it injectable in Laravel
Don't sprinkle new Groq(...) through controllers. Bind it once:
// config/services.php
'groq' => [
'key' => env('GROQ_API_KEY'),
'base' => env('GROQ_API_BASE', 'https://api.groq.com/openai/v1'),
],
// app/Providers/AppServiceProvider.php
$this->app->singleton(Groq::class, fn () => new Groq(config('services.groq.key')));
Reading the key through config() rather than letting the SDK hit getenv() matters once you run php artisan config:cache — see Gotchas.
First use case: a 10-minute smoke test
Before building anything, verify the key and model availability from php artisan tinker:
$groq = app(\LucianoTonet\GroqPHP\Groq::class);
$groq->models()->list()['data']; // confirm the key works and see live model IDs
$groq->chat()->completions()->create([
'model' => 'openai/gpt-oss-20b',
'messages' => [['role' => 'user', 'content' => 'Say hello in five words.']],
])['choices'][0]['message']['content'];
Where to look first
examples/ in the repo — the README repeatedly defers to files there (tool-calling-advanced.php, audio-transcriptions.php) for the parts the README abbreviates. Read those before writing tool-calling or audio code.$groq->models()->list() — model IDs churn fast on GroqCloud (openai/gpt-oss-20b, openai/gpt-oss-120b, whisper-large-v3, canopylabs/orpheus-v1-english in the docs are already a mix of generations). Never hardcode an ID you haven't listed.Wrap the SDK in one application service
The SDK returns raw arrays with no typed DTOs. Keep those arrays out of your domain:
final class Assistant
{
public function __construct(private Groq $groq) {}
public function answer(string $question): string
{
try {
$r = $this->groq->chat()->completions()->create([
'model' => config('services.groq.chat_model', 'openai/gpt-oss-20b'),
'messages' => [
['role' => 'system', 'content' => 'You are a concise support assistant.'],
['role' => 'user', 'content' => $question],
],
'temperature' => 0.2,
'max_completion_tokens' => 400,
]);
} catch (GroqException $e) {
report($e);
throw new AssistantUnavailable(previous: $e);
}
return $r['choices'][0]['message']['content'] ?? '';
}
}
Every controller, job, and Livewire component then depends on Assistant, and you can fake it in tests without touching the SDK.
Structured output instead of parsing prose
For anything you'll store or branch on, use response_format with json_schema and strict: true — constrained decoding means you can json_decode without defensive regex. Reserve json_object mode for exploratory work, and remember it wants the word "JSON" in the prompt.
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'ticket',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'category' => ['type' => 'string', 'enum' => ['billing','bug','other']],
'priority' => ['type' => 'integer'],
],
'required' => ['category','priority'],
'additionalProperties' => false,
],
],
],
Then map into a real class: TicketData::from(json_decode($content, true)). Strict mode is not supported by every model, so keep a fallback model ID in config.
Streaming to the browser
Use Laravel's response()->stream() (or streamDownload) and let the SDK iterate:
return response()->stream(function () use ($groq, $prompt) {
$stream = $groq->chat()->completions()->create([
'model' => 'openai/gpt-oss-20b',
'messages' => [['role' => 'user', 'content' => $prompt]],
'stream' => true,
]);
foreach ($stream->chunks() as $chunk) {
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
if ($delta !== '') {
echo "data: " . json_encode(['delta' => $delta]) . "\n\n";
ob_flush(); flush();
}
}
echo "data: [DONE]\n\n";
}, 200, ['Content-Type' => 'text/event-stream', 'X-Accel-Buffering' => 'no']);
Streaming is the single biggest UX win with Groq (sub-second first token). Use it for chat UIs; don't use it for background jobs, where a blocking call plus a queue is simpler and resumable.
Tool calling as an explicit agent loop
The README's two-pass example is the pattern, but it doesn't scale past one tool. Structure it as a bounded loop:
$messages = [...];
for ($i = 0; $i < 5; $i++) {
$res = $groq->chat()->completions()->create([
'model' => 'openai/gpt-oss-120b',
'messages' => $messages,
'tools' => $tools,
'tool_choice' => 'auto',
]);
$msg = $res['choices'][0]['message'];
$messages[] = $msg;
if (empty($msg['tool_calls'])) {
return $msg['content'];
}
foreach ($msg['tool_calls'] as $call) {
$args = json_decode($call['function']['arguments'], true) ?? [];
$result = $this->registry->call($call['function']['name'], $args);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call['id'],
'name' => $call['function']['name'],
'content' => is_string($result) ? $result : json_encode($result),
];
}
}
Key detail: you must append the assistant message with its tool_calls back into $messages before appending tool results — omitting it produces confusing 400s. Always cap the loop and validate tool names against an allowlist; never dispatch by string name to arbitrary container resolution.
Push slow modalities to the queue
Audio, vision, and batch operations take seconds to minutes. A job per operation, with the result written to the DB and dispatched as a broadcast/notification, is the correct Laravel shape:
final class TranscribeRecording implements ShouldQueue
{
public function handle(Groq $groq): void
{
$result = $groq->audio()->transcriptions()->create([
'file' => Storage::disk('local')->path($this->path),
'model' => 'whisper-large-v3',
'response_format' => 'verbose_json',
'language' => 'en',
]);
// store $result['text'] and $result['segments']
}
}
Same for vision()->analyze($pathOrUrl, $question) and TTS ->save(Storage::disk('public')->path('tts/foo.wav')).
Config-driven models and a cost line
Put model IDs in config/services.php and log usage.total_tokens from each response. Groq is cheap and fast, but token accounting is how you'll notice a runaway agent loop or a prompt that grew to 30k tokens.
Testing
Because everything is arrays over an HTTP client, the clean seam is your own service class:
$this->mock(Assistant::class, fn ($m) => $m->shouldReceive('answer')->andReturn('stubbed'));
For the SDK layer itself, use Http::fake() only if the SDK goes through Laravel's client (it doesn't — it has its own), so instead assert on your service contract and keep one or two @group external integration tests that hit the real API with a small model.
config:cache breaks getenv()-style keys. The SDK reads GROQ_API_KEY from the process environment; in production with cached config, env vars are often not present at runtime. Pass the key explicitly to the constructor from config('services.groq.key') and you sidestep the whole class of "works locally, 401 in prod" bugs.GroqException covers API errors, but transport failures (DNS, timeouts, cURL) may surface as other throwables from the underlying HTTP client. Catch Throwable at your service boundary, and add ->retry()/backoff on 429 in the job, since Groq rate limits are per-minute and spiky batch traffic will hit them.choices[0].delta, not .message, and there's a terminal [DONE]. Any code path that handles both modes needs two branches, not one.gzip in Nginx will buffer your stream until the end, producing "it works in artisan serve but not on the server." Send X-Accel-Buffering: no, and verify with curl -N.arguments is a JSON string, not an array. json_decode every time, and guard against malformed/empty strings from small models. parallel_tool_calls must currently be forced false per the README — don't rely on the model batching calls.required, and additionalProperties: false is mandatory. If you get a 400, it's almost always the schema, not the prompt.response_format supports json, verbose_json, text — not vtt or srt, so subtitle generation needs your own segment→SRT conversion from verbose_json. language is the source language. Pass a real filesystem path (Storage::path() / $upload->getRealPath()), not an UploadedFile object.wav. Long text means chunking sentences and concatenating audio yourself — plan for it rather than discovering it in production.models()->list() is your source of truth. Model IDs are deprecated and swapped on GroqCloud with little notice; a hardcoded llama-3.3-70b style ID in a config file is a future outage. Store a default plus a fallback and refresh the list on a schedule.composer.json ("lucianotonet/groq-php": "^1.0" or an exact tag), keep the SDK behind your own service class, and keep a second integration test that would fail loudly if a documented response shape changed. The escape hatch — a raw Http::withToken() call to the same OpenAI-compatible endpoint — is a 20-line rewrite, which is exactly why the wrapper abstraction is worth the effort.How can I help you explore Laravel packages today?