When a value belongs in .env, when it belongs in a parameter, and why Docker likes to blur that line
Symfony offers two different mechanisms for configuration, the Dotenv component and parameter bags, which look interchangeable at first glance but actually serve distinct purposes. Mix them up and you get familiar problems: secrets ending up in the Git repository, the same URL maintained in three different places, or a Docker environment variable silently overriding a value in production that a developer had actually set in .env.local. This article clarifies the real .env hierarchy, offers a clear rule of thumb for separating .env from parameters, and explains why sensitive values belong in the secrets vault rather than a plaintext .env file in production.
Table of Contents
- 1. The .env Hierarchy in Detail
- 2. .env vs. Parameter: A Clear Rule of Thumb
- 3. The env() Function and Its Processors in Practice
- 4. Secrets Vault Instead of a Plaintext .env in Production
- 5. Why .env Is Committed and What composer dump-env Does
- 6. Common Pitfalls with Docker Environment Variables
- 7. Using the Parameter Bag Deliberately at Runtime
- 8. Using Tests and .env.test Sensibly
- 9. A Practical Checklist for Clean Configuration Separation
- 10. Summary
- 11. FAQ
1. The .env Hierarchy in Detail
A fresh Symfony project ships with up to four different .env files, each serving a clearly defined purpose. The .env file holds the project-wide defaults and is committed to the repository, so every developer has a working baseline configuration right after a git clone, typically with a local SQLite or Docker DSN as the default. The .env.local file holds local, machine-specific overrides, is never committed, and sits in .gitignore, because it frequently contains real, individual credentials for a single developer's local environment.
On top of that there are environment-specific variants: .env.$APP_ENV, such as .env.test, is committed and holds values meant for a specific environment like testing, for example a different test database DSN. The file .env.$APP_ENV.local combines both, being environment-specific and local at the same time, is also never committed, and is meant for local overrides that should only apply within a specific environment. The loading order matters: Symfony loads .env first, then .env.local, then .env.$APP_ENV, and finally .env.$APP_ENV.local, with later files overriding earlier values, and real environment variables set by the operating system or the container always take precedence over every .env file in the end.
2. .env vs. Parameter: A Clear Rule of Thumb
The decisive question is not 'is this value secret' but 'does this value differ between environments'. A database DSN, a mailer DSN, a third-party API key, or a base URL naturally differ between local development, staging, and production, and therefore belong in the .env system, where they can be referenced in configuration through the env() function. A value that stays identical across every environment, on the other hand, such as the maximum number of retry attempts for an order notification or a feature flag that applies uniformly across the whole project, belongs as a regular parameter under the parameters key in config/services.yaml.
This separation exists for a practical reason: parameters in services.yaml are part of the versioned code and only change through an explicit commit and code review, while .env values are deliberately meant to vary per environment outside that process. Anyone who mistakenly puts an environment-independent business value into .env loses traceability through Git and risks different environments unintentionally applying different business rules. Conversely, anyone who hardcodes a database URL as a parameter in services.yaml forces every environment to change the code itself in order to point at a different database, which defeats the actual purpose of configuration.
3. The env() Function and Its Processors in Practice
In services.yaml, an environment variable is referenced through the syntax %env(VARIABLE_NAME)%, and Symfony automatically treats the value as a string unless a processor is prefixed. For other types, processors such as int, bool, float, or json are available, for example %env(int:MAX_RETRIES)%, which convert the raw string value of the environment variable into the desired PHP type before it reaches the container as a parameter. In more recent Symfony versions, these processors can also be set directly on a constructor parameter using the Autowire attribute with an env argument, skipping the detour through an explicit services.yaml parameter definition.
The example below shows a service that gets two environment variables injected directly via attribute, one as a string and one as a boolean, both typed and without any additional YAML configuration. This approach noticeably reduces indirection, because reading the class immediately shows which environment variables it actually needs, instead of having to search for them in a separate services.yaml file.
<?php
declare(strict_types=1);
namespace App\Mailer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class NotificationMailer
{
public function __construct(
#[Autowire(env: 'string:MAILER_FROM_ADDRESS')]
private readonly string $fromAddress,
#[Autowire(env: 'bool:MAILER_SANDBOX_MODE')]
private readonly bool $sandboxMode,
) {
}
public function getFromAddress(): string
{
return $this->fromAddress;
}
public function isSandboxMode(): bool
{
return $this->sandboxMode;
}
}
4. Secrets Vault Instead of a Plaintext .env in Production
Sensitive values such as API keys, signing secrets, or database passwords should not live as plaintext in an .env.local or .env.prod.local on the production server, even if that file never ends up in the Git repository. The reason is simple: anyone with filesystem access to the server, every backup, and every accidentally copied deployment artifact exposes the secret, with no additional protection mechanism kicking in. Symfony has offered a secrets vault for several years now, managed through the console commands secrets:set, secrets:list, and secrets:decrypt-to-local, which stores values encrypted inside the config/secrets/%kernel.environment%/ directory.
The vault uses an asymmetric key pair: the public key lives in the repository and lets any developer encrypt new secrets, while the private key lives exclusively on the production server or is provided through the SYMFONY_DECRYPTION_SECRET environment variable, typically through a separate, tightly restricted deployment step. A secret stored in the vault is referenced in configuration exactly like a regular environment variable, through %env(SOME_SECRET)%, because Symfony resolves secrets transparently through the very same env() mechanism. That means existing code does not need to change when switching from a plain .env variable to a vault secret; only the source of the value changes.
5. Why .env Is Committed and What composer dump-env Does
A common misunderstanding is assuming that .env is inherently a secret file. In reality, the base .env file is explicitly meant to be committed to the repository, so a new team member immediately has a working configuration with sensible placeholders and defaults. Real secrets should only appear there as an obvious placeholder, for example DATABASE_URL=mysql://app:app@127.0.0.1:3306/app, while the actual production password only ever lives in the given server's local .env.local or in the secrets vault.
In production, Symfony additionally recommends not re-parsing the .env files on every single request, but compiling them once during deployment via composer dump-env prod into an optimized .env.local.php file, which loads considerably faster since it is a plain PHP array. This file then effectively replaces parsing every .env file at runtime, while real environment variables set by the container or operating system continue to take precedence. Forgetting to run composer dump-env still leaves the application working correctly, but it unnecessarily loses performance, because every request has to read and parse every .env file from the filesystem all over again.
6. Common Pitfalls with Docker Environment Variables
The most common pitfall in containerized setups is forgetting that real process environment variables always take precedence over every .env file. If a docker-compose.yaml sets a value like APP_ENV=prod or DATABASE_URL under environment, that value overrides any local .env.local, even if a developer deliberately set a different value in that file for local debugging. That produces the confusing symptom of a change in .env.local seemingly having no effect, even though the file is syntactically correct and would normally be read.
A second common mistake is mixing up build-time and runtime environment variables: a value set via ARG in a Dockerfile is only available during the image build and must be explicitly passed through with ENV to become visible inside the container at runtime, which is easy to overlook especially with multi-stage Docker builds. A third pitfall involves env_file directives in docker-compose.yaml, which reference Docker's own .env file, and this is easily confused with the .env hierarchy that Symfony reads. Docker Compose reads an .env file at the project root for variable substitution within docker-compose.yaml itself, which is a completely different mechanism from Symfony's Dotenv component and easily causes confusion when both files share the same name.
7. Using the Parameter Bag Deliberately at Runtime
Parameters from services.yaml can not only be referenced within configuration itself but also queried programmatically through ParameterBagInterface, which suits generic services that need to look up a variable set of configuration values at runtime. Access happens through $parameterBag->get('app.max_retries'), where the parameter name is usually given a namespace prefix like app. to avoid collisions with internal Symfony or bundle parameters. For most cases, though, directly injecting a single parameter through the Autowire attribute with a param argument, or through a binding in services.yaml, is the clearer choice, because it makes the actual dependency visible in the constructor.
A typical use case for direct ParameterBagInterface access is a feature-flag service that needs to dynamically check at runtime whether a parameter whose name is not yet known even exists, for example when building a generic admin configuration dashboard. For the normal case of a single, clearly named configuration value, this generic access should be avoided, though, because, much like the full service container, it obscures the values a class actually needs, and static analysis tools like PHPStan cannot check the referenced parameter name at compile time.
8. Using Tests and .env.test Sensibly
For the test environment, Symfony automatically loads .env.test as long as APP_ENV is set to test during the test run, which the PHPUnit bridge and the KernelTestCase bootstrap ensure by default. One detail is easy to overlook here: .env.local is explicitly not loaded when APP_ENV is test, so local developer overrides do not accidentally leak into the CI pipeline and cause non-reproducible test failures there. Instead, only .env.test.local applies for local, test-environment-specific overrides, which are likewise never committed.
In CI environments, it is worth either explicitly committing every environment variable needed for tests to .env.test, as long as it is an uncritical test fixture, or setting it through the CI platform itself as a real process environment variable, when it involves credentials for a genuine external test instance. A CI pipeline should never depend on a developer's local .env.local, since that file naturally does not exist in the CI context, and its absence otherwise leads to hard-to-diagnose, environment-dependent test failures that cannot be reproduced on a developer's own machine.
9. A Practical Checklist for Clean Configuration Separation
A short chain of questions works as a rule of thumb for new configuration values: does the value change between environments? If not, it belongs as a parameter in services.yaml. If it does, is the value secret or security-relevant? If so, it belongs in the secrets vault rather than a plaintext .env file, especially in production. If the value is not secret but is environment-specific, such as a base URL or a feature toggle per stage, it belongs in the matching .env.$APP_ENV file or in a real environment variable set by the deployment.
This checklist avoids the most common mistakes in grown projects: secrets that accidentally end up in a committed .env, business constants that get mistakenly maintained as an environment variable and thereby lose their traceability through Git, and docker-compose configurations that silently override local developer overrides. Anyone who consistently asks these four questions for every new configuration value avoids most of the pitfalls described here from the outset, rather than discovering them only after a production incident.
| File / Mechanism | Committed? | Typical Content |
|---|---|---|
| .env | Yes | Project-wide defaults and placeholders |
| .env.local | No | Local, machine-specific overrides |
| .env.$APP_ENV | Yes | Environment-specific values, e.g. .env.test |
| .env.$APP_ENV.local | No | Local override for a specific environment |
| Secrets vault | Committed encrypted | API keys, passwords, signing secrets |
| Parameter in services.yaml | Yes | Environment-independent business values |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Dotenv and Parameter Bags: The Essentials at a Glance
.env hierarchy
Four files with a clearly defined load order; real environment variables always win.
Rule of thumb
If the value changes between environments it belongs in .env, otherwise in a parameter.
Secrets vault
Store sensitive values encrypted in production instead of a plaintext .env on the server.
Docker pitfall
Process environment variables from docker-compose silently override .env.local.