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.
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./chat/completions endpoint, so the method names line up with the OpenRouter API docs. Reading the facade tells you exactly what's exposed.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.
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();
}
}
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 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.
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.
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.
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.
/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()
);
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:
config/openrouter.php — timeouts, defaults, headers.LlmClient interface. When you outgrow it (multi-provider, on-prem, prompt versioning) you swap one binding instead of every call site.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.
How can I help you explore Laravel packages today?