Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Your .env File Is Not a Global Variable

  • software-engineering
  • laravel
  • typescript
  • node

Somewhere in a lot of apps there's a line like this, buried inside a service class:

$client = new CrmClient(env('CRM_API_KEY'));

It works locally. It works in staging. Then someone adds php artisan config:cache to the deploy script and that key is suddenly null in production.

Nothing about the key changed. Where you read it from did.

Why env() goes quiet in Laravel

Once Laravel's config is cached, the .env file isn't loaded anymore. From then on, env() only sees real system-level environment variables. For most apps, that means your .env values come back as null.

Calls to env() inside config/*.php are fine. They get evaluated when the cache is built, and the results get baked in. Calls anywhere else are a bet that nobody ever caches config. Somebody always does.

The fix is boring, which is the point:

// config/services.php
'crm' => [
    'url' => env('CRM_URL'),
    'key' => env('CRM_API_KEY'),
    'timeout' => (int) env('CRM_TIMEOUT', 5),
],
$client = new CrmClient(
    url: config('services.crm.url'),
    key: config('services.crm.key'),
);

One rule: env() lives in config/. Everything else asks config().

If you want to know how bad it is right now, run grep -rn "env(" app/ routes/ and see what comes back.

Recent Laravel versions also have typed getters, which are worth using for anything that isn't a plain string:

$timeout = Config::integer('services.crm.timeout');

If the value isn't actually an integer, that throws instead of quietly handing you a string or null.

Node has the same problem with fewer guardrails

In Node, every value in process.env is a string or undefined. That's it. No booleans, no numbers, no "this one is required."

So this happens:

// Both of these are true when FEATURE_SYNC=false
if (process.env.FEATURE_SYNC) { /* ... */ }
Boolean(process.env.FEATURE_SYNC);

"false" is a non-empty string. Non-empty strings are truthy. Your feature flag is on and the config file says it's off.

Parse it once at startup and export something typed:

// config.ts
import { z } from "zod";
 
const flag = z
  .enum(["true", "false"])
  .default("false")
  .transform((v) => v === "true");
 
const schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  CRM_URL: z.string().url(),
  CRM_API_KEY: z.string().min(1),
  CRM_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
  FEATURE_SYNC: flag,
});
 
const parsed = schema.safeParse(process.env);
 
if (!parsed.success) {
  const bad = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
  throw new Error(`Invalid environment: ${bad}`);
}
 
export const config = parsed.data;

A couple of things on purpose here.

The error names the bad variables, never their values. Nobody needs your API key in a crash log.

And z.coerce.boolean() looks tempting for flags, but it uses JavaScript truthiness. The string "false" comes out true. Same bug, now with a schema.

Everywhere else just imports config:

const data = await fetchJson(config.CRM_URL, {
  timeoutMs: config.CRM_TIMEOUT_MS,
});

Typed, validated, and nobody is guessing whether CRM_TIMEOUT_MS is a number.

Fail at boot, not at the first customer

A missing key should break the deploy. It should not break the checkout page for whoever happens to hit that code path first.

In Node, the config.ts above does that as long as your entrypoint imports it. In Laravel, a small check in a service provider covers the keys you truly can't run without:

// AppServiceProvider::boot()
foreach (['services.crm.url', 'services.crm.key'] as $key) {
    if (blank(config($key))) {
        throw new RuntimeException("Missing config value: {$key}");
    }
}

Be picky about what goes in that list. boot() runs for every Artisan command too, including ones in CI where production secrets don't exist. Required means required, not "nice to have."

Pass config in, don't reach for it

A class that calls config() or process.env deep inside a method is harder to test than one that takes values in its constructor.

Let the container do the reaching:

$this->app->singleton(CrmClient::class, fn () => new CrmClient(
    url: config('services.crm.url'),
    key: config('services.crm.key'),
));

Now a test is just new CrmClient(url: 'http://fake.test', key: 'test'). No juggling environment variables, no remembering to reset them after.

The habit

Read env in one place. Validate it once. Hand everything else real, typed values.

Your .env file is how values get into the app. It is not a global variable you get to reach into from anywhere.

← All posts