Tell me for any kind of development solution

Edit Template

How to Use OpenRouter with Laravel: Free Models, Fallbacks and Error Handling

OpenRouter gives you one API key for hundreds of models from OpenAI, Anthropic, Google, Meta, Mistral, DeepSeek and others, including a rotating set of free ones. Because its API follows the OpenAI format, an OpenRouter Laravel integration needs nothing more than Laravel’s built-in HTTP client.

This guide builds that integration step by step: configuration, a reusable service class, free models, automatic fallbacks, streaming, queued jobs, and handling the errors that developers most often run into.

Still choosing a provider? See OpenRouter vs Hugging Face and our list of free LLM APIs for developers.

Step 1: Get an API Key and Configure Laravel

Every OpenRouter Laravel setup starts with an API key. Create one at openrouter.ai/settings/keys. Set a credit limit on the key so a bug can never run up a large bill.

Add the settings to config/services.php:

PHP
'openrouter' => [
    'key'      => env('OPENROUTER_API_KEY'),
    'base_url' => env('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
    'model'    => env('OPENROUTER_MODEL', 'openrouter/free'),
    'fallbacks' => array_filter(explode(',', env('OPENROUTER_FALLBACKS', ''))),
    'timeout'  => (int) env('OPENROUTER_TIMEOUT', 60),
    'site_url' => env('APP_URL'),
    'app_name' => env('APP_NAME'),
],

And to .env:

BASH
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_MODEL=openrouter/free
OPENROUTER_FALLBACKS=
OPENROUTER_TIMEOUT=60

openrouter/free is a router that picks a free model at random from those that support your request’s features (tools, images, structured output). It’s ideal for development. For anything users depend on, set a specific model ID from the models page.

Step 2: Build an OpenRouter Laravel Service Class

Keep every OpenRouter call in one OpenRouter Laravel service class, so the rest of your app never deals with HTTP details:

PHP
namespace App\Services;

use App\Exceptions\OpenRouterException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class OpenRouter
{
    public function chat(array $messages, ?string $model = null, array $options = []): array
    {
        $payload = array_merge([
            'model'    => $model ?? config('services.openrouter.model'),
            'messages' => $messages,
        ], $options);

        if ($fallbacks = config('services.openrouter.fallbacks')) {
            $payload['models'] = $fallbacks;
        }

        $response = $this->client()->post('/chat/completions', $payload);

        return $this->parse($response);
    }

    public function ask(string $prompt, ?string $system = null): string
    {
        $messages = array_values(array_filter([
            $system ? ['role' => 'system', 'content' => $system] : null,
            ['role' => 'user', 'content' => $prompt],
        ]));

        return $this->chat($messages)['content'];
    }

    protected function client(): PendingRequest
    {
        return Http::baseUrl(config('services.openrouter.base_url'))
            ->withToken(config('services.openrouter.key'))
            ->withHeaders([
                'HTTP-Referer' => config('services.openrouter.site_url'),
                'X-Title'      => config('services.openrouter.app_name'),
            ])
            ->acceptJson()
            ->timeout(config('services.openrouter.timeout'));
    }

    protected function parse(Response $response): array
    {
        $data = $response->json();

        // Errors can arrive as a non-2xx status OR inside a 200 body
        if ($response->failed() || isset($data['error'])) {
            throw OpenRouterException::fromResponse($response->status(), $data['error'] ?? []);
        }

        return [
            'content' => $data['choices'][0]['message']['content'] ?? '',
            'model'   => $data['model'] ?? null,   // the model that actually answered
            'usage'   => $data['usage'] ?? [],
        ];
    }
}

The HTTP-Referer and X-Title headers are optional. They attribute usage to your app in OpenRouter’s public rankings.

Using it anywhere in your app:

PHP
$summary = app(\App\Services\OpenRouter::class)
    ->ask("Summarise in one sentence:\n\n" . $post->body, 'You are a concise technical editor.');

Step 3: Turn API Errors into Useful Exceptions

OpenRouter returns errors as JSON with a code, a message and sometimes metadata (error reference). Different codes need different handling, so give them meaning in one exception class that the rest of your OpenRouter Laravel code can check:

PHP
namespace App\Exceptions;

use RuntimeException;

class OpenRouterException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status,
        public readonly array $metadata = [],
    ) {
        parent::__construct($message, $status);
    }

    public static function fromResponse(int $status, array $error): self
    {
        $code = (int) ($error['code'] ?? $status);

        return new self($error['message'] ?? "OpenRouter error $code", $code, $error['metadata'] ?? []);
    }

    /** Worth retrying later: rate limits, timeouts, provider outages */
    public function isRetryable(): bool
    {
        return in_array($this->status, [408, 429, 502, 503], true);
    }

    /** Out of credits: retrying will never help */
    public function isBillingProblem(): bool
    {
        return $this->status === 402;
    }
}

What each status means in practice:

CodeMeaningWhat to do
401Invalid or disabled API keyCheck OPENROUTER_API_KEY; don’t retry
402Insufficient credits (also shown for free models when your balance is negative)Add credits; don’t retry, every retry also fails
403Moderation flag, guardrail, or a key’s budget is spentCheck metadata for the reason
404Model not found, or no endpoint matches your settingsCheck the model ID and your privacy settings (see below)
408Request timed outRetry, or raise your timeout
429Rate limited, by OpenRouter or the upstream providerRetry after the Retry-After delay, or fall back to another model
502The model’s provider is down or returned an invalid responseRetry or fall back
503No provider meets your routing requirementsFall back to another model

Step 4: Add Automatic Model Fallbacks

Free models get rate-limited at busy times, and any provider can have an outage, so a production OpenRouter Laravel integration needs fallbacks. OpenRouter can handle this for you: send a models array, and if the main model fails (rate limit, downtime, context length, moderation), it tries the next one (model fallbacks).

The service class already supports this through .env:

BASH
OPENROUTER_MODEL=vendor/primary-model:free
OPENROUTER_FALLBACKS=vendor/second-model:free,vendor/paid-model

Two things to know:

  • Fallbacks can cost money. If a :free model falls back to a paid one, you’re billed at the paid model’s price. Keep the list free-only if you never want charges.
  • Check which model answered. The response’s model field shows the model that was actually used; the service returns it as $result['model']. Log it, so you know how often fallbacks happen.

Step 5: Run AI Calls in Queued Jobs

LLM calls can take several seconds, and longer for reasoning models. Don’t make users wait on them inside a web request; in an OpenRouter Laravel app, AI work belongs in the queue. Put them in a queued job and let Laravel handle retries:

PHP
namespace App\Jobs;

use App\Exceptions\OpenRouterException;
use App\Models\Post;
use App\Services\OpenRouter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class GenerateSummary implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 120;

    public function __construct(public Post $post) {}

    public function backoff(): array
    {
        return [10, 30, 90];   // seconds between attempts
    }

    public function handle(OpenRouter $openRouter): void
    {
        try {
            $summary = $openRouter->ask("Summarise in two sentences:\n\n" . $this->post->body);
        } catch (OpenRouterException $e) {
            if ($e->isBillingProblem()) {
                $this->fail($e);   // stop now: retries can't fix a 402
                return;
            }

            throw $e;              // 429, 502, 503... retried with backoff
        }

        $this->post->update(['summary' => $summary]);
    }
}

Dispatch it with GenerateSummary::dispatch($post);. Failing immediately on a 402 matters: retrying a request that can only return “insufficient credits” just adds more failed calls.

To keep a whole app under OpenRouter’s free-model limit of 20 requests per minute, wrap the call in Laravel’s rate limiter middleware or a RateLimiter::attempt() block. More tactics are in staying within free-tier AI API limits.

Step 6: Stream Responses to the Browser

For OpenRouter Laravel chat interfaces, stream tokens as they’re generated instead of waiting for the full answer. Set stream: true and pass the Server-Sent Events through to the browser:

PHP
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

Route::post('/chat/stream', function (Request $request) {
    $upstream = Http::withToken(config('services.openrouter.key'))
        ->withOptions(['stream' => true])
        ->timeout(120)
        ->post(config('services.openrouter.base_url') . '/chat/completions', [
            'model'    => config('services.openrouter.model'),
            'stream'   => true,
            'messages' => [['role' => 'user', 'content' => $request->input('message')]],
        ]);

    return response()->stream(function () use ($upstream) {
        $body = $upstream->toPsrResponse()->getBody();
        $buffer = '';

        while (! $body->eof()) {
            $buffer .= $body->read(1024);

            while (($pos = strpos($buffer, "\n")) !== false) {
                $line = trim(substr($buffer, 0, $pos));
                $buffer = substr($buffer, $pos + 1);

                // Skip keep-alive comments like ": OPENROUTER PROCESSING"
                if ($line === '' || str_starts_with($line, ':')) {
                    continue;
                }

                if ($line === 'data: [DONE]') {
                    return;
                }

                $chunk = json_decode(substr($line, 6), true);

                // Errors mid-stream arrive inside a 200 response
                if (isset($chunk['error'])) {
                    echo "event: error\ndata: " . json_encode($chunk['error']['message']) . "\n\n";
                    flush();
                    return;
                }

                $text = $chunk['choices'][0]['delta']['content'] ?? '';
                if ($text !== '') {
                    echo 'data: ' . json_encode($text) . "\n\n";
                    flush();
                }
            }
        }
    }, 200, [
        'Content-Type'      => 'text/event-stream',
        'Cache-Control'     => 'no-cache',
        'X-Accel-Buffering' => 'no',   // stop Nginx buffering the stream
    ]);
});

Two details from OpenRouter’s streaming docs that break naive parsers:

  • Comment lines such as : OPENROUTER PROCESSING are sent to keep the connection alive. Calling json_decode on them fails, so skip lines starting with :.
  • Once streaming starts, the HTTP status stays 200, even if the model fails. Errors arrive as a chunk with an error object, so check every chunk.

Common OpenRouter Laravel Errors (and Fixes)

These are the problems developers most often report with an OpenRouter Laravel (or plain PHP) integration.

“No endpoints found matching your data policy”

This 404 shows up with free models. Many free models are served by providers that may use prompts for training, and if your account’s privacy settings exclude those providers, no endpoint is left to serve the request. Open openrouter.ai/settings/privacy and allow the free endpoints, or switch to a paid model. Don’t send personal or customer data to free models whose providers may train on it.

“Model is temporarily rate-limited upstream” (429)

The free model’s provider is overloaded, not your account. Retry later, add fallback models (Step 4), or use the paid version of the same model.

402 even though you only use free models

OpenRouter blocks requests, including free ones, when your credit balance is negative. Add credits to bring it above zero.

Requests time out on long answers

Laravel’s HTTP client times out after 30 seconds by default, and reasoning models can take longer. Raise OPENROUTER_TIMEOUT, and keep long calls in queued jobs with a larger $timeout.

A model ID that worked yesterday now fails

Free variants are added and removed regularly. Keep model IDs in .env (never hard-coded), and keep a fallback list so one removed model doesn’t take your feature down.

Creating more API keys doesn’t raise the limit

Rate limits apply to your account as a whole, so extra keys don’t add capacity. The daily free-model limit rises from 50 to 1,000 requests once you’ve bought at least 10 credits in total (OpenRouter limits).

Using a Package Instead

If you’d rather not maintain your own class, the community package moe-mizrak/laravel-openrouter wraps the API with a facade and typed request objects:

BASH
composer require moe-mizrak/laravel-openrouter
php artisan vendor:publish --tag=laravel-openrouter

It reads OPENROUTER_API_KEY from .env. Its default timeout is short, so raise it for slower models. The hand-written class above has the advantage of zero dependencies and full control over errors and fallbacks; the package saves setup time.

Testing Your OpenRouter Laravel Integration

Never call the real API in tests of your OpenRouter Laravel code. Laravel’s Http::fake() makes the service class easy to test, including error paths:

PHP
use App\Exceptions\OpenRouterException;
use App\Services\OpenRouter;
use Illuminate\Support\Facades\Http;

it('returns the model answer', function () {
    Http::fake([
        'openrouter.ai/*' => Http::response([
            'model'   => 'vendor/model:free',
            'choices' => [['message' => ['content' => 'Hello from the model']]],
        ]),
    ]);

    expect(app(OpenRouter::class)->ask('Hi'))->toBe('Hello from the model');
});

it('treats 402 as a billing problem', function () {
    Http::fake([
        'openrouter.ai/*' => Http::response(['error' => ['code' => 402, 'message' => 'Insufficient credits']], 402),
    ]);

    $e = null;

    try {
        app(OpenRouter::class)->ask('Hi');
    } catch (OpenRouterException $caught) {
        $e = $caught;
    }

    expect($e)->not->toBeNull()
        ->and($e->isBillingProblem())->toBeTrue()
        ->and($e->isRetryable())->toBeFalse();
});

FAQ

Is OpenRouter free to use with Laravel?

Yes, with limits. Free models (IDs ending in :free, or the openrouter/free router) cost nothing, at up to 20 requests per minute and 50 per day, or 1,000 per day after a one-time purchase of 10 credits. Other models are billed per token.

Do I need a package to use OpenRouter in Laravel?

No. OpenRouter follows the OpenAI API format, so Laravel’s Http client is enough, as shown above. Packages like moe-mizrak/laravel-openrouter or openai-php/client (with a custom base URL) are optional.

Can I use OpenRouter with the official OpenAI PHP client?

Yes. Point openai-php/client at https://openrouter.ai/api/v1 with your OpenRouter key; examples are in our OpenRouter vs Hugging Face guide.

How do I stop my app breaking when a free model disappears?

Keep model IDs in .env, use the models fallback array, and log the model field from each response so you notice when fallbacks start happening.

Can I build a chatbot with OpenRouter and Laravel?

Yes. Combine the service class with a conversation history table and the streaming endpoint above. For a complete walkthrough, see building an AI chatbot with Laravel and free LLM APIs, or the Laravel AI SDK RAG chatbot tutorial.

Share Article:

© 2025 Created by ArtisansTech