If you have ever deployed a Laravel application and watched it mysteriously return null for values you know exist in your .env file, you have almost certainly encountered this bug. It is one of the most common Laravel mistakes, and it trips up developers at every experience level.
The culprit? Calling env() directly in your application code instead of going through config files.
Why env() Returns null After config:cache
When you run php artisan config:cache, Laravel reads every config file in the config/ directory, resolves all env() calls at that moment, and writes the merged result to a single cached file at bootstrap/cache/config.php. From that point on, Laravel loads configuration exclusively from that cached file and never reads the .env file again.
This means any env() call that lives outside a config file will return null once the config is cached. The .env file is simply not loaded.
Here is what happens under the hood:
- Without cache: Laravel loads
.envvia Dotenv on every request. Allenv()calls work everywhere. - With cache: Laravel skips
.enventirely and loadsbootstrap/cache/config.php. Only values that were resolved inside config files at cache time are available.
This is by design. Config caching gives you a significant performance boost in production because it eliminates filesystem reads and Dotenv parsing on every request. The Laravel documentation explicitly recommends running php artisan config:cache as part of your deployment process.
The Wrong Way
Here is the pattern that will break in production:
// app/Services/PaymentGateway.php
class PaymentGateway
{
public function charge(int $amount): PaymentResult
{
$apiKey = env('STRIPE_SECRET_KEY'); // Returns null when config is cached
return Http::withToken($apiKey)
->post('https://api.stripe.com/v1/charges', [
'amount' => $amount,
]);
}
}
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handle(Request $request): Response
{
$secret = env('WEBHOOK_SECRET'); // Also null when cached
if (! hash_equals($secret, $request->header('X-Signature'))) {
abort(403);
}
// ...
}
}
Both of these will work perfectly on your local machine (where you probably never run config:cache) and then silently fail in production. The $apiKey becomes null, API calls fail, and your webhook validation compares null against an actual signature. Subtle, dangerous bugs.
The Right Way
The fix is straightforward: environment variables go in config files, and your application code reads from config.
Step 1: Define the values in a config file.
// config/services.php
return [
'stripe' => [
'secret' => env('STRIPE_SECRET_KEY'),
'webhook_secret' => env('WEBHOOK_SECRET'),
],
// ... other services
];
Step 2: Read from config in your application code.
// app/Services/PaymentGateway.php
class PaymentGateway
{
public function charge(int $amount): PaymentResult
{
$apiKey = config('services.stripe.secret'); // Always works
return Http::withToken($apiKey)
->post('https://api.stripe.com/v1/charges', [
'amount' => $amount,
]);
}
}
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handle(Request $request): Response
{
$secret = config('services.stripe.webhook_secret'); // Always works
if (! hash_equals($secret, $request->header('X-Signature'))) {
abort(403);
}
// ...
}
}
The config() helper reads from the cached configuration when it exists and falls back to the live config files (which call env()) when it does not. It works correctly in both environments, always.
How to Create Custom Config Files
If your application has its own settings that do not fit into the existing config files, create a new one. It is just a PHP file that returns an array:
// config/myapp.php
return [
'features' => [
'beta_enabled' => env('MYAPP_BETA_ENABLED', false),
'max_upload_size' => env('MYAPP_MAX_UPLOAD_SIZE', 10485760),
],
'integrations' => [
'analytics_id' => env('MYAPP_ANALYTICS_ID'),
'support_email' => env('MYAPP_SUPPORT_EMAIL', 'support@example.com'),
],
];
Then reference these anywhere in your app:
if (config('myapp.features.beta_enabled')) {
// Show beta features
}
$maxSize = config('myapp.features.max_upload_size');
Notice the second parameter on env() calls inside config files. That is the default value, returned when the environment variable is not set. Always provide sensible defaults for non-secret values.
Common Places Developers Make This Mistake
This anti-pattern shows up in predictable spots:
Controllers -- hardcoding env() calls for API keys or feature flags inline rather than pulling from config.
Models -- using env() in accessors, scopes, or boot methods. For example, setting a default value based on an environment variable.
Service classes -- reading credentials directly from the environment instead of accepting them through config or constructor injection.
Middleware -- checking env('APP_ENV') to toggle behavior instead of using config('app.env') or the app()->environment() helper.
Blade templates -- yes, people do this. Never call env() in a view. If you need a value in a template, pass it from the controller or use config().
Scheduled tasks and commands -- Artisan commands that read env() will break when you run config:cache, which is especially frustrating because these commands often run in production via cron.
The rule is simple: env() belongs in config/*.php files. Nowhere else. If you find yourself typing env( in any other file, stop and add the value to a config file instead.
Testing With Different Config Values
One of the best side effects of using config() instead of env() is that your code becomes trivially testable. Laravel provides a clean way to override config values in tests:
class PaymentGatewayTest extends TestCase
{
public function test_charge_uses_configured_api_key(): void
{
config(['services.stripe.secret' => 'sk_test_fake_key']);
$gateway = new PaymentGateway();
// Now the gateway will use 'sk_test_fake_key'
// You can assert against HTTP fakes, etc.
}
public function test_charge_fails_gracefully_without_api_key(): void
{
config(['services.stripe.secret' => null]);
$gateway = new PaymentGateway();
// Assert appropriate error handling
}
}
Compare this to testing code that calls env() directly. You would need to manipulate $_ENV or $_SERVER superglobals, use putenv(), or reach for packages that let you swap .env values at runtime. All of these approaches are fragile, order-dependent, and can leak state between tests.
With config(), you set the value, run the test, and Laravel resets everything automatically between test methods. No state leakage. No hacks.
You can also use this pattern to test environment-specific behavior:
public function test_debug_info_hidden_in_production(): void
{
config(['app.env' => 'production']);
config(['app.debug' => false]);
$response = $this->get('/api/status');
$response->assertJsonMissing(['debug_info']);
}
Finding Existing Violations
If you suspect your codebase has env() calls hiding outside config files, a quick search will surface them:
grep -rn "env(" app/ routes/ resources/views/ --include="*.php" --include="*.blade.php" | grep -v "config/"
Every result from that search is a potential production bug waiting to happen.
Conclusion
The env() function has exactly one job: bridging your .env file and your config files. It is not a general-purpose "read environment variable" helper for your application code. The moment you cache your config (which you should always do in production), any env() call outside of config/*.php returns null.
The fix takes seconds. Move the env() call into a config file, reference it with config(), and move on. Your code becomes cache-safe, easier to test, and more explicit about where its configuration comes from. There is no downside, and the upside is avoiding a class of bugs that only manifest in production -- the worst kind.
Make it a habit: env() in config files, config() everywhere else. No exceptions.