The driver is where the provider's actual API lives, and it is the only place that is allowed to know about it.
final class OpenaiAPI implements AiContract
{
use SendsRequests;
public function __construct(
protected PendingRequest $request
) {}
/**
* @return array<string, mixed>
*/
public function getSiteContent(string $model, string $prompt): array
{
$data = $this->send('POST', '/chat/completions', [
'model' => $model,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'response_format' => ['type' => 'json_object'],
]);
$content = (string) $data->json('choices.0.message.content');
$result = json_decode($content, true);
if (! is_array($result) || $result === []) {
throw new \RuntimeException(
'AI site content response could not be decoded into a non-empty array.'
);
}
/** @var array<string, mixed> $result */
return $result;
}
}
Two things here are worth more than they look.
The driver receives a configured PendingRequest, not a token. It does not know where its credentials came from, how long its timeout is, or what its retry policy is. That is the manager's job, and it means the driver stays a thin translation layer that is trivial to fake in a test.
The driver's guarantee is transport-level, and it stops there. It promises you decodable, non-empty JSON. It deliberately does not validate that the JSON has the keys your feature needs, because the driver has no idea what your feature needs โ that check belongs where the caller's context is known, and it gets a chapter of its own. Mixing the two produces a driver that has to be edited every time a feature changes its mind.
That throw matters more than it looks, too. The alternative โ returning null or [] on garbage โ pushes an unfalsifiable "did it work?" question into every caller. Fail here, once, loudly.
The Manager, Where the Decisions Live
The manager extends Laravel's own Manager, which means driver resolution, caching and custom creators come for free. What you add is configuration and policy.
final class AiManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) Config::get('services.ai.default');
}
public function createOpenaiDriver(): AiContract
{
return $this->buildDriver(
driverClass: OpenaiAPI::class,
config: $this->getConfig('openai'),
);
}
}
Adding a second provider is now genuinely additive: write a driver, add a createXDriver() method, add a config block. No caller changes. No controller changes.
And buildDriver is where every AI call in the application inherits its manners:
protected function buildDriver(string $driverClass, array $config = []): AiContract
{
$this->ensureValidDriver($driverClass);
$token = $config['token'] ?? '';
if (! is_string($token) || $token === '') {
throw new InvalidArgumentException(
message: 'AI driver token is not configured; expected a per-tenant credential or an environment fallback.',
code: Response::HTTP_INTERNAL_SERVER_ERROR,
);
}
return new $driverClass(
request: Http::baseUrl($config['url'] ?? '')
->withToken($token)
->timeout(Config::integer('services.ai.config.timeout', default: 60))
->retry(
times: max(1, Config::integer('services.ai.config.retry_times', default: 3)),
sleepMilliseconds: max(0, Config::integer('services.ai.config.retry_sleep_ms', default: 500)),
when: fn (Throwable $e): bool => self::shouldRetry($e),
)
->throw()
);
}
Every AI call in the application now has a timeout, a retry policy and a validated credential, and none of it is repeated anywhere.