Skip to content

Environments — APP_ENV, dotenv cascade, cache warmup

TL;DRAPP_ENV détermine 3 choses : (1) quels config/packages/{env}/*.yaml charger, (2) quel .env.{env}.local lire, (3) le dossier de cache var/cache/{env}. APP_DEBUG est orthogonal : tu peux avoir APP_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

bash
# .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
bash
# .env.dev (committed — defaults pour dev)
APP_DEBUG=1
MAILER_DSN=smtp://localhost:1025  # Mailhog
bash
# .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
bash
# .env.local (gitignored)
DATABASE_URL=postgresql://achref:[email protected]:5432/app_dev
STRIPE_API_KEY=sk_test_localdev
bash
# .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
yaml
# 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
yaml
# 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
yaml
# config/packages/test/framework.yaml
framework:
    test: true
    session:
        storage_factory_id: session.storage.factory.mock_file
    cache:
        app: cache.adapter.array
php
// 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

  1. Cache warmup CI/CD : php bin/console cache:clear --env=prod --no-debug puis php bin/console cache:warmup --env=prod --no-debug pendant le build, copier var/cache/prod dans l'artefact de déploiement → boot prod instantané.
  2. 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.
  3. 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=0 obligatoire.
  4. Multiple kernels : config/packages/api/, config/packages/admin/ avec deux kernels distincts (bin/api, bin/admin). Pour gros monorepo avec scopes très distincts.
  5. 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.).
  6. 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 prod génère un .env.local.php optimisé.
  • 6.0 : bootEnv() devient le standard. .env.local.php (PHP statique) chargé en priorité s'il existe. Recommandation : ne jamais lire .env en prod, générer .env.local.php au build.
  • 6.1 : framework.handle_all_throwables: true par défaut (depuis 6.0 en réalité, stabilisé 6.1). Erreurs PHP transformées en exceptions dans le kernel.
  • 6.4 LTS : kernel.debug paramètre toujours exposé. cache:clear plus rapide grâce à la suppression atomique du dossier. --no-warmup flag pour clear sans warm.
  • 7.0 : suppression du fallback loadEnv() → uniquement bootEnv(). APP_ENV doit ê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

  1. APP_ENV=test en dev local — désactive le chargement des .env.local → tes credentials dev cassent. La convention est : tests CLI = bin/phpunit qui set automatiquement APP_ENV=test.
  2. Cache pas vidé en prod — déploiement qui change services.yaml mais oublie cache:clear → containers obsolètes, services manquants. Toujours automatiser dans le pipeline.
  3. OPcache + nouveaux fichiersopcache.validate_timestamps=0 en prod (recommandé pour perf) signifie que les nouveaux fichiers PHP ne sont pas vus jusqu'à opcache_reset ou restart PHP-FPM.
  4. Worker mode + container booté — si tu modifies un service en dev avec FrankenPHP worker, le changement n'est pas pris → reboot worker (SIGUSR2 ou restart).
  5. .env.local.php obsolète — généré une fois avec composer dump-env prod, puis tu changes .env.prod → fichier statique stale. Régénérer dans la CI/CD à chaque build.
  6. Mélange $_ENV et getenv() — en worker mode, getenv() lit le snapshot au boot du worker, mais $_ENV peut être muté par requêtes précédentes (bug). Toujours utiliser $_ENV ou mieux : DI parameter.
  7. APP_DEBUG=true au lieu de 1 — Symfony parse (bool), donc "true" = true mais "false" = aussi true (string non vide). Toujours utiliser 0/1.
  8. 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

php
// 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 :

php
// 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 :

bash
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.

php
// 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__);
    }
}
php
// 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],
];
yaml
# 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)%'
yaml
# 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
ini
# .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
php
// 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. Évite APP_DEBUG=1 sauf pour debug ciblé.
  • Environnement custom (staging, preprod) : crée config/packages/staging/ + .env.staging. Hérite de prod via kernel.environment checks, 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 :

  1. ClassCacheCacheWarmer — précompile la liste des classes utilisées.
  2. ContainerCacheWarmer — compile le container DI (le plus long).
  3. RouterCacheWarmer — compile le matcher d'URLs + générateur.
  4. TwigCacheWarmer — précompile tous les templates Twig découvrables.
  5. SerializerCacheWarmer (6.x) — extrait metadata Serializer.
  6. ValidatorCacheWarmer — extrait metadata des constraints.
  7. DoctrineMetadataCacheWarmer — précompile les ORM mappings.
  8. ProfilerCacheWarmer (dev/test seulement).

Tu peux écrire ton propre warmer :

php
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 :

php
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) :

php
// 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']);
};
php
// 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 :

ini
; 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-data

Symfony auto-génère preload.php au cache:warmup (var/cache/prod/App_KernelProdContainer.preload.php). Linke-le :

bash
ln -s var/cache/prod/App_KernelProdContainer.preload.php config/preload.php

Gain : +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) et cacheDir (runtime mutable : Twig, etc.). Override getBuildDir() pour pointer vers un répertoire read-only monté en lecture seule — ça permet un FS prod en readOnlyRootFilesystem: true (durcissement Kubernetes) tout en gardant var/cache sur un emptyDir writable. C'est le pattern recommandé pour les images immuables.

📊 Tableau de décision — quel mécanisme pour quel besoin

BesoinMécanismePourquoi 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}/*.yamlUne 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 à runtimecomposer dump-env prod.env.local.phpLire 4 fichiers .env à chaque boot = I/O inutile en prod
Override ponctuel sans toucher au codeenv 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 :

bash
# 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=prod

Les 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_ENV et APP_DEBUG, et peut-on les combiner librement ? » Ils sont orthogonaux : APP_ENV choisit quelle config charger (config/packages/{env}/, quels .env.{env} lire, le dossier var/cache/{env}), APP_DEBUG choisit si le container est recompilé à chaque requête sur changement + active la collecte d'erreurs détaillées. APP_ENV=prod APP_DEBUG=1 est valide et utile pour reproduire un bug prod avec le profiler. Piège connu : APP_DEBUG="false" est truthy (string non vide) → toujours 0/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 : $_ENV muté par une requête précédente vs getenv() figé au boot.

  • « Comment garantis-tu un boot prod sub-100ms et un FS immuable ? » Container compilé au build (cache:warmup --env=prod dans le Dockerfile) + composer dump-env prod pour ne pas relire 4 fichiers .env au boot + OPcache validate_timestamps=0 + preloading. Séparer buildDir (immuable, baked) de cacheDir (writable, emptyDir) permet readOnlyRootFilesystem. Aucun cache:clear au 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.prod sont 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, seule decrypt.private.key est injectée par la plateforme), soit dans un secret manager externe lu via %env(...)% (nécessaire pour la rotation à chaud sans redeploy).

🔗 Liens

Bibliothèque tech perso — Achref