Reading Laravel Config From a Queued Job — and the env() Trap That Bites You in Production

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
foreach (array_chunk($userIds, 100) as $chunk) {
    SendOnboardingEmailJob::dispatch($chunk);
}

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:

  1. 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.
  2. 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.
  3. 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. 🎉

This entry was posted in Laravel, php and tagged , , . Bookmark the permalink.

Comments are closed.