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
# .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"]'# .env.local (gitignored, secrets dev)
DATABASE_URL=postgresql://achref:[email protected]:5432/app_dev
STRIPE_API_KEY=sk_test_xxx# 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)%'# 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)%'// 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 :
# 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
- Env processors chainables —
%env(json:file:resolve:CONFIG_PATH)%:resolveremplace%kernel.project_dir%,filelit le contenu,jsonparse. Lecture droite-à-gauche. - Defaults avec
default:— la valeur aprèsdefault:est un nom de parameter (pas un littéral).%env(int:default:app.http_timeout:HTTP_TIMEOUT)%: siHTTP_TIMEOUTest vide/absent, prend le parameterapp.http_timeout. Pour un fallback vide (null), utilise%env(default::HTTP_TIMEOUT)%. Indispensable pour les paramètres optionnels. - Config par environnement —
config/packages/prod/foo.yaml,config/packages/dev/foo.yaml,config/packages/test/foo.yaml. Override partiel, merge automatique avecconfig/packages/foo.yaml. - Parameter binding global — déclare une fois dans
_defaults.bind, injecté automatiquement dans tous les services par nom de paramètre + type. Plus DRY. - Secrets vault vs env — secrets pour prod (chiffrés, commités), env pour dev local. Le binaire de prod n'a que
decrypt keyen variable d'environnement, pas les secrets eux-mêmes. %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 YAMLbind:. - 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'ajouterurlencode:,base64:, etc. custom. - 7.0 :
Dotenv::load()n'auto-overload plus$_ENVsi 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
.enven prod — anti-pattern de mettre des secrets dans.envcommité. Utiliser secrets vault, ou env vars système (/etc/environment, Kubernetes secrets, etc.).- Cache trop chaud —
php bin/console cache:clear --env=prodne suffit pas après changement deservices.yamlsi OPcache est actif → restart PHP-FPM. - Env var changée mais non rechargée — Symfony lit
$_ENVau 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. - Type casting implicite —
APP_DEBUG=0est la string"0", truthy en PHP. Utiliser%env(bool:APP_DEBUG)%ou%env(int:APP_DEBUG)%pour être safe. - Cascade dotenv mal comprise —
.env.localoverride.env,.env.{env}.localoverride.env.{env}.APP_ENVest lu en premier pour savoir quel.env.{env}charger. - Secret vault sans
SYMFONY_DECRYPTION_SECRETen prod — secrets non déchiffrables → exceptions au boot. Toujours configurer la clé via env système, jamais committer la decrypt key. 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.).%%échappé — pour avoir un%littéral dans un YAML, double-le :password: 'foo%%bar'. Sinon Symfony cherche un parameterbar.
🧪 Testing
// 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 :
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 :
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.
// 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'];
}
}# 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']// 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,
) {}
}# 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%'# .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// 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 unbind: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écision | Build-time (compilé dans le container) | Runtime (%env(...)%) | Données (DB/Redis/feature-flag) |
|---|---|---|---|
| Connu quand ? | Au cache:warmup | Au boot du kernel (ou par requête en non-worker) | À chaque requête |
| Change comment ? | Redeploy + warmup | Restart process / worker | INSERT/UPDATE, instantané |
| Latence de propagation | minutes (CI/CD) | secondes (rolling restart) | millisecondes |
| Exemples | nb de workers Messenger, mapping de routes, tag de services | DSN, clés API, URLs, niveau de log | flags A/B, config tenant, kill-switch |
| Coût d'un mauvais choix | tu redéploies pour un flag → vélocité morte | tu mets un flag en DB → 1 requête SQL par hit | tu 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.
# 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-varsaffiche les noms,--revealles valeurs. Bannir--revealdes 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 Monologprocessors+ Sentrybefore_sendpour redacterpassword,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 uneEnvNotFoundExceptionà la résolution (donc à la première requête qui touche le service, ou au warmup si compilée). Préfère undefault:explicite + une validation au démarrage (un compiler pass ou unkernel.bootqui 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 prodgénère.env.local.php(unreturn [...]PHP pur) → supprime tout le parsing des fichiers.envau boot. Gain mesurable sur les apps à fort QPS en FPM. À faire dans leDockerfile(build stage), jamais à la main en prod.cache:warmupdoit 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
| Processor | Exemple | Ré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
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 :
- Bootstrap Dotenv (
Dotenv::bootEnv()). - Détecte l'environnement (SAPI, CLI, FrankenPHP, RoadRunner).
- Choisit le
RunnerInterfaceadapté. - Invoke le closure avec
$context = $_SERVER + $_ENV. - Récupère le
Kernel, leApplicationConsole, ou autre. - 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
// 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
- Configuring Symfony
- Environment Variables
- Secrets Vault
- Multiple Kernels per environment
- 12-factor app — config (https://12factor.net/config)
composer dump-env prod— Flex command pour pré-compiler les env