Config & Env Safety
env() outside a config file returns null once config:cache has run. The code is correct, the variable is set, the deploy is green — and the value is gone. It is the most reliable way to ship a bug that cannot be reproduced locally, because locally nobody runs config:cache.
This skill stops configuration that evaporates in production, and secrets that leak when it does.
The footgun
Laravel loads .env at boot and env() reads from it. php artisan config:cache serializes the whole merged config into one PHP file and stops loading .env entirely — that is the point, it is what makes the cache fast. From then on:
- Every
env() call outside config/ returns its default, or null. A env('STRIPE_KEY') in a service class becomes null. Nothing throws. The Stripe client is constructed with a null key and fails later, somewhere else, with an unrelated-looking error.
- The failure is environment-shaped. Local dev has no config cache, so it works. CI often has no config cache, so it passes. Only the production deploy — the one place that caches — is broken.
- A closure in a config file makes the cache fatal, not silent.
config:cache runs var_export() over the config array; a Closure cannot be serialized, so the command dies with Your configuration files are not serializable. Better than silent, but it breaks the deploy for everyone, and the usual "fix" is to stop caching config — which is the wrong end of the problem.
APP_DEBUG=true in production is a credential dump. The debug error page renders the stack trace and the environment: database password, mail password, API keys, APP_KEY. One uncaught exception on a public route is a full secret disclosure. It is not a hypothetical — it is one of the most common Laravel breaches there is.
- Rotating
APP_KEY invalidates data, not just sessions. Every Crypt:: value, every encrypted cast column, and every "remember me" token becomes undecryptable. Changing it to fix a session problem destroys encrypted columns permanently.
- A stale cache outlives the code.
bootstrap/cache/config.php committed, baked into an image, or left behind by a failed deploy serves yesterday's configuration with today's code.
Rules
NEVER call env() outside config/. Not in controllers, models, jobs, service providers' boot(), middleware, Blade, or tests. Add a config key and read config('services.stripe.key'). This is the single rule that prevents most of this skill's failures.
Every env() in a config file gets a sensible default. env('QUEUE_CONNECTION', 'sync') — so a missing variable is a documented fallback, not null propagating into a client constructor.
NEVER put a closure, object, or resource in a config file. config:cache serializes with var_export(); a closure makes it fail outright. Bind the dynamic thing in a service provider and let config hold only scalars, arrays, and strings.
APP_DEBUG=false in every non-local environment. No exceptions. Staging included — staging usually holds real-shaped secrets. To debug production, read the log; never flip debug on a public host.
APP_ENV=production on production, and check it before destructive work. Laravel's own confirmation prompts (migrate --force, db:wipe) key off it, and App::isProduction() is how your code refuses to seed, wipe, or send test mail.
APP_KEY must be set, unique per environment, and never rotated casually. Generate with php artisan key:generate. Rotating it invalidates every encrypted value; if you must rotate, decrypt-and-re-encrypt first with the old key available via APP_PREVIOUS_KEYS.
.env is never committed; .env.example always is. Every new variable gets an entry in .env.example with a placeholder — that file is the only documentation of what a deployment needs. Confirm .env is in .gitignore before adding anything to it.
Deploys run config:cache (or optimize) — and always after the code is in place. Cache in the release step, not the build step, if any value depends on the environment the container lands in. Serve nothing from a config cache built on a different host with a different .env.
bootstrap/cache/*.php is generated, never committed. A committed config.php shadows the real configuration in a way that reads as "the deploy didn't pick up my change".
Secrets live in the environment (or a secret manager), not in config/ defaults. env('API_KEY', 'sk_live_realkeyhere') puts the real key in git forever, and the default silently masks a missing variable.
Cache config in tests only if production does. If the deploy caches, run at least one CI job with config:cache applied — that is the job that catches an env() call that slipped into application code.
Good vs bad
The bug that only happens in production
// ❌ null after config:cache — and only after config:cache
final class StripeClient
{
public function __construct()
{
$this->key = env('STRIPE_SECRET');
}
}
// ✅ config/services.php — the only place env() belongs
return [
'stripe' => [
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];
// ✅ and the class reads config, which the cache preserves
final class StripeClient
{
public function __construct()
{
$this->key = config('services.stripe.secret');
}
}
A closure that breaks config:cache
// ❌ LogicException: Your configuration files are not serializable.
return [
'timezone' => fn () => Auth::user()?->timezone ?? 'UTC',
];
// ✅ config holds the scalar; the provider holds the behaviour
// config/app.php
return ['timezone' => env('APP_TIMEZONE', 'UTC')];
// app/Providers/AppServiceProvider.php
public function boot(): void
{
$this->app->bind(TimezoneResolver::class, fn ($app) =>
new TimezoneResolver(config('app.timezone'))
);
}
Environment checks
// ❌ reads the raw env var, which is empty under a config cache
if (env('APP_ENV') === 'production') {
// never true in production. exactly backwards.
}
// ✅
if (App::isProduction()) {
// ...
}
A destructive command with no guard
// ❌ one wrong --env away from wiping production
public function handle(): void
{
Artisan::call('migrate:fresh --seed');
}
// ✅ refuse in production, loudly
public function handle(): int
{
if (App::isProduction()) {
$this->error('Refusing to run against production.');
return self::FAILURE;
}
$this->call('migrate:fresh', ['--seed' => true]);
return self::SUCCESS;
}
Deploy ordering
# ❌ caches config, then changes the code it was built from
php artisan config:cache
git pull && composer install --no-dev
php artisan migrate --force
# ✅ code first, then cache, and clear the old cache if the deploy can fail midway
git pull && composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize # config + route + view cache, after the code is final
Verification checklist
No env() outside config/. This is the whole skill in one grep — run it and expect zero results:
grep -rn --include=*.php "env(" app/ routes/ database/ resources/ bootstrap/ | grep -v "config/"
Anything it finds is a value that will be null in production.
No env() in Blade. grep -rn "env(" resources/views/.
config:cache succeeds. Run it locally against a scratch environment; a not serializable error means a closure or object reached a config file:
php artisan config:cache && php artisan config:clear
Every new key is in .env.example. Diff the variables referenced by config/ against .env.example; a missing entry means the next person's deploy comes up misconfigured with no error.
.env is ignored and untracked. git check-ignore -v .env and git ls-files --error-unmatch .env (the latter should fail).
bootstrap/cache/*.php is untracked. git ls-files bootstrap/cache/ should list only .gitignore.
Debug is off outside local. Assert it, so a bad .env fails a test rather than a customer:
it('never runs with debug enabled in production', function () {
config()->set('app.env', 'production');
expect(config('app.debug'))->toBeFalse();
});
One CI job runs with the config cached, matching production, so an env() that slipped into app code fails the build instead of the deploy.
Style. vendor/bin/pint config app to keep the diff clean.
When it's OK to bend the rule
env() in config/ is not a bend — it is the one correct place for it.
env() in bootstrap/app.php or a .php file that runs before the framework boots is unavoidable and safe: it executes before any config cache is consulted. Keep it to the handful of values that genuinely need it.
env() in tests is acceptable when the test itself sets the variable and the suite never runs with a config cache. Prefer config()->set() — it works either way.
- Not caching config at all is a legitimate choice for a low-traffic app that values a simpler deploy. Then this skill's central rule relaxes — but write that decision down, because the first person to add
config:cache for performance will otherwise break the app in a way nobody can reproduce.
APP_DEBUG=true on a private, seeded, secret-free staging box is fine. On anything reachable from the internet, or holding real credentials, it is not.
References
1---2name: laravel-config-env-safety3description: This skill should be used when the agent calls env(), edits files under config/, touches .env or .env.example, writes deploy or Dockerfile steps, or suggests config:cache, config:clear, optimize, optimize:clear, route:cache, or view:cache. Trigger on any new configuration value, on reading a secret or API key, on APP_DEBUG / APP_ENV / APP_KEY / APP_URL changes, on closures or dynamic values placed inside config files, and on service-provider or middleware code that reads configuration. Also load when the user mentions "env", "environment variable", "config cache", "works locally but not in production", "returns null in production", "APP_KEY", "APP_DEBUG", "secrets", "dotenv", or "deploy script".4license: MIT5---67# Config & Env Safety89`env()` outside a config file returns **null** once `config:cache` has run. The code is correct, the variable is set, the deploy is green — and the value is gone. It is the most reliable way to ship a bug that cannot be reproduced locally, because locally nobody runs `config:cache`.1011This skill stops configuration that evaporates in production, and secrets that leak when it does.1213## The footgun1415Laravel loads `.env` at boot and `env()` reads from it. `php artisan config:cache` serializes the whole merged config into one PHP file and **stops loading `.env` entirely** — that is the point, it is what makes the cache fast. From then on:1617- **Every `env()` call outside `config/` returns its default, or `null`.** A `env('STRIPE_KEY')` in a service class becomes `null`. Nothing throws. The Stripe client is constructed with a null key and fails later, somewhere else, with an unrelated-looking error.18- **The failure is environment-shaped.** Local dev has no config cache, so it works. CI often has no config cache, so it passes. Only the production deploy — the one place that caches — is broken.19- **A closure in a config file makes the cache fatal, not silent.** `config:cache` runs `var_export()` over the config array; a `Closure` cannot be serialized, so the command dies with `Your configuration files are not serializable`. Better than silent, but it breaks the deploy for everyone, and the usual "fix" is to stop caching config — which is the wrong end of the problem.20- **`APP_DEBUG=true` in production is a credential dump.** The debug error page renders the stack trace *and the environment*: database password, mail password, API keys, `APP_KEY`. One uncaught exception on a public route is a full secret disclosure. It is not a hypothetical — it is one of the most common Laravel breaches there is.21- **Rotating `APP_KEY` invalidates data, not just sessions.** Every `Crypt::` value, every encrypted cast column, and every "remember me" token becomes undecryptable. Changing it to fix a session problem destroys encrypted columns permanently.22- **A stale cache outlives the code.** `bootstrap/cache/config.php` committed, baked into an image, or left behind by a failed deploy serves yesterday's configuration with today's code.2324## Rules25261. **NEVER call `env()` outside `config/`.** Not in controllers, models, jobs, service providers' `boot()`, middleware, Blade, or tests. Add a config key and read `config('services.stripe.key')`. This is the single rule that prevents most of this skill's failures.27282. **Every `env()` in a config file gets a sensible default.** `env('QUEUE_CONNECTION', 'sync')` — so a missing variable is a documented fallback, not `null` propagating into a client constructor.29303. **NEVER put a closure, object, or resource in a config file.** `config:cache` serializes with `var_export()`; a closure makes it fail outright. Bind the dynamic thing in a service provider and let config hold only scalars, arrays, and strings.31324. **`APP_DEBUG=false` in every non-local environment. No exceptions.** Staging included — staging usually holds real-shaped secrets. To debug production, read the log; never flip debug on a public host.33345. **`APP_ENV=production` on production, and check it before destructive work.** Laravel's own confirmation prompts (`migrate --force`, `db:wipe`) key off it, and `App::isProduction()` is how your code refuses to seed, wipe, or send test mail.35366. **`APP_KEY` must be set, unique per environment, and never rotated casually.** Generate with `php artisan key:generate`. Rotating it invalidates every encrypted value; if you must rotate, decrypt-and-re-encrypt first with the old key available via `APP_PREVIOUS_KEYS`.37387. **`.env` is never committed; `.env.example` always is.** Every new variable gets an entry in `.env.example` with a placeholder — that file is the only documentation of what a deployment needs. Confirm `.env` is in `.gitignore` before adding anything to it.39408. **Deploys run `config:cache` (or `optimize`) — and always after the code is in place.** Cache in the release step, not the build step, if any value depends on the environment the container lands in. Serve nothing from a config cache built on a different host with a different `.env`.41429. **`bootstrap/cache/*.php` is generated, never committed.** A committed `config.php` shadows the real configuration in a way that reads as "the deploy didn't pick up my change".434410. **Secrets live in the environment (or a secret manager), not in `config/` defaults.** `env('API_KEY', 'sk_live_realkeyhere')` puts the real key in git forever, and the default silently masks a missing variable.454611. **Cache config in tests only if production does.** If the deploy caches, run at least one CI job with `config:cache` applied — that is the job that catches an `env()` call that slipped into application code.4748## Good vs bad4950### The bug that only happens in production5152```php53// ❌ null after config:cache — and only after config:cache54final class StripeClient55{56 public function __construct()57 {58 $this->key = env('STRIPE_SECRET');59 }60}61```6263```php64// ✅ config/services.php — the only place env() belongs65return [66 'stripe' => [67 'secret' => env('STRIPE_SECRET'),68 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),69 ],70];71```7273```php74// ✅ and the class reads config, which the cache preserves75final class StripeClient76{77 public function __construct()78 {79 $this->key = config('services.stripe.secret');80 }81}82```8384### A closure that breaks `config:cache`8586```php87// ❌ LogicException: Your configuration files are not serializable.88return [89 'timezone' => fn () => Auth::user()?->timezone ?? 'UTC',90];91```9293```php94// ✅ config holds the scalar; the provider holds the behaviour95// config/app.php96return ['timezone' => env('APP_TIMEZONE', 'UTC')];9798// app/Providers/AppServiceProvider.php99public function boot(): void100{101 $this->app->bind(TimezoneResolver::class, fn ($app) =>102 new TimezoneResolver(config('app.timezone'))103 );104}105```106107### Environment checks108109```php110// ❌ reads the raw env var, which is empty under a config cache111if (env('APP_ENV') === 'production') {112 // never true in production. exactly backwards.113}114```115116```php117// ✅118if (App::isProduction()) {119 // ...120}121```122123### A destructive command with no guard124125```php126// ❌ one wrong --env away from wiping production127public function handle(): void128{129 Artisan::call('migrate:fresh --seed');130}131```132133```php134// ✅ refuse in production, loudly135public function handle(): int136{137 if (App::isProduction()) {138 $this->error('Refusing to run against production.');139140 return self::FAILURE;141 }142143 $this->call('migrate:fresh', ['--seed' => true]);144145 return self::SUCCESS;146}147```148149### Deploy ordering150151```bash152# ❌ caches config, then changes the code it was built from153php artisan config:cache154git pull && composer install --no-dev155php artisan migrate --force156```157158```bash159# ✅ code first, then cache, and clear the old cache if the deploy can fail midway160git pull && composer install --no-dev --optimize-autoloader161php artisan migrate --force162php artisan optimize # config + route + view cache, after the code is final163```164165## Verification checklist1661671. **No `env()` outside `config/`.** This is the whole skill in one grep — run it and expect zero results:168 ```bash169 grep -rn --include=*.php "env(" app/ routes/ database/ resources/ bootstrap/ | grep -v "config/"170 ```171 Anything it finds is a value that will be `null` in production.1721732. **No `env()` in Blade.** `grep -rn "env(" resources/views/`.1741753. **`config:cache` succeeds.** Run it locally against a scratch environment; a `not serializable` error means a closure or object reached a config file:176 ```bash177 php artisan config:cache && php artisan config:clear178 ```1791804. **Every new key is in `.env.example`.** Diff the variables referenced by `config/` against `.env.example`; a missing entry means the next person's deploy comes up misconfigured with no error.1811825. **`.env` is ignored and untracked.** `git check-ignore -v .env` and `git ls-files --error-unmatch .env` (the latter should fail).1831846. **`bootstrap/cache/*.php` is untracked.** `git ls-files bootstrap/cache/` should list only `.gitignore`.1851867. **Debug is off outside local.** Assert it, so a bad `.env` fails a test rather than a customer:187 ```php188 it('never runs with debug enabled in production', function () {189 config()->set('app.env', 'production');190191 expect(config('app.debug'))->toBeFalse();192 });193 ```1941958. **One CI job runs with the config cached**, matching production, so an `env()` that slipped into app code fails the build instead of the deploy.1961979. **Style.** `vendor/bin/pint config app` to keep the diff clean.198199## When it's OK to bend the rule200201- **`env()` in `config/`** is not a bend — it is the one correct place for it.202- **`env()` in `bootstrap/app.php` or a `.php` file that runs before the framework boots** is unavoidable and safe: it executes before any config cache is consulted. Keep it to the handful of values that genuinely need it.203- **`env()` in tests** is acceptable when the test itself sets the variable and the suite never runs with a config cache. Prefer `config()->set()` — it works either way.204- **Not caching config at all** is a legitimate choice for a low-traffic app that values a simpler deploy. Then this skill's central rule relaxes — but write that decision down, because the first person to add `config:cache` for performance will otherwise break the app in a way nobody can reproduce.205- **`APP_DEBUG=true` on a private, seeded, secret-free staging box** is fine. On anything reachable from the internet, or holding real credentials, it is not.206207## References208209- Configuration — https://laravel.com/docs/12.x/configuration210- Environment configuration & `env()` — https://laravel.com/docs/12.x/configuration#environment-configuration211- Configuration caching (`config:cache`, and why `env()` returns null) — https://laravel.com/docs/12.x/configuration#configuration-caching212- Debug mode — https://laravel.com/docs/12.x/configuration#debug-mode213- Encryption & `APP_KEY` / `APP_PREVIOUS_KEYS` — https://laravel.com/docs/12.x/encryption214- Deployment — optimization (`optimize`, `config:cache`, `route:cache`) — https://laravel.com/docs/12.x/deployment#optimization215- `App::isProduction()` / environment detection — https://laravel.com/docs/12.x/configuration#determining-the-current-environment216- Artisan — production confirmation prompts (`--force`) — https://laravel.com/docs/12.x/artisan217- Laravel Pint — https://laravel.com/docs/12.x/pint