The deploy went out on a Friday afternoon. Tests were green, staging looked fine, and nobody touched the .env file. On Monday, finance noticed that no invoices had been sent to the e-invoicing provider since Friday.
The sync job was running. It just decided, every single time, that the integration was disabled.
The cause was one line in a service class, and one new line in the deploy script. If you have ever called env() outside a config file in Laravel, this story is about your code too.
The problem: env() works until the day it doesn't
Here is a simplified version of the code. It looks harmless:
<?php namespace App\Services; class InvoiceSync { public function enabled(): bool { return (bool) env('INVOICE_SYNC_ENABLED', false); } public function batchSize(): int { return (int) env('INVOICE_SYNC_BATCH', 50); } } It worked for months. Then someone improved the deploy script and added the step every Laravel deployment guide recommends:
php artisan config:cache The Laravel documentation is very direct about what happens next: once the configuration has been cached, the .env file is no longer loaded during requests or Artisan commands, so env() only returns external, system-level environment variables.
Our variable lived in .env, not in the server environment. So env('INVOICE_SYNC_ENABLED', false) started returning the default, false. No exception, no log line. The feature simply switched itself off.
The opposite version is worse. If the default had been true, a kill switch you set to false in .env would have been silently ignored in production.
Why you never see it locally
The docs also say you should not run config:cache during local development, because you change config values all the time. That is good advice, and it is exactly why the bug hides: locally, .env is always loaded and every env() call works. The bug only exists in the environment where the cache exists.
The rule: env() belongs in config files only
The fix is the rule the documentation states: call env() only inside files in the config directory, and read values everywhere else with config(). When you cache, Laravel evaluates those config files once, with .env loaded, and writes the result to a single file. After that, config() reads the cached values and .env is not needed.
Step 1: Move the values into a config file
<?php // config/integrations.php return [ 'invoice_sync' => [ 'enabled' => filter_var(env('INVOICE_SYNC_ENABLED', false), FILTER_VALIDATE_BOOL), 'batch_size' => (int) env('INVOICE_SYNC_BATCH', 50), 'endpoint' => env('INVOICE_SYNC_ENDPOINT', ''), ], ]; Two details matter here:
- Normalise types in the config file.
.envvalues are strings, except for a few reserved words. According to the docs,true,false,nullandempty(and their(true)-style variants) become real booleans,nulland an empty string. Everything else stays a string, soINVOICE_SYNC_BATCH=50reaches PHP as"50". Cast it where it is read, once. -
filter_var(..., FILTER_VALIDATE_BOOL)instead of(bool). Someone will eventually writeINVOICE_SYNC_ENABLED=offor=no. With(bool), any non-empty string except"0"istrue, so"off"would enable the feature.FILTER_VALIDATE_BOOLtreatson/yes/1/trueas true andoff/no/0/false/""as false. I checked this in PHP 8.3 before trusting it.
Step 2: Read config with typed accessors
<?php namespace App\Services; use Illuminate\Support\Facades\Config; class InvoiceSync { public function enabled(): bool { return Config::boolean('integrations.invoice_sync.enabled'); } public function batchSize(): int { return Config::integer('integrations.invoice_sync.batch_size'); } public function endpoint(): string { return Config::string('integrations.invoice_sync.endpoint'); } } Laravel's Config facade has typed methods: string, integer, float, boolean, array and collection. They do not convert anything. In the framework source, each one checks the type and throws an InvalidArgumentException with a message like "Configuration value for key [integrations.invoice_sync.enabled] must be a boolean, string given."
That is the point. A wrong type now fails loudly at the first read, instead of quietly becoming true or 0. It also means a missing key fails: Config::boolean('typo.key') gets null and throws, while config('typo.key') would happily return null.
Step 3: Fail fast at boot for values production needs
Some values are not optional. If the integration is enabled, an empty endpoint is a misconfiguration, not a reason to send requests to nowhere. Check that once, when the app boots in production:
<?php namespace App\Providers; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; use RuntimeException; class AppServiceProvider extends ServiceProvider { public function boot(): void { if (! $this->app->environment('production')) { return; } if (Config::boolean('integrations.invoice_sync.enabled') && Config::string('integrations.invoice_sync.endpoint') === '') { throw new RuntimeException('INVOICE_SYNC_ENDPOINT is required when invoice sync is enabled.'); } } } Keep this list short and specific. It is a guard rail for the few settings that would cause real damage, not a schema for every key.
Step 4: Make the rule impossible to forget
Rules that live only in code review get broken. Two cheap ways to enforce this one:
Larastan. It ships a rule called noEnvCallsOutsideOfConfig. It is disabled by default; enable it in phpstan.neon:
parameters: noEnvCallsOutsideOfConfig: true It reports: "Called 'env' outside of the config directory which returns null when the config is cached, use 'config'."
A grep in CI if you do not run static analysis yet:
if grep -rnE --include='*.php' '(^|[^>:$[:alnum:]_])env\(' app routes database resources; then echo "env() found outside config/. Use config() instead." exit 1 fi The character class skips method calls such as $this->env(...) and App::env(). I tested it against a small fixture: it flagged env("X") and (bool) env("W") in app/, ignored the method calls, and did not scan config/.
Step 5: Verify the cached values after deploy
Laravel has two commands that make this easy to check on the server, right after config:cache:
php artisan config:cache php artisan config:show integrations php artisan about --only=environment config:show prints the values of one config file, so you see what the app will actually use, not what you think is in .env. If something looks wrong, fix the source and run config:cache again; config:clear removes the cache entirely.
Common pitfalls
env() in routes, Blade views and migrations. The rule is about every file outside config/, not just classes in app/. Routes and views are where these calls hide most often.
Long-running workers keep old config. Queue workers and Octane load config when they start. After changing and re-caching config, restart them (php artisan queue:restart for queue workers), or the old values keep running.
config:cache on your laptop. If you cached config locally once, .env changes stop having any effect and it feels like Laravel is ignoring you. Run php artisan config:clear and leave caching to the deploy script.
Typed accessors without casting. Config::integer() on a value written as env('X', 50) throws as soon as X is set in .env, because the value is the string "50". That is a good failure, but cast in the config file so it never happens.
Secrets in the repository. The answer to "can we commit .env so deploys are simpler?" is no. If you need versioned environment files, Laravel has php artisan env:encrypt, which writes an encrypted .env.encrypted that is safe to commit, and env:decrypt on the server with a key stored outside the repo.
Summary
- After
config:cache, Laravel does not load.env;env()returns only real system environment variables. - Call
env()only inconfig/files, normalise types there ((int),filter_var(..., FILTER_VALIDATE_BOOL)), and read everything else withconfig(). - Use
Config::boolean(),Config::integer()andConfig::string()so wrong types and missing keys fail loudly. - Validate the few critical settings at boot in production.
- Enforce the rule with Larastan's
noEnvCallsOutsideOfConfigor a CI grep, and check real values withconfig:showafter deploy.
The invoice sync bug cost a weekend of unsent invoices and one awkward Monday meeting. The fix was about twenty lines. The CI check is what makes sure it does not come back.
Have you ever been bitten by a config value that was different in production than you thought, and how did you find out?
