That token check deserves its own section, because skipping it costs a genuinely miserable afternoon.
If the credential is missing and you do nothing, Laravel cheerfully sends Authorization: Bearer — the header exists, its value is empty. The provider rejects it with an authentication error whose message says, in effect, you did not provide an API key.
Which is true, and completely misleading, because the code that reads the key ran fine and the config value exists. You will go looking for a revoked key, a wrong environment, a clock skew, a proxy stripping headers. The actual answer is that a per-tenant credential resolved to an empty string and nobody checked.
Fail at the boundary, with a message that names both places the value could have come from. That error should tell the next person exactly where to look, because the next person is you in four months.
This generalises. A misconfiguration should fail where it is configured, not where it is used. The further those two points drift apart, the more expensive the bug.
Credentials Belong to a Tenant, Not to the Application
Here is the decision that most first implementations get wrong, and it is very hard to retrofit.
A single application-wide API key means every tenant's usage lands in one bucket. You cannot attribute spend. You cannot cap one noisy tenant without capping all of them. You cannot let a customer bring their own key. And if that key is rotated or rate-limited, every tenant fails at once.
So the config for a driver resolves per tenant, with an environment fallback for the ones who have not brought their own:
protected function getConfig(string $driver): array
{
return (array) Config::get("services.ai.drivers.{$driver}", default: []);
}
The important part is not the method — it is that the values behind it are resolved from the authenticated caller's settings before the driver is built, and only fall back to a shared environment value when there is nothing tenant-specific.
Do this on day one. Retrofitting per-tenant credentials into a system that assumed one global key means touching every call site, every test and every cache key, usually under pressure, usually because a bill arrived.
Retry What Is Worth Retrying
Retries are where good intentions produce bad systems. The default instinct — "retry three times on failure" — quietly turns one malformed request into three, and one billing error into three.
The policy that survives contact with a real provider distinguishes between failures that might resolve themselves and failures that definitely will not.
private static function shouldRetry(Throwable $e): bool
{
if ($e instanceof ConnectionException) {
return true;
}
if ($e instanceof RequestException) {
$status = $e->response->status();
return $status === 429 || ($status >= 500 && $status < 600);
}
return false;
}
Connection failures and provider 5xx are transient — the same request may well succeed a moment later. A 429 is explicit backpressure, and is the one case where the provider has told you it is worth waiting.
Everything else is not retried, and the important member of that set is the 4xx. A malformed request will be malformed on the second attempt too. Retrying it wastes latency, wastes rate limit, and — depending on what you sent and how the provider counts — can waste money. Worse, it hides the bug: an error you retry three times is an error you notice three times more slowly.
The final clause matters as much as the first two. Anything that is not an HTTP failure — a JSON decode error, a type error, a bug in your own mapping code — must not be retried. Those are your problems, and running them again does nothing but delay the stack trace.