The portfolio runs against four AI providers depending on what's available: Claude for chat, OpenAI for backup, Groq for low-latency completions, and Ollama for local embeddings. They have different APIs, different rate limits, and different failure modes. The driver pattern keeps the calling code clean.
The Pattern
Laravel's storage and mail drivers are the model. One contract, multiple implementations, runtime selection.
interface AiProvider
{
public function complete(string $prompt, array $options = []): string;
public function embed(string $text): array;
public function name(): string;
}
The Manager
final class AiProviderManager
{
/** @var array<string, AiProvider> */
private array $drivers = [];
public function __construct(private array $config) {}
public function driver(?string $name = null): AiProvider
{
$name ??= $this->config['default'];
return $this->drivers[$name] ??= $this->resolve($name);
}
private function resolve(string $name): AiProvider
{
return match ($name) {
'claude' => new ClaudeProvider($this->config['claude']),
'openai' => new OpenAiProvider($this->config['openai']),
'groq' => new GroqProvider($this->config['groq']),
'ollama' => new OllamaProvider($this->config['ollama']),
default => throw new InvalidArgumentException("Unknown provider [{$name}]"),
};
}
}
Bind it as a singleton in a service provider:
$this->app->singleton(AiProviderManager::class, function ($app) {
return new AiProviderManager(config('ai'));
});
A Single Provider
final class ClaudeProvider implements AiProvider
{
public function __construct(private array $config) {}
public function complete(string $prompt, array $options = []): string
{
$response = Http::withHeaders([
'x-api-key' => $this->config['key'],
'anthropic-version' => '2023-06-01',
])
->timeout($options['timeout'] ?? 30)
->post('https://api.anthropic.com/v1/messages', [
'model' => $options['model'] ?? 'claude-sonnet-4-6',
'max_tokens' => $options['max_tokens'] ?? 1024,
'messages' => [['role' => 'user', 'content' => $prompt]],
]);
$response->throw();
return $response->json('content.0.text');
}
public function embed(string $text): array
{
throw new RuntimeException('Claude does not provide embeddings — use Voyage or Ollama.');
}
public function name(): string
{
return 'claude';
}
}
The embed() throw is intentional. Each provider is honest about what it doesn't do — the manager decides which provider gets which call.
Failover
Single-provider failure handling is brittle. Wrap the manager in a failover:
final class FailoverProvider implements AiProvider
{
/** @param list<AiProvider> $providers */
public function __construct(private array $providers) {}
public function complete(string $prompt, array $options = []): string
{
$errors = [];
foreach ($this->providers as $provider) {
try {
return $provider->complete($prompt, $options);
} catch (Throwable $e) {
$errors[$provider->name()] = $e->getMessage();
Log::warning("AI provider [{$provider->name()}] failed", ['error' => $e->getMessage()]);
}
}
throw new AllProvidersFailedException($errors);
}
public function embed(string $text): array
{
// similar
}
public function name(): string
{
return 'failover';
}
}
Wire it from config:
'failover' => [
'completion' => ['claude', 'openai', 'groq'],
'embedding' => ['ollama', 'voyage'],
],
Calling It
Application code stays clean:
$answer = app(AiProviderManager::class)
->driver()
->complete($prompt, ['max_tokens' => 512]);
Or inject in constructors:
final class ChatController
{
public function __construct(private AiProviderManager $ai) {}
public function __invoke(Request $request): JsonResponse
{
$reply = $this->ai->driver()->complete($request->input('message'));
return response()->json(['reply' => $reply]);
}
}
Testing
it('falls over to the next provider when the first fails', function () {
$first = Mockery::mock(AiProvider::class);
$first->shouldReceive('complete')->andThrow(new RuntimeException('rate limited'));
$first->shouldReceive('name')->andReturn('first');
$second = Mockery::mock(AiProvider::class);
$second->shouldReceive('complete')->andReturn('answer from second');
$failover = new FailoverProvider([$first, $second]);
expect($failover->complete('hi'))->toBe('answer from second');
});
Mocking the contract means tests don't hit any real API.
Why This Beats if ($provider === 'openai') Chains
- New providers add one class, no edits to callers
- Test seams are at the contract, not at HTTP
- Failover policy lives in one place
- Per-provider config is isolated
- Streaming, retries, rate-limit headers — all extend the same interface
The only judgement call: don't over-design. If you only ever use one provider, you don't need this. The moment you add a second, refactor.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
Cutting Claude API Costs by 89% with Prompt Caching
Newer →
RAG on SQLite: Pure-PHP Cosine Similarity Without pgvector
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.