Symfony Dotenv and Parameter Bags: Keeping Configuration Cleanly Separated
AI generated
SF
{ }
Symfony · Configuration · Dotenv
Symfony Dotenv and Parameter Bags: Keeping Configuration Cleanly Separated
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.

15 min read Dotenv Component Parameter Bags & Secrets

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.

11. FAQ: Dotenv and Parameter Bags: The Essentials at a Glance

1Do I have to commit the .env file to the Git repository?
Yes, the base .env file is explicitly meant to be committed and should contain sensible placeholders and defaults, not real production secrets.
2What is the difference between .env.local and .env.$APP_ENV?
.env.local is machine-specific and never committed, while .env.$APP_ENV, such as .env.test, is environment-specific but committed and applies to every developer.
3When should a value be maintained as a parameter instead of an environment variable?
Whenever the value does not differ between environments, such as a business constant or a project-wide limit that should stay traceable through Git.
4How does the Symfony secrets vault work technically?
Through an asymmetric key pair: the public key lives in the repository for encrypting, and the private key lives only on the production server or in SYMFONY_DECRYPTION_SECRET for decrypting.
5Can I reference a vault secret like a regular environment variable?
Yes, through %env(SOME_SECRET)% in configuration, since Symfony resolves secrets transparently through the same env() mechanism used for regular environment variables.
6Why is .env.local not loaded when APP_ENV is test?
So local developer overrides do not accidentally leak into CI test runs and cause non-reproducible, environment-dependent test failures there.
7What exactly does composer dump-env prod do?
It compiles every relevant .env file once into an optimized .env.local.php file, which loads considerably faster as a plain PHP array than repeatedly parsing multiple .env files.
8Why does a Docker environment variable seemingly override my .env.local with no effect?
Because real process environment variables, such as those docker-compose sets under environment, always take precedence over every .env file, regardless of what .env.local contains.
9What is the difference between Docker Compose's .env file and Symfony's?
Docker Compose reads its own .env file at the project root for variable substitution within docker-compose.yaml itself, a completely different mechanism from Symfony's Dotenv component.
10Should I inject ParameterBagInterface directly into a service?
Only for generic cases with dynamically unknown parameter names. For a single, clearly named value, direct injection through the Autowire attribute with a param argument is the clearer, checkable choice.