LLM integration in Laravel: HTTP client, service, streaming and queues

Connecting a model to Laravel without third-party packages: the Http facade, a service class, a streaming response, background jobs with retries and Http::fake tests. With 401–503 errors explained.

4 min · · development

LLM integration in Laravel doesn't need a separate package: the built-in HTTP client (the Http facade) supports timeouts, retries and faking responses in tests. Below is a structure for a Laravel project: config, a service class for Zukko's OpenAI-compatible API, a controller with regular and streaming responses, a queued job and tests. The code targets Laravel 11 and newer; in older versions, mostly the job and route scaffolding differ.

Configuration

In .env:

ZUKKO_API_KEY=fetch_your_key ZUKKO_BASE_URL=https://api.zukko.pro/v1 ZUKKO_MODEL=claude-sonnet-5

In config/services.php:

bash
'zukko' => [
    'base_url' => env('ZUKKO_BASE_URL', 'https://api.zukko.pro/v1'),
    'api_key' => env('ZUKKO_API_KEY'),
    'model' => env('ZUKKO_MODEL', 'claude-sonnet-5'),
],

Read values with env() only in config files and use config('services.zukko...') in code — otherwise the variables will be empty after php artisan config:cache. Create the key on the API keys page; for an application it's convenient to have a separate key with a daily limit.

Service class

File app/Services/ZukkoService.php:

bash
<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

class ZukkoService
{
    private string $baseUrl;
    private string $apiKey;

What matters here:

  • The base URL is https://api.zukko.pro/v1, the path is /chat/completions, and authorization is a Bearer token via withToken.
  • retry(2, 500, ...) retries only on network errors (ConnectionException). Retrying 401, 402 or 403 is pointless, and 429 is better handled through a queue with a delay. The throw: false argument returns the failed response instead of an exception, so we decide what to do with it.
  • $response->throw() throws a RequestException, from which the controller extracts the status.
  • timeout(120) — long answers take time to generate; the default 30 seconds may not be enough.

Laravel builds the service through the container, so manual registration is optional — just type-hint the class in a controller or job.

Controller with error handling

bash
<?php

namespace App\Http\Controllers;

use App\Services\ZukkoService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Request;

class ChatController extends Controller
{
    private const ERRORS = [
        401 => 'Configuration error: invalid API key',
        402 => 'Service temporarily unavailable: balance exhausted',
        403 => 'Model not allowed for this key',

Route: Route::post('/chat', [ChatController::class, 'send']);. In Laravel 11, routes/api.php is created with php artisan install:api; if you use routes/web.php, remember the CSRF token in the request. The user gets a clear message, and the raw exception text stays in the logs.

Streaming response

To make text appear as it is generated, request a stream from the API and pass it through to the client. Add a method to ZukkoService:

bash
public function stream(array $messages): \Generator
{
    $response = Http::withToken($this->apiKey)
        ->withOptions(['stream' => true])
        ->timeout(120)
        ->post("{$this->baseUrl}/chat/completions", [
            'model' => $this->model,
            'messages' => $messages,
            'max_tokens' => 1024,
            'stream' => true,
        ])
        ->throw();

    $body = $response->toPsrResponse()->getBody();

The stream option is passed to Guzzle via withOptions and keeps the whole response from being loaded into memory. The empty($chunk['choices']) check is mandatory: the last chunk of the stream carries only usage and an empty choices array.

The controller method:

bash
public function stream(Request $request, ZukkoService $zukko)
{
    $data = $request->validate(['message' => 'required|string|max:4000']);

    return response()->stream(function () use ($zukko, $data) {
        foreach ($zukko->stream([['role' => 'user', 'content' => $data['message']]]) as $text) {
            echo $text;
            if (ob_get_level() > 0) {
                ob_flush();
            }
            flush();
        }
    }, 200, [
        'Content-Type' => 'text/plain; charset=utf-8',
        'X-Accel-Buffering' => 'no',
    ]);
}

The X-Accel-Buffering: no header disables buffering in nginx. Under PHP-FPM a streaming response holds a worker for the whole generation — take that into account when sizing pm.max_children.

Queued job

Process long documents in the background. Create the job with php artisan make:job ProcessTextWithLLM and fill it in:

bash
<?php

namespace App\Jobs;

use App\Models\ProcessedText;
use App\Services\ZukkoService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\RequestException;
use Throwable;

class ProcessTextWithLLM implements ShouldQueue
{
    use Queueable;

Temporary errors (429, 503, network) cause a retry after 60 seconds, at most three attempts. Key and balance errors end the job immediately — failed() records the reason. Dispatch with ProcessTextWithLLM::dispatch($processedText); and run the worker with php artisan queue:work.

If a model is disabled, the API returns 503 with the code model_disabled. GET https://api.zukko.pro/v1/models returns the current model list (the data[].id field), which you can use to pick a replacement. The same list is on the "Models" page in the dashboard.

Tests with Http::fake

bash
use App\Services\ZukkoService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

public function test_chat_returns_model_answer(): void
{
    config(['services.zukko.api_key' => 'test-key']);
    Http::fake([
        'api.zukko.pro/*' => Http::response([
            'choices' => [['message' => ['content' => 'Done']]],
            'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 2],
        ]),
    ]);

    $answer = app(ZukkoService::class)->chat([['role' => 'user', 'content' => 'test']]);

    $this->assertSame('Done', $answer);
    Http::assertSent(fn (Request $request) =>
        $request->hasHeader('Authorization', 'Bearer test-key')
        && $request['model'] === 'claude-sonnet-5');
}

Http::fake intercepts all requests to the given domain, so tests don't spend your balance.

Cost and model choice

The model is set by the ZUKKO_MODEL variable: claude-sonnet-5, gpt-5.6-sol, glm-5.3-flash and others from the price list. Compare which suits your application on your own requests. Example for claude-sonnet-5 ($0.3 per 1M input and output tokens): a request with 1,000 input and 400 output tokens costs (1,000 × 0.3 + 400 × 0.3) / 1,000,000 = $0.00042. Tokens are logged from the usage field, and total spend is visible in the "Usage" section of the dashboard. The request format is described in the chat completions reference, and authorization in the authentication section.

FAQ

Do I need the openai-php/laravel package?

Not necessarily. The built-in HTTP client is enough for chat, streaming and tests. The package may be more convenient if you need typed responses and many different endpoints — then set the base URL https://api.zukko.pro/v1 in its settings and check its documentation.

Can I pass temperature?

For GPT and GLM, yes, via the $options array. Newer Claude models (claude-sonnet-5, claude-opus-5-5) may reject a temperature other than 1 and a top_p below 0.99 with a 400 error, so better not to pass these parameters to them.

How do I count tokens before sending a request?

There's the POST https://api.zukko.pro/v1/messages/count_tokens endpoint in Anthropic format: send model and messages with the anthropic-version: 2023-06-01 header, and the response contains input_tokens. See the count_tokens reference.

Why does the streaming response arrive all at once?

Check buffering: PHP output_buffering, response compression and proxy buffering. The X-Accel-Buffering: no header and flush() calls fix most cases.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

LLM integration in Laravel: Http client, service, queues · Zukko