Last updated: 9 November 2026. See theupdate logat the end.
The classic Laravel deployment bug goes like this: everything works on your machine, you run php artisan config:cache in production, and a setting that was fine yesterday is suddenly null. The cause is almost always an env() call in the wrong place. This article explains how .env, env() and config() fit together, with short examples you can run, and covers the parts that bite in practice: value types, quoting, environment files, config caching and encrypted env files.
Everything below was run on Laravel 13.40.0 with PHP 8.3.
The flow in one line
.env (or a real environment variable) → read by env() inside a file inconfig/ → read everywhere else with config().
The Laravel docs state the rule directly: once the configuration is cached, the .env file is no longer loaded, so you should only call env() from your configuration files. Everything else in the app reads config().
A working example: adding your own setting
Say you integrate a payment API. First add the values to .env:
PAYMENT_API_URL=https://api.pay.example.test
PAYMENT_TIMEOUT=30
PAYMENT_DEBUG=true
Then expose them through a config file. Here I added an entry to config/services.php. The second argument to env() is the default, used when the variable doesn't exist:
// config/services.php
'payment' => [
'url' => env('PAYMENT_API_URL', 'https://sandbox.pay.example.test'),
'timeout' => (int) env('PAYMENT_TIMEOUT', 10),
'debug' => env('PAYMENT_DEBUG', false),
],
In application code, use config() with dot notation. Using the same pattern in a controller, job or service:
$url = config('services.payment.url');
$timeout = config('services.payment.timeout');
To see the effect, I printed both calls from an Artisan command. With nothing in .env, the defaults apply. After adding the three lines, the values come through, already converted: the timeout is an integer, and true became a real boolean.
-- nothing in .env --
env(PAYMENT_API_URL) = NULL
config(services.payment.url) = 'https://sandbox.pay.example.test'
config(services.payment.timeout) = 10
config(services.payment.debug) = false
-- after adding the three lines --
env(PAYMENT_API_URL) = 'https://api.pay.example.test'
config(services.payment.url) = 'https://api.pay.example.test'
config(services.payment.timeout) = 30
config(services.payment.debug) = true
(php artisan probe in these outputs is a throwaway command I defined in routes/console.php to print the values; it is not built into Laravel.)
Real environment variables beat .env
A variable set in the process environment (by your shell, systemd, Docker, your host's dashboard) overrides the same key in .env. This is what the docs describe, and it is why production servers often need no .env file at all:
$ PAYMENT_API_URL=https://override.example.test php artisan probe
env(PAYMENT_API_URL) = 'https://override.example.test'
config(services.payment.url) = 'https://override.example.test'
Value types: what env() returns
Everything in .env is a string, except a few reserved words that env() converts:
.env value |
env() returns |
|---|---|
true / (true)
|
true (bool) |
false / (false)
|
false (bool) |
empty / (empty)
|
'' (empty string) |
null / (null)
|
null |
I tested a few of these, including one the docs don't spell out, a quoted "false":
.env: FLAG_TRUE=true FLAG_FALSE=false FLAG_QUOTED_FALSE="false"
FLAG_EMPTY= FLAG_NULL=null FLAG_TEXT=hello
FLAG_TRUE true
FLAG_FALSE false
FLAG_QUOTED_FALSE false
FLAG_EMPTY ''
FLAG_NULL NULL
FLAG_TEXT 'hello'
FLAG_UNSET NULL
Note that the quoted "false" still came back as boolean false, so quotes do not keep the word as a string. Also note that a key with an empty value (FLAG_EMPTY=) returns an empty string, which is not the same as null, and a key that is missing entirely gives null unless you pass a default. If a setting can legitimately be a plain string like "false", don't use those words as values.
Spaces need quotes
A value containing spaces must be in double quotes. Without them the app doesn't just misread the value, it refuses to start:
# .env
MY_TITLE=My Application
$ php artisan probe
The environment file is invalid!
Failed to parse dotenv file. Encountered unexpected whitespace at [My Application].
Writing MY_TITLE="My Application" fixes it. The docs show the same rule with APP_NAME="My Application".
The config:cache trap
php artisan config:cache merges all config files into one cached file, which is faster, and you should run it on deploy. But after that, Laravel stops loading .env on each request, so a direct env() call outside config/ only sees real system environment variables. Here is the demo. I cached the config, then changed the URL in .env:
$ php artisan config:cache
INFO Configuration cached successfully.
$ sed -i 's#^PAYMENT_API_URL=.*#PAYMENT_API_URL=https://changed.example.test#' .env
$ php artisan probe
env(PAYMENT_API_URL) = NULL
config(services.payment.url) = 'https://api.pay.example.test'
$ php artisan config:clear
INFO Configuration cache cleared successfully.
$ php artisan probe
env(PAYMENT_API_URL) = 'https://changed.example.test'
config(services.payment.url) = 'https://changed.example.test'
Two lessons in one output. First, env() in application code returned NULL while the cache was active, even though the variable is in .env. That's the production-only bug from the introduction. Second, config() kept serving the old value, because the cache is a snapshot: changing .env has no effect until you run config:cache again (or config:clear locally). So the deploy order is: update the environment, then rebuild the cache.
The fix for the bug is always the same. Move the env() call into a config file, as in the payment example, and read config() in your code.
Environment names and extra env files
APP_ENV names the current environment, and App::environment() reads it:
use Illuminate\Support\Facades\App;
if (App::environment('local')) { /* ... */ }
if (App::environment(['local', 'staging'])) { /* local OR staging */ }
The docs also describe an environment-specific file: if APP_ENV is set externally or you pass --env, Laravel looks for .env.[name] and loads it instead of .env if it exists. The word "instead" matters. I created a .env.staging with only two keys:
.env.staging:
APP_NAME="Staging App"
PAYMENT_API_URL=https://staging.pay.example.test
$ php artisan probe --env=staging
config(services.payment.url) = 'https://staging.pay.example.test'
config(services.payment.timeout) = 10
config(services.payment.debug) = false
config(app.name) = 'Staging App'
App::environment() = 'staging'
The timeout and debug flag fell back to their defaults (10 and false) even though .env sets them to 30 and true: the staging file replaced .env, it was not layered on top. So a .env.staging must contain every variable the app needs. And when the named file does not exist, nothing breaks, you just get the default .env under a different name:
$ APP_ENV=qa php artisan probe # no .env.qa exists
config(services.payment.url) = 'https://api.pay.example.test'
config(app.name) = 'Laravel'
App::environment() = 'qa'
That quietly misleads: the app reports it is in qa while running on the .env values.
Catching wrong types: the typed getters
Laravel's Config facade has typed getters that throw when a value is the wrong type, which turns a silent bug into a clear error at the call site:
use Illuminate\Support\Facades\Config;
$timeout = Config::integer('services.payment.timeout'); // 30
$url = Config::string('services.payment.url');
Config::integer('services.payment.url'); // throws
Config::integer -> InvalidArgumentException: Configuration value for key [services.payment.url] must be an integer, string given.
Config::boolean -> InvalidArgumentException: Configuration value for key [services.payment.url] must be a boolean, string given.
Keeping secrets out of git: encrypted env files
The docs are clear that .env should not be committed, since it holds credentials. If you want your environment in source control anyway, Laravel can encrypt it. The commands and the default cipher (AES-256-CBC, which needs a 32-character key) come from the docs. Here is the round trip I ran, with a throwaway key:
$ php artisan env:encrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
INFO Environment successfully encrypted.
Key ........ 3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
Cipher ..... AES-256-CBC
Encrypted file ..... .env.encrypted
$ php artisan env:decrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
ERROR Environment file already exists.
$ rm .env
$ php artisan env:decrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
INFO Environment successfully decrypted.
Decrypted file ..... .env
$ diff .env .env.original && echo identical
identical
Decrypting refuses to overwrite an existing .env unless you pass --force. On a server you can also set the key in the LARAVEL_ENV_ENCRYPTION_KEY environment variable instead of passing --key. With --readable, variable names stay visible and only values are encrypted, which makes diffs in pull requests much more useful:
$ php artisan env:encrypt --readable --force --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
$ cut -c1-40 .env.encrypted | head -3
APP_NAME=eyJpdiI6Ik1VQU5yU1BQa0pGK0VycVZ
APP_ENV=eyJpdiI6IjBpVDZTeFRwOVNxRlUxZGtU
APP_KEY=eyJpdiI6Im53RTkxbG9sS0J3SGtTR0tP
(The ciphertext differs on every run; yours will look different.) Never commit the key, and don't reuse the key shown here. Store the real one in a password manager or your CI secret store. Per the docs, comments and blank lines from the original file are dropped in the readable form.
Common mistakes
-
env()in controllers, models or services. Works locally, returnsnullafterconfig:cache. Readconfig()instead. -
Forgetting to rebuild the cache on deploy. The cache is a snapshot; new
.envvalues are ignored until you runconfig:cacheagain. - Unquoted values with spaces. The app refuses to boot.
-
Assuming
.env.stagingadds to.env. It replaces it. -
Setting
APP_ENVto a name with no matching file and assuming it is isolated. It silently uses.env. -
Committing
.env. Commit.env.examplewith placeholders, as the docs recommend. -
Running
config:cacheon your dev machine. You will chase "my change does nothing" for an hour. Run it on deploy. -
Leaving
APP_DEBUG=truein production. See the checklist in the production security article.
Alternatives to a .env file
On servers, real environment variables (systemd units, Docker's environment or secrets, your platform's settings page, a secrets manager) avoid keeping a plaintext file on disk at all, and Laravel reads them with the same env()-in-config pattern. That is a deployment preference rather than a Laravel requirement, so choose what fits your hosting. I only tested .env and shell variables here.
Related Dev Talk articles
Frequently asked questions
Can I use env() anywhere if I never run config:cache?
It will work, but you are one deploy script away from the bug. Reading config() everywhere costs nothing and keeps you safe if caching is switched on later.
Why does my .env change not take effect?
Either the config is cached (run php artisan config:clear), or a real environment variable with the same name is overriding .env. Check with printenv NAME.
What is the difference between .env and .env.example?
.env holds your real values and stays out of git. .env.example is committed and lists the variable names with placeholders so teammates know what to set.
Is the encrypted env file safe to commit?
The docs say encrypted environment files may be placed in source control. The safety depends entirely on keeping the key out of the repository.
Sources
Originally published on DEV Talk.
Top comments (0)