Skip to content

Configuration — env vars, processors, secrets

TL;DR — Config Symfony = 4 niveaux : YAML/PHP/attribute (statique compilé) → parameters (%kernel.project_dir%) → env vars (%env(DB_URL)% résolu au runtime) → secrets (%env(secret:STRIPE_KEY)% déchiffrés au runtime). Les env processors (int:, bool:, json:, csv:) transforment les strings env en types PHP. Maîtriser la cascade dotenv (.env.env.local.env.{env}.env.{env}.local) évite 90% des bugs prod.

🧠 Mental model — ASCII diagram + analogie

   BUILD TIME (cache:warmup)              RUNTIME (chaque requête)
   ┌──────────────────────────┐          ┌────────────────────────┐
   │ config/packages/*.yaml    │          │ $_ENV (loaded via      │
   │ config/services.yaml      │          │  Dotenv if no real env)│
   │ config/routes/*.yaml      │          │                        │
   │      │                    │          │       │                │
   │      ▼                    │          │       ▼                │
   │ Extensions::load()        │          │ EnvVarProcessor        │
   │      │                    │          │ - resolve("DB_URL")    │
   │      ▼                    │          │ - apply "int:" prefix  │
   │ ContainerBuilder          │          │ - cast / parse JSON    │
   │   - parameters            │          │                        │
   │   - definitions           │          │       │                │
   │      │                    │          │       ▼                │
   │      ▼                    │          │ Inject in service args │
   │  COMPILED PHP CONTAINER   │  ─────►  │ that contain %env(.)%  │
   │  (with placeholders for   │          │ placeholders           │
   │   env vars NOT resolved)  │          │                        │
   └──────────────────────────┘          └────────────────────────┘

Analogie : ton container compilé est un gabarit imprimé. Tous les %env(FOO)% sont des trous dans le gabarit. À chaque requête, le EnvVarProcessor remplit les trous avec les valeurs actuelles de l'environnement. C'est pour ça que tu peux changer .env.local sans clear cache, mais que changer un YAML demande un warmup.

🛠️ Code minimal — env processors + secrets + per-env config

bash
# .env (committed, defaults non-secrets)
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=__CHANGE_ME__
DATABASE_URL=postgresql://app:[email protected]:5432/app
MAILER_DSN=null://null
PAYMENT_FEATURES='["stripe","paypal"]'
bash
# .env.local (gitignored, secrets dev)
DATABASE_URL=postgresql://achref:[email protected]:5432/app_dev
STRIPE_API_KEY=sk_test_xxx
yaml
# config/services.yaml
parameters:
    # default: prend un NOM de parameter en fallback (pas un littéral) :
    app.http_timeout_default: 30
    app.timeout: '%env(int:default:app.http_timeout_default:HTTP_TIMEOUT)%'
    app.features: '%env(json:PAYMENT_FEATURES)%'
    app.allowed_ips: '%env(csv:ALLOWED_IPS)%'
    app.config_path: '%env(resolve:CONFIG_FILE)%'
    app.url_parsed: '%env(url:APP_URL)%'
    app.is_prod_default: false
    app.is_prod: '%env(bool:default:app.is_prod_default:APP_PROD)%'

services:
    _defaults:
        autowire: true
        bind:
            int $httpTimeout: '%app.timeout%'
            array $features: '%app.features%'
            string $stripeKey: '%env(STRIPE_API_KEY)%'
yaml
# config/packages/cache.yaml
framework:
    cache:
        app: cache.adapter.filesystem

# config/packages/prod/cache.yaml (override en prod)
framework:
    cache:
        app: cache.adapter.redis
        default_redis_provider: '%env(REDIS_URL)%'
php
// Code consommateur
namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class StripeClient
{
    public function __construct(
        #[Autowire('%env(STRIPE_API_KEY)%')]
        private string $apiKey,
        #[Autowire('%app.timeout%')]
        private int $timeout,
    ) {}
}

Secrets vault :

bash
# Génère une keypair
php bin/console secrets:generate-keys

# Stocke un secret chiffré
php bin/console secrets:set STRIPE_API_KEY
# (prompt interactif, ou --random, ou < fichier)

# Liste
php bin/console secrets:list --reveal

# Usage : identique à un env var, transparent
# %env(STRIPE_API_KEY)% → cherche d'abord dans secrets vault, fallback env

🎯 Patterns courants

  1. Env processors chainables%env(json:file:resolve:CONFIG_PATH)% : resolve remplace %kernel.project_dir%, file lit le contenu, json parse. Lecture droite-à-gauche.
  2. Defaults avec default: — la valeur après default: est un nom de parameter (pas un littéral). %env(int:default:app.http_timeout:HTTP_TIMEOUT)% : si HTTP_TIMEOUT est vide/absent, prend le parameter app.http_timeout. Pour un fallback vide (null), utilise %env(default::HTTP_TIMEOUT)%. Indispensable pour les paramètres optionnels.
  3. Config par environnementconfig/packages/prod/foo.yaml, config/packages/dev/foo.yaml, config/packages/test/foo.yaml. Override partiel, merge automatique avec config/packages/foo.yaml.
  4. Parameter binding global — déclare une fois dans _defaults.bind, injecté automatiquement dans tous les services par nom de paramètre + type. Plus DRY.
  5. Secrets vault vs env — secrets pour prod (chiffrés, commités), env pour dev local. Le binaire de prod n'a que decrypt key en variable d'environnement, pas les secrets eux-mêmes.
  6. %kernel.project_dir%, %kernel.environment%, %kernel.debug% : parameters built-in toujours dispos. Privilégie-les aux constantes globales.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : env processors stables, secrets vault stable. #[Autowire] n'existe pas encore → uniquement YAML bind:.
  • 6.0 : suppression de Container::set() pour parameters runtime. Tu dois recompiler pour changer un param non-env.
  • 6.1 : #[Autowire] attribute → injecter directement %env(...)% dans un argument sans passer par YAML.
  • 6.3 : #[Autowire(env: 'STRIPE_KEY')] shortcut. #[AutowireEnv('STRIPE_KEY')] apparait.
  • 6.4 LTS : env var processors deviennent extensibles via tag container.env_var_processor. Possibilité d'ajouter urlencode:, base64:, etc. custom.
  • 7.0 : Dotenv::load() n'auto-overload plus $_ENV si la variable existe déjà au niveau système → priorité système env > .env files (sécurité prod).
  • 7.1+ : nouveau processor enum: pour caster directement vers un enum PHP 8.1 : %env(enum:App\Enum\Mode:APP_MODE)%.

⚠️ Pitfalls

  1. .env en prod — anti-pattern de mettre des secrets dans .env commité. Utiliser secrets vault, ou env vars système (/etc/environment, Kubernetes secrets, etc.).
  2. Cache trop chaudphp bin/console cache:clear --env=prod ne suffit pas après changement de services.yaml si OPcache est actif → restart PHP-FPM.
  3. Env var changée mais non rechargée — Symfony lit $_ENV au boot du kernel. Worker mode (FrankenPHP/RoadRunner) garde le kernel booté entre requêtes : changer une env ne prend effet qu'au redémarrage worker.
  4. Type casting impliciteAPP_DEBUG=0 est la string "0", truthy en PHP. Utiliser %env(bool:APP_DEBUG)% ou %env(int:APP_DEBUG)% pour être safe.
  5. Cascade dotenv mal comprise.env.local override .env, .env.{env}.local override .env.{env}. APP_ENV est lu en premier pour savoir quel .env.{env} charger.
  6. Secret vault sans SYMFONY_DECRYPTION_SECRET en prod — secrets non déchiffrables → exceptions au boot. Toujours configurer la clé via env système, jamais committer la decrypt key.
  7. config/packages/test/ qui override trop — tu testes une config différente de la prod. Limite aux services strictement test-specific (cache.adapter.array, mailer null, etc.).
  8. %% échappé — pour avoir un % littéral dans un YAML, double-le : password: 'foo%%bar'. Sinon Symfony cherche un parameter bar.

🧪 Testing

php
// tests/Configuration/EnvProcessorTest.php
<?php
namespace App\Tests\Configuration;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class EnvProcessorTest extends KernelTestCase
{
    public function testFeaturesIsArray(): void
    {
        self::bootKernel();
        $features = static::getContainer()->getParameter('app.features');
        self::assertIsArray($features);
        self::assertContains('stripe', $features);
    }

    public function testTimeoutHasDefault(): void
    {
        // Unsetting before bootKernel pour tester le default
        unset($_ENV['HTTP_TIMEOUT'], $_SERVER['HTTP_TIMEOUT']);
        putenv('HTTP_TIMEOUT');

        self::bootKernel();
        self::assertSame(30, static::getContainer()->getParameter('app.timeout'));
    }
}

Override env per-test :

php
public function testWithCustomEnv(): void
{
    $_ENV['STRIPE_API_KEY'] = 'sk_test_override';
    self::bootKernel();
    // ...
}

protected function tearDown(): void
{
    unset($_ENV['STRIPE_API_KEY']);
    parent::tearDown();
}

Debug CLI :

bash
php bin/console debug:container --env-vars
php bin/console debug:config framework cache
php bin/console secrets:list --reveal --env=prod
php bin/console debug:dotenv

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH multi-tenant (PayFit-like) : config par tenant via parameter bag dynamique

Contexte : SaaS de paie pour PME (~5 000 clients). Chaque tenant a sa convention collective, sa devise, ses jours fériés, son URS de signature électronique (Yousign, DocuSign, Universign), et ses templates email. Centraliser toute cette config dans services.yaml est impossible (taille, latence de warmup).

Solution : un TenantConfigBag chargé à kernel.request après résolution du tenant, exposé comme service request-scoped. Le bag lit la config depuis une table Postgres tenant_config cachée 10 minutes en Redis. Les services consommateurs (PayrollComputer, SignatureClient, Mailer) reçoivent l'interface TenantConfigInterface et lisent getCurrency(), getSignatureProvider(). La config statique de Symfony (config/packages/) ne porte que ce qui est invariant : connection DB principale, security firewalls, listeners.

Bénéfice : ajouter un client = INSERT en base. Bumper la conformité d'une convention collective = update SQL. Aucun deploy.

Scénario 2 — Banque : secrets via HashiCorp Vault + Symfony env processors

Contexte : néobanque dont la sécurité ACPR exige : (1) aucun secret en clair dans le filesystem, (2) rotation automatique des credentials DB toutes les 24h, (3) audit trail de chaque lecture de secret. Symfony Secrets Vault local n'est pas suffisant car non-rotatable depuis l'extérieur.

L'équipe écrit un EnvVarProcessor custom : %env(vault:DB_CREDENTIALS)%. Le processor parle au Vault HCP via SDK, lit le KV secret/data/payments/db, cache 5 minutes en APCu. Vault rotate le mot de passe DB toutes les 24h, le worker FrankenPHP recharge la connection à chaque cycle. Les secrets sont injectés dans doctrine.dbal.url, mailer.dsn, et toutes les API clients via le syntaxe %env(vault:STRIPE_KEY)%.

Résultat : pas un seul secret en variable d'environnement statique ; le pentest annuel n'a pas trouvé de credentials exposés. La rotation est complètement transparente pour le code applicatif.

Scénario 3 — E-commerce Mode (Sézane-like) : pipeline CI/CD avec env-per-PR

Contexte : retailer mode FR avec 40 devs. Chaque PR déclenche un environnement éphémère (review app sur Kubernetes) avec sa propre URL pr-1234.preview.brand.fr, sa propre base, son propre tenant Stripe en mode test.

Pipeline GitHub Actions : génère dynamiquement un .env.preview (avec APP_URL=https://pr-1234.preview.brand.fr, STRIPE_SECRET=sk_test_..., DATABASE_URL=...), monte le pod, run cache:warmup. La config Symfony utilise des processors agressivement : %env(default:fallback_url:APP_URL)%, %env(int:PHP_MAX_MEMORY)%, %env(json:FEATURE_FLAGS)%. Les valeurs sensibles (production Stripe live key) ne sont jamais dans le pipeline preview — un EnvVarProcessor failsafe: retourne null si la variable n'existe pas en preview, et les services adaptent leur comportement.

Bénéfice : ~80 review apps actives en parallèle sans collision, et les devs voient leur feature live en HTTPS avant merge. Aucune fuite de secret prod en preview.

🛠️ Exemple end-to-end

Use case : compagnie d'assurance qui gère 4 environnements (dev, staging, preprod, prod) avec des configs subtilement différentes : URL d'API de référentiel, niveau de logging, throttling rate limiter, target webhook. On veut une config typée, des secrets chiffrés en repo, et un processor custom pour décrypter des tokens.

php
// src/Config/EnvVarProcessor/EncryptedJwtProcessor.php
<?php
declare(strict_types=1);

namespace App\Config\EnvVarProcessor;

use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;

final class EncryptedJwtProcessor implements EnvVarProcessorInterface
{
    public function __construct(
        #[\SensitiveParameter] private readonly string $masterKey,
    ) {}

    /**
     * IMPORTANT : pour `%env(encjwt:resolve:FOO)%`, $name vaut "resolve:FOO".
     * Il faut TOUJOURS rappeler $getEnv($name) (et non getenv()) pour laisser
     * Symfony résoudre récursivement les processors suivants dans la chaîne,
     * lire le secrets vault, appliquer les defaults, etc.
     * Le type de retour est `mixed` (un processor peut produire un array, etc.).
     */
    public function getEnv(string $prefix, string $name, \Closure $getEnv): string
    {
        // $name est l'expression restante (sans "encjwt:"). $getEnv la résout
        // entièrement : env système, .env, vault, processors imbriqués.
        $cipher = (string) $getEnv($name);

        $decoded = base64_decode($cipher, true);
        if ($decoded === false) {
            throw new \RuntimeException("Invalid base64 in encrypted env var {$name}");
        }
        if (\strlen($decoded) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
            throw new \RuntimeException("Ciphertext too short for {$name}");
        }

        // La clé doit faire exactement 32 octets ; un fichier monté finit souvent
        // par un "\n" → on tronque dur pour éviter un sodium_exception opaque.
        $key = substr($this->masterKey, 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
        if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
            throw new \RuntimeException('ENCRYPTION_KEY must be 32 bytes');
        }

        $nonce   = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $payload = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $plain   = sodium_crypto_secretbox_open($payload, $nonce, $key);

        if ($plain === false) {
            // Échec = clé invalide OU ciphertext altéré (AEAD). Ne JAMAIS logger
            // le ciphertext ni la clé. On efface la clé de la mémoire ensuite.
            throw new \RuntimeException("Cannot decrypt {$name} (bad key or tampered ciphertext)");
        }

        sodium_memzero($key);

        return $plain;
    }

    public static function getProvidedTypes(): array
    {
        return ['encjwt' => 'string'];
    }
}
yaml
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Config\EnvVarProcessor\EncryptedJwtProcessor:
        arguments:
            $masterKey: '%env(file:ENCRYPTION_KEY_FILE)%'
        tags: ['container.env_var_processor']
php
// src/Config/AppContext.php
<?php
declare(strict_types=1);

namespace App\Config;

enum AppEnvironment: string
{
    case Dev = 'dev';
    case Staging = 'staging';
    case Preprod = 'preprod';
    case Prod = 'prod';

    public function isProductionLike(): bool
    {
        return $this === self::Preprod || $this === self::Prod;
    }
}

final readonly class AppContext
{
    public function __construct(
        public AppEnvironment $environment,
        public string $referentialApiUrl,
        public string $referentialApiToken,
        public int $rateLimitPerMinute,
        public string $webhookSigningSecret,
    ) {}
}
yaml
# config/packages/app.yaml
parameters:
    app.environment: '%env(string:APP_ENV)%'
    app.referential.api_url: '%env(string:REFERENTIAL_API_URL)%'
    app.referential.api_token: '%env(encjwt:REFERENTIAL_API_TOKEN_ENC)%'
    app.rate_limit.per_minute: '%env(int:RATE_LIMIT_PER_MINUTE)%'
    app.webhook.signing_secret: '%env(string:WEBHOOK_SIGNING_SECRET)%'

services:
    App\Config\AppContext:
        arguments:
            $environment: !php/const App\Config\AppEnvironment::Prod
            $referentialApiUrl: '%app.referential.api_url%'
            $referentialApiToken: '%app.referential.api_token%'
            $rateLimitPerMinute: '%app.rate_limit.per_minute%'
            $webhookSigningSecret: '%app.webhook.signing_secret%'
ini
# .env
RATE_LIMIT_PER_MINUTE=120
REFERENTIAL_API_URL=https://referentiel.assur.local

# .env.prod
RATE_LIMIT_PER_MINUTE=5000
REFERENTIAL_API_URL=https://api.referentiel-assurance.fr
php
// src/Service/ReferentialClient.php
<?php
declare(strict_types=1);

namespace App\Service;

use App\Config\AppContext;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class ReferentialClient
{
    public function __construct(
        private HttpClientInterface $http,
        private AppContext $context,
    ) {}

    public function fetchInsuredPerson(string $ssn): array
    {
        $response = $this->http->request('GET', $this->context->referentialApiUrl . '/persons/' . $ssn, [
            'auth_bearer' => $this->context->referentialApiToken,
            'timeout' => $this->context->environment->isProductionLike() ? 5 : 30,
        ]);

        return $response->toArray();
    }
}

Toute config sensible est chiffrée dans le repo (REFERENTIAL_API_TOKEN_ENC=base64...), une clé de chiffrement est lue depuis un fichier monté par Kubernetes secrets. La rotation de token = nouveau commit de .env.prod. Les devs ne voient jamais le token clair, mais peuvent déployer une nouvelle version sans dépendre d'un ops.


🔁 Quand utiliser / éviter

  • Env var : tout ce qui change entre dev/staging/prod (DSN, API keys, feature flags, URLs).
  • Parameter : constantes du projet (timeout par défaut, taille de page, version de l'API).
  • Secret vault : valeurs sensibles que tu veux versionner (chiffrées). Évite si tu utilises déjà un secret manager externe (Vault, AWS Secrets Manager).
  • YAML vs PHP config : YAML pour la majorité (lisible). PHP pour la config conditionnelle complexe (boucles, conditions).
  • Attribute config (#[Autowire]) : pour binding très ciblé proche du code. Évite si tu rebind le même env dans 10 services → fais un bind: global.

🧭 Comment un staff engineer raisonne sur la config

La question structurante n'est pas « YAML ou PHP ? » mais « à quel moment cette valeur est-elle connue, et à quelle vitesse doit-elle changer ? ». C'est l'axe qui décide de tout le reste.

Axe de décisionBuild-time (compilé dans le container)Runtime (%env(...)%)Données (DB/Redis/feature-flag)
Connu quand ?Au cache:warmupAu boot du kernel (ou par requête en non-worker)À chaque requête
Change comment ?Redeploy + warmupRestart process / workerINSERT/UPDATE, instantané
Latence de propagationminutes (CI/CD)secondes (rolling restart)millisecondes
Exemplesnb de workers Messenger, mapping de routes, tag de servicesDSN, clés API, URLs, niveau de logflags A/B, config tenant, kill-switch
Coût d'un mauvais choixtu redéploies pour un flag → vélocité mortetu mets un flag en DB → 1 requête SQL par hittu mets un DSN en DB → bootstrap circulaire (DB pour lire la config DB)

Règle staff : tout ce qui doit changer sans redeploy mais avec un restart acceptable → env var. Tout ce qui doit changer à chaud, par utilisateur/tenant, ou très fréquemment → store applicatif (DB/Redis) lu dans une couche FeatureFlag/TenantConfig, pas dans le container. Confondre les deux est la cause #1 de configs ingérables.

Le piège du "config qui appelle la config"

Un anti-pattern récurrent : vouloir lire le DSN Doctrine depuis une table… alors que Doctrine a besoin du DSN pour se connecter. Toute config nécessaire au bootstrap (DB primaire, cache, secrets decrypt key, APP_ENV) DOIT vivre en env var/secret, jamais en data. La data-config ne peut porter que ce qui est lu après que l'infra de base est debout.

🔐 Précédence des sources — qui gagne vraiment

C'est la source de confusion la plus chère en prod. Ordre de priorité du plus fort au plus faible (Symfony 7+) :

1. Variable d'environnement RÉELLE du système (export, K8s env, /etc/environment)
2. .env.local.php          ← si présent (composer dump-env), remplace 3-6
3. .env.{APP_ENV}.local    ← gitignored
4. .env.{APP_ENV}          ← committed
5. .env.local              ← gitignored
6. .env                    ← committed, defaults
7. default: du processor   ← %env(int:default:30:X)%

Changement majeur 7.0 : avant, Dotenv faisait de l'overload et écrasait une variable système déjà exportée. Depuis 7.0, l'env système réel gagne toujours sur les fichiers .env. C'est ce qui rend .env safe en prod : tes secrets injectés par K8s ne seront jamais écrasés par un .env oublié dans l'image Docker. En 5.4/6.x sur des projets anciens, vérifie Dotenv::usePutenv() et le flag d'overload — un .env committé pouvait écraser un secret système.

bash
# Vérifier d'où vient RÉELLEMENT une valeur (indispensable en debug prod) :
php bin/console debug:dotenv DATABASE_URL
# affiche la cascade et marque la ligne gagnante

🔭 Observability & sécurité — ce qui te sauve à 3h du matin

  • Ne jamais dumper le container avec secrets : debug:container --env-vars affiche les noms, --reveal les valeurs. Bannir --reveal des scripts CI loggés.
  • Scrub des secrets dans les logs/APM : un %env(json:...)% mal formé lève une exception dont le message peut contenir la valeur brute. Configure Monolog processors + Sentry before_send pour redacter password, token, secret, key. Tester ce scrubbing est un vrai test.
  • Fail-fast au boot, pas au premier hit : une env var manquante sans default: lève une EnvNotFoundException à la résolution (donc à la première requête qui touche le service, ou au warmup si compilée). Préfère un default: explicite + une validation au démarrage (un compiler pass ou un kernel.boot qui vérifie les invariants critiques) plutôt qu'une panne 6h plus tard sur une route rare.
  • Rotation = invalidation : si un secret est lu via un processor qui cache (APCu/Redis), une rotation côté Vault ne se voit pas tant que le TTL n'expire pas. Documente le TTL comme un SLA ("propagation rotation ≤ 5 min").

🏎️ Performance — où ça coûte vraiment

  • Les %env()% sont résolus à chaque boot de kernel, pas mis en cache entre requêtes en mode classique PHP-FPM (chaque requête = nouveau process = nouveau boot). En worker mode (FrankenPHP/RoadRunner/Swoole), le kernel reste booté → les env sont résolus une fois et figés jusqu'au restart worker. C'est un piège (cf. Pitfalls) mais aussi un gain perf.
  • Un processor custom qui fait un appel réseau (Vault, Secrets Manager) sans cache ajoute sa latence à chaque boot. En FPM, ça veut dire à chaque requête. Toujours mettre un cache (APCu local de préférence, pas Redis qui rajoute un round-trip) avec un TTL court.
  • composer dump-env prod génère .env.local.php (un return [...] PHP pur) → supprime tout le parsing des fichiers .env au boot. Gain mesurable sur les apps à fort QPS en FPM. À faire dans le Dockerfile (build stage), jamais à la main en prod.
  • cache:warmup doit tourner au build (immutable image), pas au démarrage du pod. Un warmup au runtime = cold start de plusieurs secondes × N pods.

🪜 Approfondissement — tous les env processors built-in

ProcessorExempleRésultat
string: (default)%env(FOO)%"bar"
int:%env(int:HTTP_TIMEOUT)%30
float:%env(float:RATE)%0.15
bool:%env(bool:DEBUG)%true
not:%env(not:DEBUG)%false (inversé)
json:%env(json:FEATURES)%['a', 'b']
csv:%env(csv:IPS)%['1.1.1.1', '2.2.2.2']
default:param:%env(default:app.timeout:HTTP_TIMEOUT)%valeur du parameter app.timeout si unset (le segment est un nom de parameter, pas un littéral)
file:%env(file:SECRET_FILE)%contenu du fichier
resolve:%env(resolve:URL)%params %foo% remplacés
base64:%env(base64:KEY)%décodé base64
query_string:%env(query_string:URL)%array des params parsés
url:%env(url:DATABASE_URL)%array parse_url()
enum:%env(enum:App\Enum\Mode:MODE)%enum case (7.1+)
trim:%env(trim:RAW)%trimmed

Chaînables (lecture droite-à-gauche) : %env(json:base64:CONFIG)% = décode base64 puis parse JSON.

🪜 Cascade dotenv détaillée

Symfony charge les fichiers dans cet ordre exact (les suivants override les précédents) :

1. .env                  ← committed, defaults
2. .env.local            ← gitignored, overrides locaux (SAUF si APP_ENV=test)
3. .env.{APP_ENV}        ← committed, par environnement
4. .env.{APP_ENV}.local  ← gitignored, secrets par env (SAUF si APP_ENV=test)

Exception test : .env.test.local n'est PAS chargé pour assurer la reproductibilité CI. C'est .env.test (committed) qui doit contenir tout ce qu'il faut.

.env.local.php : si présent (généré par composer dump-env prod), il est utilisé à la place de TOUS les autres (sauf .env.local). C'est un cache statique pour la prod.

🪜 Configuration runtime — Symfony Runtime component

Le fichier public/index.php minimal :

php
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

Le autoload_runtime.php est généré par symfony/runtime. Il :

  1. Bootstrap Dotenv (Dotenv::bootEnv()).
  2. Détecte l'environnement (SAPI, CLI, FrankenPHP, RoadRunner).
  3. Choisit le RunnerInterface adapté.
  4. Invoke le closure avec $context = $_SERVER + $_ENV.
  5. Récupère le Kernel, le Application Console, ou autre.
  6. Run + send response (ou exit code en CLI).

C'est ça qui permet de switch de runner sans modifier index.php (juste un composer require runtime/frankenphp-symfony).

🪜 services.yaml vs services.php vs attributes — quand utiliser quoi

php
// config/services.php — alternative PHP, autocompletion IDE
<?php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $container) {
    $services = $container->services()
        ->defaults()
        ->autowire()
        ->autoconfigure();

    $services->load('App\\', '../src/')
        ->exclude(['../src/{Entity,Tests,Kernel.php}']);

    $services->set(App\Payment\StripeGateway::class)
        ->tag('app.payment_gateway', ['priority' => 100]);
};
  • YAML : 90% des cas. Lisible, diff-friendly.
  • PHP : config conditionnelle (if ($env === 'prod') ...), boucles, validation au build.
  • Attribute (#[Autowire], #[AutowireService]) : binding hyper-local à un service. Évite si > 5 attributes par classe (lisibilité).

🏋️ Exercices

Progression : implémenter → durcir en production → casser puis réparer. Pars d'un projet Symfony 7 neuf (symfony new --webapp lab).

1. Processor csv: typé avec default (échauffement)

Objectif : exposer un parameter app.allowed_origins (array) depuis CORS_ALLOWED_ORIGINS, avec un default sûr si la var est absente. Indice/Solution : %env(csv:default:cors_default:CORS_ALLOWED_ORIGINS)%, où cors_default est un parameter ['https://app.local']. Écris un KernelTestCase qui vérifie le default ET une valeur surchargée via $_ENV. Piège : csv: sur une string vide renvoie [''], pas [] — teste-le.

2. Cascade dotenv : prouve la précédence (compréhension)

Objectif : pose la même clé FOO dans .env, .env.local, .env.prod, .env.prod.local avec 4 valeurs distinctes, puis prédis et vérifie la valeur résolue en APP_ENV=prod, puis avec un export FOO=... système. Indice/Solution : php bin/console debug:dotenv FOO. Attendu : .env.prod.local gagne sur les fichiers ; l'export système gagne sur tout (Symfony 7). Bonus : lance composer dump-env prod et observe que .env.local.php court-circuite la cascade fichiers.

3. Processor custom vault: avec cache (production-grade)

Objectif : implémente %env(vault:secret/data/app/db#password)% qui lit un KV (mock un client en mémoire), cache la valeur en APCu 60s, et propage les processors imbriqués via $getEnv. Indice/Solution : EnvVarProcessorInterface, getProvidedTypes(): ['vault' => 'string']. Parse $name en path#field. Cache key = vault. . sha1($name). Test le hit/miss et le TTL. Échec attendu si tu appelles getenv() au lieu de $getEnv($name) → tu casses le chaînage et les defaults.

4. Fail-fast au boot sur invariants critiques (production-grade)

Objectif : écris un CompilerPass (ou un EventSubscriber sur kernel.request priorité haute) qui refuse de booter en prod si APP_SECRET vaut __CHANGE_ME__ ou si DATABASE_URL pointe sur 127.0.0.1. Indice/Solution : ContainerBuilder::resolveEnvPlaceholders() est risqué au compile (env pas encore là). Préfère un subscriber qui throw une \RuntimeException claire au premier hit, gardé par %kernel.environment%. Vérifie que le message ne contient pas le secret.

5. Casser puis réparer : le secret figé en worker mode (break-then-fix)

Objectif : reproduis le bug "j'ai rotaté la clé Stripe mais l'app utilise toujours l'ancienne". Lance en FrankenPHP worker, change STRIPE_KEY, observe que rien ne change. Puis répare sans restart manuel. Indice/Solution : le kernel booté fige $_ENV. Solutions : (a) lire le secret via un service qui ré-interroge le vault avec TTL (pas via injection %env% figée au boot), ou (b) un endpoint /admin/reload qui appelle Runtime/restart worker, ou (c) signal SIGUSR1 selon le runner. Documente le tradeoff latence-de-propagation vs perf.

6. Multi-tenant config sans exploser le container (architecte)

Objectif : 5000 tenants, chacun avec devise + provider de signature. Implémente un TenantConfigProvider request-scoped lu depuis Postgres, caché 10 min en Redis, derrière une interface. Prouve que le warmup du container reste constant quel que soit le nombre de tenants. Indice/Solution : la config tenant ne touche jamais services.yaml. Service #[Autowire] du RequestStack, résolution du tenant sur kernel.request, lazy-load. Mesure le temps de cache:warmup à 5 puis 5000 tenants — il ne doit pas bouger (c'est la preuve que tu n'as rien mis dans le container).

🎤 En entretien

Q : Pourquoi %env(FOO)% peut changer sans cache:clear, mais pas un parameter YAML ? R : Les %env()% sont des placeholders laissés non-résolus dans le container compilé ; ils sont remplis au runtime par l'EnvVarProcessor à chaque boot du kernel. Un parameter YAML littéral est gravé dans le container compilé au warmup — le changer exige une recompilation.

Q : Un dev met un secret dans .env committé "juste pour dev". Quel est le risque réel et la bonne pratique ? R : Risque : fuite dans l'historique git (irréversible sans rewrite + rotation), et en prod l'env système gagne sur .env (7.0) mais un secret réel reste exposé dans l'image. Bonne pratique : .env ne porte que des defaults non-secrets ; les secrets dev vont dans .env.local (gitignored) ou le secrets vault, et en prod via env système (K8s secrets) ou vault externe.

Q : Tu rotates un secret côté HashiCorp Vault, l'app ne le voit pas. Pourquoi, et quels leviers ? R : Soit le kernel est figé (worker mode → $_ENV lu une fois), soit ton processor cache la valeur (TTL non expiré). Leviers : TTL court documenté comme SLA, restart/rolling des workers à la rotation, ou lecture du secret via un service à la demande plutôt qu'une injection %env% figée au boot.

Q : APP_DEBUG=0 mais ton code voit true. Que se passe-t-il ? R : 0 est la string "0" côté env ; selon la conversion, une string non-vide peut être truthy. Il faut caster explicitement : %env(bool:APP_DEBUG)% (ou int:). C'est l'illustration que tout env var est une string tant qu'un processor ne l'a pas typée.

🔗 Liens

Bibliothèque tech perso — Achref