Today’s lesson came from a perfectly innocent-looking change in a Laravel app. We had a magic number — a chunk size — sprinkled across three call sites:
1 2 3 |
One reviewer flagged it: “If we ever need to tune this, three files need to change.” Fair. So I did the obvious Laravel thing — I’d reach for env(), drop a default in .env.example, and call it a day. 🐘
Then a colleague asked the right question: does that actually work inside a queued job?
The trap nobody talks about
Here’s the bit that catches teams over and over:
env() reads from $_ENV at runtime. That works fine in development. But the moment your deployment runs php artisan config:cache — and most production deployments do, because it’s a 10x boot-time win — Laravel stops loading .env on subsequent requests. Inside a queue worker, env(‘FOO’) often returns null, and you silently fall back to whatever default you passed.
So this:
1 | $chunkSize = (int) env('USER_CHUNK_SIZE', 100); |
Looks tunable. Behaves like 100 always. Forever. Because the env var “isn’t there” by the time the worker reads it. 💡
The Laravel docs are explicit about this — env() is meant to be called inside config/*.php files, nowhere else. It’s a real footgun, especially in CI pipelines that already run php artisan optimize as part of deploy.
The fix: one knob, three guards
The canonical pattern is to centralize the value in a config file and read it via config() from your application code. Like this:
1 2 3 4 5 6 | // config/app.php return [ // ... 'campaign_user_chunk_size' => (int) env('CAMPAIGN_USER_CHUNK_SIZE', 100), // ... ]; |
1 2 3 4 5 | // In a job, controller, service — anywhere outside config/ $chunkSize = (int) config('app.campaign_user_chunk_size', 100); foreach (array_chunk($userIds, $chunkSize) as $chunk) { SomeJob::dispatch($chunk); } |
Three things are happening here, and each one matters:
- The default in .env.example. This is documentation — anyone provisioning a new environment can see the var exists and what a sensible value looks like. It’s not load-bearing for behavior; it’s load-bearing for discoverability.
- The default in the config file. env(‘CAMPAIGN_USER_CHUNK_SIZE’, 100) means if the env var is missing entirely, the config still has a sane value. This is the layer that survives config:cache, because once cached, the array is committed to disk with that 100 baked in.
- The default at the call site. config(‘app.campaign_user_chunk_size’, 100) — yes, also there. “But isn’t that redundant?” Sort of. It’s defense in depth. If someone deletes the key from config/app.php, or forgets to run config:cache after a deploy, your code still works. The cost is one literal integer; the upside is one less way for a deploy to silently break.
Why call-site defaults aren’t “DRY violation”
I went back and forth with myself on this. The DRY instinct says: define the default once, in config/app.php, and reference it from the call site bare. config(‘app.campaign_user_chunk_size’). Done.
But production deploys are not a controlled environment. They’re a parade of small mistakes — someone edits the config file and removes the key, the cached config is stale because the deploy script didn’t run config:cache, the env var has a typo. The cost of the call-site default is one duplicate literal. The cost of a missing-key crash on a Friday afternoon deploy is significantly higher. Repetition wins. 🛡️
Mental model: env vs config
The rule that stuck with me, and that I’d offer to anyone learning Laravel:
- env() belongs in config/*.php. Nowhere else.
- config() is what your app reads everywhere else — controllers, jobs, services, blade views, tests.
Once you internalize that, you stop having to think about whether config:cache is on or off. Your code reads from a stable in-memory array; the config files are the only place env vars get materialized.
Three layers of defaults, one canonical source, no surprises in queue workers. That’s the boring version of “production-ready,” and it’s worth the extra fifteen minutes. 🎉