In 2026, almost every Laravel project integrates 3–10 external APIs: OpenAI, Stripe, Telegram, AWS S3, Resend, Brevo, and so on.
Yet most key leaks happen not because of sophisticated attacks, but due to silly mistakes: committing to git, logging them, sending them to the frontend, or calling env() in production after config:cache.
Today we’ll walk through a battle-tested path — from “it just works” to “I sleep peacefully”.
1. Basic (but already correct) level — .env + config
Never hard-code keys in your source:
// ❌ Bad
$openai = new OpenAI('sk-123...');
// ✅ Good
# .env
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_live_...
// config/services.php
return [
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'organization' => env('OPENAI_ORG'),
],
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET_KEY'),
],
];
Usage:
$key = config('services.openai.api_key');
// or with type safety (Laravel 11+)
$key = Config::string('services.openai.api_key');
Why is config better than env() everywhere?
After php artisan config:cache, any env() call outside config files returns null.
That’s why every env() should live only inside config/*.php files.
Golden rule:
env() — only in config files.
Everywhere else — config('path.to.key').
2. Middle level: type-safe configs + DTO
A plain string is fragile. Let’s make it strongly typed.
Create a value object:
// app/Config/OpenAiConfig.php
final readonly class OpenAiConfig
{
public function __construct(
public string $apiKey,
public ?string $organization = null,
public string $baseUrl = 'https://api.openai.com/v1',
) {
if (empty($this->apiKey)) {
throw new RuntimeException('OpenAI API key is missing');
}
}
public static function make(): self
{
return new self(
apiKey: Config::string('services.openai.api_key'),
organization: Config::string('services.openai.organization', null),
);
}
}
Register it as a singleton:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(OpenAiConfig::class, fn () => OpenAiConfig::make());
}
Usage anywhere:
$openai = app(OpenAiConfig::class);
$client = OpenAI::client($openai->apiKey, $openai->organization);
Benefits:
- IDE autocompletion for fields
- Fail fast at boot — not in production three days later
- Easy to mock / test
3. Zero-downtime key rotation
API providers (OpenAI, Anthropic, Stripe, AWS) recommend rotating keys every 90 days.
But simply replacing the key in .env → every request fails until the next deploy.
Simple fallback approach:
# .env
OPENAI_API_KEY_CURRENT=sk-abc123...
OPENAI_API_KEY_OLD=sk-oldkey...
// app/Services/OpenAiService.php
class OpenAiService
{
private array $keys;
public function __construct()
{
$this->keys = [
Config::string('services.openai.api_key_current'),
Config::string('services.openai.api_key_old', null),
];
}
public function client(): \OpenAI\Client
{
foreach ($this->keys as $key) {
if ($key === null) continue;
try {
$client = OpenAI::client($key);
$client->models()->list(); // quick validation
return $client;
} catch (\Exception $e) {
// log and try next
}
}
throw new RuntimeException('All OpenAI keys failed');
}
}
Even better — grace period (like AWS / Laravel encryption):
- Add new key as CURRENT
- Keep old one as FALLBACK_1, FALLBACK_2…
- Remove old key 1–2 hours later (or after deploy)
For automatic rotation, consider packages like laravel-locksmith (zero-downtime rotation with AWS secrets / other providers).
4. Production-only enhancements
Health check for API keys
Add a /health (or /up) route:
// routes/health.php
Route::get('/health/api-keys', function () {
$status = [
'openai' => Config::string('services.openai.api_key') ? 'OK' : 'MISSING',
'stripe' => Config::string('services.stripe.secret') ? 'OK' : 'MISSING',
// ...
];
$missing = array_keys(array_filter($status, fn($v) => $v === 'MISSING'));
if ($missing) {
abort(500, 'Missing API keys: ' . implode(', ', $missing));
}
return response()->json([
'status' => 'healthy',
'checked_keys' => $status,
]);
})->name('health.api-keys');
Kubernetes, Laravel Forge, Ploi can monitor this endpoint.
Laravel Pennant + feature flags
Temporarily disable a key:
Pennant::define('openai-enabled', fn () => env('OPENAI_ENABLED', true));
if (Pennant::active('openai-enabled')) {
// use it
} else {
// fallback / mock
}
5. Anti-patterns of 2026 (don’t do this)
-
env('KEY')in controllers/services afterconfig:cache - Storing keys in database without encryption (
encrypt()/Crypt::) - Committing .env to git
- Sending keys to frontend (even via VITE_ env)
- Ignoring 429 / invalid key errors without fallback
- Using the same key across all environments
Pre-deploy checklist
- All keys are in .env — .env is not in git
-
env()calls exist only inconfig/*.php -
php artisan config:cacheis running in production - You’re using
Config::string()/ typed config methods - DTO / singleton exists for each service
- Fallback or rotation is implemented
-
/healthroute checks API keys - No keys appear in logs (check
config/logging.php)
Go check your projects right now — one missing key can cost you a fortune.
How do you store and rotate API keys?
Share your approaches in the comments — maybe you have an even cleaner solution!
If you found this useful — give it a ❤️ and save for later.
Secure deploys! 🔐
Top comments (0)