Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Openrouter Laravel Package

moe-mizrak/laravel-openrouter

A Laravel package for integrating OpenRouter's API, giving your app a clean, fluent way to access hundreds of AI models for chat completions, streaming, and more. Includes configurable providers, request handling, and Laravel-friendly tooling.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish config

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

2. Set env

OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_DEFAULT_MODEL=anthropic/claude-3.5-sonnet

There's no service provider registration in modern Laravel — package discovery handles it. If you're on an older version, check the README for the manual provider line.

3. Where to look first

  • config/openrouter.php — API key, base URL, default model, timeout, and any provider-routing defaults. Almost every "why isn't this working" question resolves here.
  • The facade / manager class — the package is a thin, fluent wrapper over OpenRouter's /chat/completions endpoint, so the method names line up with the OpenRouter API docs. Reading the facade tells you exactly what's exposed.
  • The tests directory in the repo — the fastest way to see real request/response shapes without spending tokens.

4. First use case: a sanity check in tinker

// php artisan tinker
use MoeMizrak\LaravelOpenRouter\Facades\OpenRouter;

$response = OpenRouter::chat()
    ->model('anthropic/claude-3.5-sonnet')
    ->messages([
        ['role' => 'system', 'content' => 'You are terse.'],
        ['role' => 'user',   'content' => 'Name three Laravel testing helpers.'],
    ])
    ->send();

dump($response->content());   // text
dump($response->usage());     // tokens + cost

If that works, everything else is variations on the same call. Start by shipping one visible feature — a summarizer, a support-ticket triager, a commit-message generator — before building abstractions around it.


Implementation Patterns

Facade for scripts, DI for application code

The facade is fine in tinker, migrations, and one-off commands. In services and controllers, inject the client so you can swap a fake in tests:

final class TicketSummarizer
{
    public function __construct(private OpenRouter $client) {}

    public function summarize(string $body): string
    {
        return $this->client->chat()
            ->model(config('openrouter.default_model'))
            ->messages([
                ['role' => 'system', 'content' => 'Summarize in one sentence.'],
                ['role' => 'user', 'content' => $body],
            ])
            ->send()
            ->content();
    }
}

Always wrap LLM calls in a Job

LLM calls take 2–30 seconds. Never call the API inline in a web request unless you're streaming (below). A queued job gives you retries, backoff, and timeouts for free:

final class SummarizeTicket implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 15;
    public int $timeout = 90;

    public function __construct(public int $ticketId) {}

    public function handle(TicketSummarizer $summarizer): void
    {
        $ticket = Ticket::findOrFail($this->ticketId);
        $ticket->update(['summary' => $summarizer->summarize($ticket->body)]);
    }
}

Set $timeout above the package timeout, and the package timeout above your slowest expected model.

Streaming belongs in HTTP controllers only

Streaming is the package's main "feels magic" feature and its biggest footgun. The pattern:

Route::get('/chat/stream', function (Request $request) {
    return response()->stream(function () use ($request) {
        foreach (OpenRouter::chat()->model($request->model)->messages($request->messages)->stream() as $chunk) {
            echo "data: {$chunk->delta()}\n\n";
            ob_flush(); flush();
        }
        echo "data: [DONE]\n\n";
    }, 200, ['Content-Type' => 'text/event-stream', 'X-Accel-Buffering' => 'no']);
});

Use stream() for anything user-facing (perceived latency), send() for jobs. You cannot stream usefully from a queued worker to a browser without a broadcasting layer, so don't try.

OpenRouter-specific routing: use it

The main reason to reach for OpenRouter over a direct provider SDK is model routing. Two patterns worth adopting day-one:

// Fallback chain — if Anthropic is down, try OpenAI, then Google
OpenRouter::chat()
    ->models(['anthropic/claude-3.5-sonnet', 'openai/gpt-4o', 'google/gemini-pro'])
    ->messages($messages)
    ->send();

// Cost/latency variants
->model('meta-llama/llama-3.1-70b-instruct:floor')  // cheapest provider
->model('meta-llama/llama-3.1-70b-instruct:nitro')  // fastest provider

If the package exposes a provider() or route() config block (check the config file), use it to pin providers you've validated against your prompts. Cheap models can be surprisingly bad at JSON; always test with the exact model you ship.

Tool calling / structured output

For anything that needs to write to your database or trigger side effects, use tool calling rather than parsing free text:

OpenRouter::chat()
    ->model('openai/gpt-4o')
    ->messages($messages)
    ->tools([[
        'type' => 'function',
        'function' => [
            'name' => 'create_task',
            'description' => 'Create a task',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'title'    => ['type' => 'string'],
                    'due_date' => ['type' => 'string', 'format' => 'date'],
                ],
                'required' => ['title'],
            ],
        ],
    ]])
    ->toolChoice('auto')
    ->send();

Loop until the model stops emitting tool calls; validate arguments against a FormRequest or a spatie/laravel-data DTO before touching the DB. Treat every tool argument as user input.

Testing: fake the HTTP layer, never the API

The package sits on Laravel's HTTP client, so Http::fake() works. Pin the exact response shape once and reuse it:

protected function setUp(): void
{
    parent::setUp();
    Http::fake([
        'openrouter.ai/*' => Http::response([
            'id' => 'gen-123',
            'choices' => [[
                'message' => ['role' => 'assistant', 'content' => 'mocked reply'],
                'finish_reason' => 'stop',
            ]],
            'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15],
        ]),
    ]);
}

public function test_it_summarizes_a_ticket(): void
{
    (new SummarizeTicket(1))->handle(app(TicketSummarizer::class));

    $this->assertDatabaseHas('tickets', ['summary' => 'mocked reply']);
    Http::assertSent(fn ($req) => $req['model'] === 'anthropic/claude-3.5-sonnet');
}

Never let CI hit the real API — you'll get flaky tests, mysterious cost spikes, and rate-limit failures on busy days.

Cache the model catalogue

/models changes rarely and is a separate request. Cache it for a day if you build a model picker or validate model strings before shipping a request:

$models = Cache::remember('openrouter.models', now()->addDay(), fn () =>
    OpenRouter::models()->list()
);

Gotchas and Tips

Timeouts are almost always the first bug. Laravel's default HTTP timeout is generous, but not 30-second-streaming generous. Set the package timeout in config/openrouter.php (usually OPENROUTER_TIMEOUT) to something like 120, and make sure queue worker timeouts and PHP max_execution_time exceed it.

Streaming breaks behind proxies. Nginx buffers SSE by default. Send X-Accel-Buffering: no and Content-Type: text/event-stream, disable output buffering, and flush after each chunk. If your stream arrives all at once at the end, this is why.

API keys leak through exceptions and logs. The package wraps Guzzle, and Guzzle's RequestException can include headers in some configurations. Configure a redact list for OPENROUTER_API_KEY in config/logging.php and never dd($request) a live client. Rotate keys if you suspect a leak.

Model strings are strings — typos fail at request time, not boot time. There's no enum. Pull valid IDs from /models or from a config constant, and assert against it in a test. anthropic/claude-3-5-sonnet (hyphen) vs anthropic/claude-3.5-sonnet (dot) is a real class of bug that will cost you a deploy.

OpenRouter returns 200 with provider errors embedded. Check choices[0] actually exists — a fallback may have failed silently, or the response may contain an error key with a 200 status. Write a defensive accessor once rather than sprinkling ?? everywhere.

Cost is not free and not obvious. OpenRouter bills per request at provider rates. Every ->send() in a loop is money. If you don't already, log usage.total_cost (OpenRouter includes credit usage in the response) to a llm_calls table with user, prompt hash, model, and tokens. You'll want it the first time finance asks. Also cap max_tokens — a runaway completion is a runaway bill.

Set HTTP-Referer and X-Title headers. OpenRouter uses these for ranking and attribution on your account. The config file usually exposes them; set them to your app's URL and name. Without them you still work, but you get less useful analytics.

429s need backoff, not retries. Use ->backoff() in the job and treat 429 as "slow down," not "try again immediately." Same for 503s during provider outages — the fallback models([...]) array will route around a dead provider, which is far better than a retry storm against the same one.

Provider data policies vary. OpenRouter routes to many providers with different retention rules. If you're handling PII, pin providers whose policies you've reviewed (via provider preferences in the config) rather than letting the router pick the cheapest.

Version-pin the package. With a small maintainer (159 stars), pin to an exact tag in composer.json and read the diff before bumping. The underlying OpenRouter API does evolve (new fields, deprecated ones), so a minor package bump can change request behavior.

Extension points, in order of preference:

  1. Publish and edit config/openrouter.php — timeouts, defaults, headers.
  2. Wrap the package behind your own LlmClient interface. When you outgrow it (multi-provider, on-prem, prompt versioning) you swap one binding instead of every call site.
  3. Use Laravel macros on the facade if you genuinely need a shortcut everywhere — but macros hide dependencies, so use them sparingly.
  4. Register a custom Guzzle handler if you need per-request observability (tracing, structured logs) beyond what the facade exposes.

Debugging slow or wrong responses: log the raw request payload without the API key, then paste it into curl against OpenRouter directly. If it reproduces there, it's your prompt; if not, it's the package. That split saves hours.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package