Environment Variables
Env vars carry per-machine settings; .env files supply defaults, processors cast them, and the values are read at runtime.
%env(processor:processor:VAR_NAME)%An environment variable is a value the operating system hands to your process. Symfony reads configuration from environment variables so one build can run on a laptop and in production with different addresses, keys and flags, and so a credential never sits in a committed file.
The .env files
.env is a development convenience. It sets a variable only when the real environment has not already set it, so a variable exported by the server, the container runtime or the shell always wins. The files load in this order, each overriding the one above:
| File | Holds | Commit |
|---|---|---|
.env | defaults for every developer | yes |
.env.local | this machine's overrides | no |
.env.<APP_ENV> | per-environment defaults | yes |
.env.<APP_ENV>.local | this machine, one environment | no |
Reading one in configuration
%env(MAILER_DSN)% reads a variable. A value from a .env file is always a string, so Symfony ships processors that cast it: %env(bool:FEATURE_SIGNUPS)%, %env(int:HTTP_PORT)%, %env(json:ALLOWED_LANGUAGES)%, %env(csv:REDIS_NODES)%. Plain %env(FOO)% and %env(string:FOO)% are the same thing. Processors chain, and the one nearest the name runs first: %env(json:file:resolve:AUTH_FILE)% resolves parameters in the value, reads that file, then decodes the JSON. Symfony 8.1 also offers default:, key:, url:, query_string:, enum:, trim:, const:, not:, defined:, base64:, require:, shuffle:, float: and urlencode:.
Why runtime matters
Parameters are frozen into the compiled container. Environment values are not: the container stores a placeholder and resolves it when it is used, so a new value takes effect without rebuilding the cache.
Example
// src/Service/SignupGate.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class SignupGate
{
public function __construct(
// app.signups_enabled is '%env(bool:FEATURE_SIGNUPS)%', so the string
// from the environment arrives here already cast to a real bool
#[Autowire(param: 'app.signups_enabled')]
private bool $signupsEnabled,
#[Autowire(param: 'app.signup_closed_message')]
private string $closedMessage,
) {
}
/**
* Returns null when signups are open, or the reason they are not.
*/
public function reasonClosed(): ?string
{
return $this->signupsEnabled ? null : $this->closedMessage;
}
}
// $gate = new SignupGate(false, 'Signups reopen in April.');
// echo $gate->reasonClosed() ?? 'open';
// Output: Signups reopen in April.
When to use it
- Point the application at a local mail catcher without changing a committed file.
- Turn a feature off in production by setting one variable, with no cache rebuild.
- Read a JSON credentials file whose path differs on every host.
More examples
The files themselves
The split that matters: .env documents which variables exist, .env.local holds the values. Add .env.local and .env.*.local to .gitignore, which the Symfony skeleton already does. Check that before putting a real key in one.
# .env (committed: defaults, never a real credential)
APP_ENV=dev
APP_SECRET=
MAILER_DSN=null://null
FEATURE_SIGNUPS=false
# a default for a variable the code expects but you do not ship
[email protected]
# .env.local (never committed: this machine only)
APP_SECRET=4f1c0d7b9a2e4c81
MAILER_DSN=smtp://127.0.0.1:1025
FEATURE_SIGNUPS=trueCasting and defaults in configuration
Naming a parameter per setting gives you one place to see the cast and one name to inject. The env(FEATURE_SIGNUPS) parameter is a fallback: without it, a missing variable is an error when the container needs the value.
# config/services.yaml
parameters:
# a default used when the variable is not set at all
env(FEATURE_SIGNUPS): 'false'
app.signups_enabled: '%env(bool:FEATURE_SIGNUPS)%'
app.signup_closed_message: 'Signups reopen in April.'
app.allowed_locales: '%env(csv:ALLOWED_LOCALES)%'
app.webhook_timeout: '%env(int:WEBHOOK_TIMEOUT)%'
# runs right to left: resolve %parameters% in the value, read the
# file at that path, then JSON-decode what it contains
app.google_auth: '%env(json:file:resolve:AUTH_FILE)%'
env(AUTH_FILE): '%kernel.project_dir%/config/auth.json'Checking and freezing values
debug:dotenv answers "which file won", which is the usual question when a value is not what you expected. composer dump-env is a production step: real environment variables still override the dumped file, so it changes performance, not precedence.
# which .env files were read, and the value each variable ended up with
$ php bin/console debug:dotenv
# every env var the configuration refers to, and whether it is set
$ php bin/console debug:container --env-vars
# deploy step: fold the .env files into one PHP file
$ composer dump-env prod
# writes .env.local.php, so the .env files are not parsed on each request
Discussion