Environments — APP_ENV, dotenv cascade, cache warmup
TL;DR —
APP_ENVdétermine 3 choses : (1) quelsconfig/packages/{env}/*.yamlcharger, (2) quel.env.{env}.locallire, (3) le dossier de cachevar/cache/{env}.APP_DEBUGest orthogonal : tu peux avoirAPP_ENV=prod APP_DEBUG=1(utile pour debug une régression sans config staging dédiée). Maîtriser la cascade dotenv + container compilation + cache warmup = pouvoir déployer sans surprise.
🧠 Mental model — ASCII diagram + analogie
┌─────────────────────────────────────────────────────────────────┐
│ BOOT SEQUENCE (chaque requête en dev, une fois en prod worker) │
│ │
│ 1. autoload_runtime.php → Runtime::__invoke(closure) │
│ 2. Dotenv::bootEnv('.env') │
│ Loads in order (later wins): │
│ ┌────────────────────────────────────────┐ │
│ │ .env ← committed │ │
│ │ .env.local ← gitignored │ ONLY if not test │
│ │ .env.{APP_ENV} ← committed │ │
│ │ .env.{APP_ENV}.local ← gitignored │ ONLY if not test │
│ └────────────────────────────────────────┘ │
│ 3. APP_ENV, APP_DEBUG now in $_ENV │
│ 4. new Kernel($_ENV['APP_ENV'], (bool) $_ENV['APP_DEBUG']) │
│ 5. Kernel::boot() │
│ - $debug ? cache miss → CONTAINER COMPILATION (slow) │
│ - else load var/cache/{env}/App_KernelXxx.php (fast) │
│ 6. $kernel->handle($request) → Response │
└─────────────────────────────────────────────────────────────────┘Analogie : APP_ENV est la playlist que tu choisis ; chaque playlist a ses propres morceaux (config). APP_DEBUG est le mode karaoké : tu peux l'activer sur n'importe quelle playlist. Le cache var/cache/{env} est la version downloadée de la playlist : si tu changes un morceau (yaml), tu dois re-télécharger (cache warmup).
🛠️ Code minimal — env-specific config + cache strategies
# .env (committed)
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=__will_be_overridden__
DATABASE_URL=postgresql://app:[email protected]:5432/app
MAILER_DSN=null://null# .env.dev (committed — defaults pour dev)
APP_DEBUG=1
MAILER_DSN=smtp://localhost:1025 # Mailhog# .env.test (committed — defaults pour test)
APP_ENV=test
KERNEL_CLASS=App\Kernel
SYMFONY_DEPRECATIONS_HELPER=max[self]=0&max[direct]=0
PANTHER_NO_HEADLESS=0# .env.local (gitignored)
DATABASE_URL=postgresql://achref:[email protected]:5432/app_dev
STRIPE_API_KEY=sk_test_localdev# .env.prod.local (gitignored, sur le serveur prod uniquement)
APP_SECRET=ReallyLongRandomString
DATABASE_URL=postgresql://prod_user:[email protected]:5432/app_prod
STRIPE_API_KEY=sk_live_realKey# config/packages/dev/web_profiler.yaml — actif uniquement en dev
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect_serializer_data: true# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json# config/packages/test/framework.yaml
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
cache:
app: cache.adapter.array// src/Kernel.php
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function getCacheDir(): string
{
// En CI ou container, override possible
return ($_ENV['APP_CACHE_DIR'] ?? $this->getProjectDir().'/var/cache').'/'.$this->environment;
}
public function getLogDir(): string
{
return ($_ENV['APP_LOG_DIR'] ?? $this->getProjectDir().'/var/log');
}
}🎯 Patterns courants
- Cache warmup CI/CD :
php bin/console cache:clear --env=prod --no-debugpuisphp bin/console cache:warmup --env=prod --no-debugpendant le build, copiervar/cache/proddans l'artefact de déploiement → boot prod instantané. - Container compilé en dev : Symfony recompile auto si tu changes un YAML. Mais les classes PHP des services nécessitent un
cache:clear(ou OPcache reset) pour être pickées si tu en ajoutes une nouvelle. - Worker mode (FrankenPHP, RoadRunner) : kernel booté une fois, container partagé entre 1000s de requêtes. Cache hits ~free, mais memory leaks à surveiller.
APP_ENV=prod APP_DEBUG=0obligatoire. - Multiple kernels : config/packages/api/, config/packages/admin/ avec deux kernels distincts (
bin/api,bin/admin). Pour gros monorepo avec scopes très distincts. APP_ENV=prod APP_DEBUG=1— reproduire un bug prod localement avec profiler. Recompile container, profiler actif, mais avec config prod (Redis, real mailer, etc.).SYMFONY_DOTENV_PATH: surcharger le chemin de.env(utile pour tests d'intégration multi-tenants ou CI).
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 :
Dotenv::loadEnv()legacy,bootEnv()arrive. Cache adapter par défaut filesystem partout.composer dump-env prodgénère un.env.local.phpoptimisé. - 6.0 :
bootEnv()devient le standard..env.local.php(PHP statique) chargé en priorité s'il existe. Recommandation : ne jamais lire.enven prod, générer.env.local.phpau build. - 6.1 :
framework.handle_all_throwables: truepar défaut (depuis 6.0 en réalité, stabilisé 6.1). Erreurs PHP transformées en exceptions dans le kernel. - 6.4 LTS :
kernel.debugparamètre toujours exposé.cache:clearplus rapide grâce à la suppression atomique du dossier.--no-warmupflag pour clear sans warm. - 7.0 : suppression du fallback
loadEnv()→ uniquementbootEnv().APP_ENVdoit être défini avant le boot (sinon throw, pas de default silencieux). - 7.1+ : amélioration de la compilation incrémentale du container, certains changements YAML ne triggerent plus un full rebuild.
⚠️ Pitfalls
APP_ENV=testen dev local — désactive le chargement des.env.local→ tes credentials dev cassent. La convention est : tests CLI =bin/phpunitqui set automatiquementAPP_ENV=test.- Cache pas vidé en prod — déploiement qui change
services.yamlmais oubliecache:clear→ containers obsolètes, services manquants. Toujours automatiser dans le pipeline. - OPcache + nouveaux fichiers —
opcache.validate_timestamps=0en prod (recommandé pour perf) signifie que les nouveaux fichiers PHP ne sont pas vus jusqu'àopcache_resetou restart PHP-FPM. - Worker mode + container booté — si tu modifies un service en dev avec FrankenPHP worker, le changement n'est pas pris → reboot worker (
SIGUSR2ou restart). .env.local.phpobsolète — généré une fois aveccomposer dump-env prod, puis tu changes.env.prod→ fichier statique stale. Régénérer dans la CI/CD à chaque build.- Mélange
$_ENVetgetenv()— en worker mode,getenv()lit le snapshot au boot du worker, mais$_ENVpeut être muté par requêtes précédentes (bug). Toujours utiliser$_ENVou mieux : DI parameter. APP_DEBUG=trueau lieu de1— Symfony parse(bool), donc"true"=truemais"false"= aussitrue(string non vide). Toujours utiliser0/1.- Cache dir non-writable — Docker avec user UID mismatch, var/cache non writable → boot kernel throw.
RUN chown -R www-data:www-data var/dans le Dockerfile.
🧪 Testing
// tests/EnvironmentTest.php
<?php
namespace App\Tests;
use App\Kernel;
use PHPUnit\Framework\TestCase;
final class EnvironmentTest extends TestCase
{
public function testTestEnvIsActive(): void
{
self::assertSame('test', $_ENV['APP_ENV']);
}
public function testKernelBootsInTest(): void
{
$kernel = new Kernel('test', true);
$kernel->boot();
self::assertSame('test', $kernel->getEnvironment());
self::assertStringEndsWith('var/cache/test', $kernel->getCacheDir());
}
}Override env runtime pour tester un scénario prod :
// tests/ProdConfigTest.php
final class ProdConfigTest extends KernelTestCase
{
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected static function createKernel(array $options = []): KernelInterface
{
return new Kernel('prod', false);
}
public function testProdProfilerIsDisabled(): void
{
self::bootKernel();
self::assertFalse(static::getContainer()->has('profiler'));
}
}CLI debug :
php bin/console about
php bin/console debug:container --env=prod
php bin/console debug:config framework cache --env=prod
php bin/console debug:dotenv
APP_ENV=prod php bin/console cache:warmup --no-debug
composer dump-env prod🎬 Cas d'usage concrets
Scénario 1 — SaaS RH (Lucca-like) : pipeline dev → staging → preprod → prod
Contexte : SaaS RH avec ~120 devs. Le flow de release est strict : feature branch → review app éphémère (env preview) → staging (env staging, données dump anonymisé de prod) → preprod (env preprod, jumeau exact de prod) → prod (prod).
Chaque environnement a son config/packages/{env}/*.yaml :
dev: mailer SMTP local Mailpit, profiler on, doctrine logging ALL.staging: mailer Mailtrap, fixtures rejouées chaque nuit, Redis isolé, monitoring Sentry avec environnement tagué.preprod: config identique à prod (même DSN structure, même monitoring), mais base seedée avec données réelles anonymisées via outil interne.prod: APCu activé, Doctrine query cache Redis, OPcache preload Symfony, FrankenPHP en mode worker.
Discipline : un seul .env.{env}.local par environnement, versionné dans un repo séparé ops/secrets/ chiffré via SOPS+age. Les devs n'y ont pas accès en prod.
Scénario 2 — Cabinet comptable multi-tenant : env-per-tenant dérivé
Contexte : cabinet d'expertise comptable (Pennylane competitor interne) qui héberge 80 cabinets clients sur une infra unique. Chaque cabinet a une instance "logique" (sous-domaine + DB schéma), mais l'app Symfony tourne en un seul binaire.
L'astuce : un wrapper script bin/console-tenant <tenant_id> <command> exporte dynamiquement APP_ENV=prod + TENANT_ID=cabinet-mercier puis lance la console. Les config/packages/prod/doctrine.yaml utilise %env(string:TENANT_DB_DSN)% qui est résolu via un EnvVarProcessor custom lisant la table tenants centrale. Les commands de maintenance (doctrine:migrations:migrate, messenger:consume) tournent ainsi par tenant via boucle CI.
Spec critique : les containers cache (var/cache/prod) sont identiques pour tous les tenants — la différence vient des env vars résolues runtime. Un seul cache:warmup sert tous les clients. Économies prouvées : ~70% mémoire vs un container par tenant.
Scénario 3 — E-commerce (BackMarket-like) : env ci-integration pour tests bout-en-bout
Contexte : marketplace tech refurbished avec 200+ devs. Le pipeline CI lance pour chaque PR : (1) unit tests sous APP_ENV=test, (2) tests d'intégration sous APP_ENV=ci-integration avec vraie base Postgres temporaire, vrai Redis, vrai MinIO comme S3, mais des sandbox externes (Stripe test mode, Sendgrid sandbox).
config/packages/ci-integration/ réutilise majoritairement les configs prod (pour tester un comportement proche prod) mais : (1) doctrine.dbal.driver reste pdo_pgsql réel, (2) mailer redirige to: vers une boîte CI, (3) le messenger utilise transport in-memory pour ne pas bloquer le pipeline, (4) le rate limiter est désactivé sur l'API publique.
Bénéfice mesuré : taux de bugs en prod après merge divisé par 4 depuis l'introduction de ci-integration (mesure sur 9 mois).
🛠️ Exemple end-to-end
Use case : application B2B SaaS avec 4 environnements (dev, test, staging, prod). On veut illustrer une config par env qui (1) change le mailer transport, (2) active/désactive le rate limiter sur certaines routes API, (3) active le profiler uniquement en dev, (4) charge des fixtures différentes en staging.
// src/Kernel.php
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function getProjectDir(): string
{
return \dirname(__DIR__);
}
}// config/bundles.php
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true, 'staging' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];# config/packages/framework.yaml (base, tous envs)
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
php_errors:
log: true
rate_limiter:
api_anonymous:
policy: sliding_window
limit: '%env(int:RATE_LIMIT_ANON_PER_MIN)%'
interval: '1 minute'
# config/packages/dev/framework.yaml
framework:
profiler: { only_exceptions: false, collect: true }
web_profiler:
toolbar: true
# config/packages/prod/framework.yaml
framework:
profiler: { enabled: false }
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
# config/packages/staging/framework.yaml
framework:
profiler: { only_exceptions: true, collect: false }
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'# config/packages/dev/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN_DEV)%' # smtp://mailpit:1025
# config/packages/staging/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN_STAGING)%' # mailtrap
envelope:
recipients: ['[email protected]']
# config/packages/prod/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN_PROD)%' # sendgrid+api# .env
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=changeme
RATE_LIMIT_ANON_PER_MIN=60
# .env.staging
APP_ENV=staging
APP_DEBUG=0
RATE_LIMIT_ANON_PER_MIN=120
# .env.prod
APP_ENV=prod
APP_DEBUG=0
RATE_LIMIT_ANON_PER_MIN=600// src/Controller/Api/PublicSearchController.php
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
final class PublicSearchController extends AbstractController
{
public function __construct(
private readonly RateLimiterFactory $apiAnonymousLimiter,
) {}
#[Route('/api/search', name: 'api_public_search', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$limiter = $this->apiAnonymousLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
return new JsonResponse(['error' => 'rate_limit_exceeded'], 429);
}
return new JsonResponse(['hits' => []]);
}
}En dev, un dev peut spammer l'endpoint en boucle (limit 60/min, suffisant pour tests manuels). En prod, 600/min/IP : adapté au trafic réel. Une bascule en staging permet de tester un comportement intermédiaire avant la prod.
Discipline opérationnelle : bin/console cache:warmup --env=prod est lancé dans le Dockerfile final pour pré-construire le container ; le démarrage du pod prend < 200 ms. Aucun cache:clear ne tourne jamais en runtime prod — pour rollout, on déploie un nouveau container immutable.
🔁 Quand utiliser / éviter
dev: machine locale, profiler on, debug on, cache rebuild fluent. Évite pour benchmarking.test:bin/phpunit, CI. Mailer null, cache array (in-memory), session mock. Évite de tester perf ici.prod: déploiement. Cache pre-built, no profiler, OPcache à fond. ÉviteAPP_DEBUG=1sauf pour debug ciblé.- Environnement custom (
staging,preprod) : créeconfig/packages/staging/+.env.staging. Hérite deprodviakernel.environmentchecks, override ce qui change. - Worker mode (FrankenPHP) : prod recommandée pour perf x5-x10. Évite en dev sauf si tu testes le worker behavior (state isolation).
🪜 Approfondissement — cache warmup détaillé
Quand tu lances php bin/console cache:warmup --env=prod, les warmers (services taggés kernel.cache_warmer) s'exécutent en séquence :
ClassCacheCacheWarmer— précompile la liste des classes utilisées.ContainerCacheWarmer— compile le container DI (le plus long).RouterCacheWarmer— compile le matcher d'URLs + générateur.TwigCacheWarmer— précompile tous les templates Twig découvrables.SerializerCacheWarmer(6.x) — extrait metadata Serializer.ValidatorCacheWarmer— extrait metadata des constraints.DoctrineMetadataCacheWarmer— précompile les ORM mappings.ProfilerCacheWarmer(dev/test seulement).
Tu peux écrire ton propre warmer :
namespace App\CacheWarmer;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
final class RoutesJsonWarmer implements CacheWarmerInterface
{
public function __construct(private \Symfony\Component\Routing\RouterInterface $router) {}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$routes = [];
foreach ($this->router->getRouteCollection() as $name => $route) {
$routes[$name] = $route->getPath();
}
file_put_contents($cacheDir.'/routes.json', json_encode($routes));
return [];
}
public function isOptional(): bool { return true; }
}Tag implicit via autoconfigure: true.
🪜 Worker mode — implications profondes
En classique PHP-FPM, chaque requête = new Kernel() → boot() → handle() → terminate(). En worker mode (FrankenPHP, RoadRunner, Swoole) :
1. Worker process starts:
$kernel = new Kernel($env, $debug);
$kernel->boot(); // ONCE
while (true) {
$request = receive_next_request();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
$kernel->reset(); // ← critical: reset state for next request
}$kernel->reset() appelle reset() sur tous les services taggés kernel.reset. Doctrine EntityManager le fait pour clear l'identity map. Tes services aussi doivent le faire s'ils accumulent du state :
namespace App\Service;
use Symfony\Contracts\Service\ResetInterface;
final class RequestScopedCache implements ResetInterface
{
private array $cache = [];
public function reset(): void
{
$this->cache = [];
}
}Services taggés automatiquement via _instanceof. Sans reset(), leak garanti.
🪜 Multiple kernels — pattern monorepo
Pour gros projets (/api, /admin, /worker avec configs différentes) :
// bin/admin
<?php
use App\AdminKernel;
require dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new AdminKernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// src/AdminKernel.php
final class AdminKernel extends BaseKernel
{
public function getProjectDir(): string { return dirname(__DIR__); }
public function getCacheDir(): string { return $this->getProjectDir().'/var/cache/admin/'.$this->environment; }
public function registerBundles(): iterable
{
// Subset de bundles spécifiques admin
}
protected function configureContainer(ContainerConfigurator $c): void
{
$c->import('../config/admin/{packages}/*.yaml');
}
}Avantage : optimisation par scope, cache séparé, déploiement indépendant possible. Inconvénient : 2× les warmups CI.
🪜 OPcache + preloading
Pour la prod, OPcache est obligatoire :
; php.ini (prod)
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; ← clé prod
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-dataSymfony auto-génère preload.php au cache:warmup (var/cache/prod/App_KernelProdContainer.preload.php). Linke-le :
ln -s var/cache/prod/App_KernelProdContainer.preload.php config/preload.phpGain : +30-50% throughput. Mais : restart PHP-FPM obligatoire pour preload les nouveaux fichiers.
Note 7.x — depuis Symfony 6.3, le container peut être splitté en
buildDir(artefacts compilés, immuables, baked dans l'image) etcacheDir(runtime mutable : Twig, etc.). OverridegetBuildDir()pour pointer vers un répertoire read-only monté en lecture seule — ça permet un FS prod enreadOnlyRootFilesystem: true(durcissement Kubernetes) tout en gardantvar/cachesur unemptyDirwritable. C'est le pattern recommandé pour les images immuables.
📊 Tableau de décision — quel mécanisme pour quel besoin
| Besoin | Mécanisme | Pourquoi pas l'alternative |
|---|---|---|
| Valeur qui change par env (DSN, limites) | %env(...)% + .env.{env} | Pas un fichier YAML par env : runtime > build-time, 1 seul container compilé |
| Comportement structurel qui change (handler Monolog, profiler) | config/packages/{env}/*.yaml | Une env var ne peut pas activer/désactiver un service ou changer un type de handler |
| Secret (prod) | secrets:set (vault chiffré) ou .env.prod.local hors VCS | .env.prod est committé → jamais de secret dedans |
| Valeur figée au build, jamais relue à runtime | composer dump-env prod → .env.local.php | Lire 4 fichiers .env à chaque boot = I/O inutile en prod |
| Override ponctuel sans toucher au code | env var d'OS (APP_ENV=prod php ...) | L'OS env gagne toujours sur les fichiers .env |
Règle mentale : env var = donnée runtime, fichier {env}/*.yaml = structure build-time, secret = vault. Mélanger les trois est la source n°1 de "ça marche en dev pas en prod".
🔐 Secrets — au-delà de .env.prod.local
.env.prod.local non versionné fonctionne mais ne scale pas (rotation, audit, multi-machine). Symfony a un vault natif :
# Génère une paire de clés (decrypt.private.key reste hors VCS / injectée au déploiement)
php bin/console secrets:generate-keys --env=prod
# Ajoute un secret (chiffré, committable)
php bin/console secrets:set DATABASE_URL --env=prod
php bin/console secrets:set STRIPE_API_KEY --env=prod
# Liste (valeurs masquées sans --reveal)
php bin/console secrets:list --env=prodLes secrets chiffrés vivent dans config/secrets/prod/ (committables). Seule decrypt.private.key est sensible : injectée via le secret manager de la plateforme (Vault, AWS Secrets Manager, K8s Secret monté en fichier). À runtime, %env(DATABASE_URL)% résout d'abord la vraie env var, puis le vault — donc en local tu surcharges sans déchiffrer.
Mental model staff : le vault Symfony résout le problème "chiffré et committable" mais ne remplace pas un secret manager pour la rotation à chaud — il faut redéployer pour propager un secret changé. Pour de la rotation sans redeploy, garde le DSN dans un vault externe lu via %env(...)% au boot du worker.
🏋️ Exercices
Pré-requis : un projet Symfony 7.x (
symfony new env-lab --webapp). Fais chaque exercice dans une branche dédiée.
Exercice 1 — Tracer la cascade dotenv (implement)
Objectif : prouver empiriquement l'ordre de précédence des 4 fichiers .env.
Crée .env, .env.local, .env.dev, .env.dev.local définissant tous une même variable LADDER à une valeur différente. Écris une commande console app:show-ladder qui dump $_ENV['LADDER']. Lance-la avec APP_ENV=dev, puis avec APP_ENV=test, et explique l'écart.
Indice/Solution : avec dev, .env.dev.local gagne. Avec test, les fichiers .local sont skippés (sauf si SYMFONY_DOTENV_PATH / test explicite) → c'est .env.test puis .env qui décident. Vérifie avec php bin/console debug:dotenv qui affiche la provenance ligne par ligne.
Exercice 2 — Env custom staging héritant de prod (implement)
Objectif : créer un environnement staging qui clone la config prod sauf le mailer et le profiler.
Crée config/packages/staging/ qui importe les YAML de prod via when@staging ou import explicite, puis override mailer.dsn (vers Mailtrap) et garde profiler.only_exceptions: true. Ajoute .env.staging. Vérifie avec php bin/console debug:config framework profiler --env=staging.
Indice/Solution : préfère when@staging: dans un YAML partagé plutôt que dupliquer tout prod/. Le piège : config/packages/staging/ n'hérite pas automatiquement de prod/ — Symfony ne charge QUE {staging}/. Utilise un import explicite ou la syntaxe when@.
Exercice 3 — Cache pre-build immuable + boot < 100 ms (production-grade)
Objectif : produire une image Docker où le container est entièrement compilé au build et var/cache est read-only.
Dans le Dockerfile : composer dump-env prod, cache:clear --env=prod --no-debug, cache:warmup --env=prod. Override getBuildDir() pour pointer vers un dossier baked. Lance le conteneur avec un FS root read-only et un emptyDir writable uniquement sur /app/var/log. Mesure le temps du premier handle().
Indice/Solution : sépare buildDir (immuable, dans l'image) de cacheDir (writable, monté). Vérifie qu'aucun cache:clear ne tourne au runtime. Si le boot échoue en read-only, c'est qu'un warmer écrit dans cacheDir au premier hit — identifie-le via strace -e trace=openat ou en rendant var/cache writable et en diffant les fichiers créés.
Exercice 4 — EnvVarProcessor custom multi-tenant (production-grade)
Objectif : résoudre %env(tenant_dsn:TENANT_ID)% en lisant une table tenants centrale, container partagé.
Implémente un EnvVarProcessorInterface qui, pour le préfixe tenant_dsn, lit TENANT_ID dans l'env et retourne le DSN du tenant. Le container compilé reste identique pour tous les tenants ; seule l'env var TENANT_ID change runtime.
Indice/Solution : getProvidedTypes(): ['tenant_dsn' => 'string']. Attention : l'EnvVarProcessor est appelé à chaque résolution mais le résultat est mis en cache par requête — en worker mode, assure-toi que le cache de résolution est reset entre requêtes (sinon le tenant N+1 voit le DSN du tenant N). C'est exactement le piège du scénario 2.
Exercice 5 — Break-then-fix : le leak worker mode (break → fix)
Objectif : reproduire puis corriger une fuite d'état en mode worker.
Crée un service RequestCounter qui accumule des entrées dans un array $seen sans implémenter ResetInterface. Lance l'app sous FrankenPHP worker (frankenphp run ou php -d ... worker). Envoie 10 000 requêtes (hey -n 10000). Observe la mémoire grimper monotone. Puis implémente ResetInterface::reset() et reprouve que la mémoire se stabilise.
Indice/Solution : le leak vient de l'absence de reset() → $seen n'est jamais vidé entre requêtes. Le fix : implements ResetInterface + tag auto via _instanceof. Vérifie avec kernel.reset taggé : php bin/console debug:container --tag=kernel.reset. Bonus : un static ou un EntityManager non-clearé fuit aussi — $kernel->reset() clear l'EM mais pas tes statics.
Exercice 6 — Break-then-fix : OPcache + validate_timestamps=0 (break → fix)
Objectif : reproduire le bug "nouveau fichier PHP invisible en prod" et le corriger proprement.
Mets opcache.validate_timestamps=0. Déploie une nouvelle classe de service + un services.yaml qui la référence, sans vider le cache container ni OPcache. Observe le ServiceNotFoundException ou l'ancien comportement. Corrige via la séquence de déploiement correcte.
Indice/Solution : deux caches stale distincts — (1) le container compilé (cache:warmup au build), (2) OPcache (les .php du nouveau code). Fix : déployer une nouvelle image immuable (le pod neuf a OPcache vierge + container frais) plutôt que de patcher en place. Si tu dois patcher en place : cache:clear + opcache_reset() (ou restart PHP-FPM). Documente pourquoi le rolling deploy d'images immuables élimine la classe entière de bugs.
🎤 En entretien
« Quelle est la différence entre
APP_ENVetAPP_DEBUG, et peut-on les combiner librement ? » Ils sont orthogonaux :APP_ENVchoisit quelle config charger (config/packages/{env}/, quels.env.{env}lire, le dossiervar/cache/{env}),APP_DEBUGchoisit si le container est recompilé à chaque requête sur changement + active la collecte d'erreurs détaillées.APP_ENV=prod APP_DEBUG=1est valide et utile pour reproduire un bug prod avec le profiler. Piège connu :APP_DEBUG="false"est truthy (string non vide) → toujours0/1.« En worker mode (FrankenPHP), qu'est-ce qui change fondamentalement et quel est le risque n°1 ? » Le kernel est booté une fois et partagé entre des milliers de requêtes (cache hits ~gratuits, throughput x5-x10). Risque n°1 : la fuite d'état entre requêtes — tout service accumulant du state doit implémenter
ResetInterface, et$kernel->reset()est appelé après chaque requête. Les statics et l'identity map Doctrine sont les coupables classiques. Secondaire :$_ENVmuté par une requête précédente vsgetenv()figé au boot.« Comment garantis-tu un boot prod sub-100ms et un FS immuable ? » Container compilé au build (
cache:warmup --env=proddans le Dockerfile) +composer dump-env prodpour ne pas relire 4 fichiers.envau boot + OPcachevalidate_timestamps=0+ preloading. SéparerbuildDir(immuable, baked) decacheDir(writable,emptyDir) permetreadOnlyRootFilesystem. Aucuncache:clearau runtime : on déploie une nouvelle image immuable, ce qui invalide aussi OPcache proprement (pod neuf).« Où mets-tu un secret de prod, et pourquoi pas dans
config/packages/prod/ou.env.prod? » Ni l'un ni l'autre :config/packages/prod/et.env.prodsont committés. Un secret va soit dans.env.prod.local(hors VCS, simple mais pas de rotation/audit), soit dans le vault Symfony (secrets:set, chiffré et committable, seuledecrypt.private.keyest injectée par la plateforme), soit dans un secret manager externe lu via%env(...)%(nécessaire pour la rotation à chaud sans redeploy).