Console Commands — CLI, signals, scheduling
TL;DR — Le composant Console transforme PHP en CLI propre :
Commandclass, arguments/options typés, prompts interactifs, progress bars, signals POSIX (SIGTERM,SIGINT) pour shutdown gracieux,Lockcomponent pour interdire l'exécution concurrente,Scheduler(6.3+) pour le cron in-app via Messenger. Règle d'or : un controller ne lance jamais une commande, il dispatche un message.
🧠 Mental model
bin/console app:user:import users.csv --batch=500 --dry-run -vv
│ │ │ │ │ │
│ │ argument option flag verbosity
│ │
│ command name (namespace : action)
binary entry point
Command lifecycle:
┌──────────────────────────────────────────────────────────────┐
│ __construct → DI injected │
│ configure() → declare args/options/help │
│ initialize() → resolve services, parse env │
│ interact() → prompt user if missing required args │
│ execute() → real work, returns exit code (0..255) │
└──────────────────────────────────────────────────────────────┘
│
Signals ─────────┼────► SignalableCommandInterface
(SIGTERM/SIGINT) │ handleSignal() → flush, release lock, exit cleanly
│
Lock ─────────┘ ──► LockFactory → acquire blocking/non-blocking
Scheduler (6.3+):
┌──────────────┐ every 5min ┌──────────────────┐ dispatch ┌──────────┐
│ Schedule │ ─────────────▶│ RecurringMessage │ ──────────▶│ Bus │
│ Provider │ │ (cron/period) │ │ + Handler│
└──────────────┘ └──────────────────┘ └──────────┘
│
messenger:consume scheduler_defaultAnalogie : une console command, c'est un point d'entrée alternatif à HTTP. Même container, même services, juste un autre Input → Output. Pense au php-fpm vs bin/console comme à deux portes vers la même maison.
🛠️ Code minimal
// src/Command/ImportUsersCommand.php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
#[AsCommand(
name: 'app:user:import',
description: 'Import users from a CSV file',
)]
final class ImportUsersCommand extends Command implements SignalableCommandInterface
{
private bool $shouldStop = false;
private ?\Symfony\Component\Lock\LockInterface $lock = null;
public function __construct(
private readonly LockFactory $lockFactory,
private readonly UserImporter $importer,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'CSV path')
->addOption('batch', 'b', InputOption::VALUE_REQUIRED, 'Batch size', 500)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not persist')
->setHelp(<<<HELP
Imports users from CSV. Use --dry-run to validate without writing.
Example: <info>bin/console app:user:import users.csv --batch=1000</info>
HELP);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
if (!$input->getArgument('file')) {
$io = new SymfonyStyle($input, $output);
$input->setArgument('file', $io->ask('CSV path?'));
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$file = (string) $input->getArgument('file');
$batch = (int) $input->getOption('batch');
$dryRun = (bool) $input->getOption('dry-run');
$this->lock = $this->lockFactory->createLock('user:import', 3600);
if (!$this->lock->acquire(false)) {
$io->error('Another import is already running.');
return Command::FAILURE;
}
try {
$total = $this->importer->count($file);
$io->title(sprintf('Importing %d users (batch=%d, dry-run=%s)',
$total, $batch, $dryRun ? 'yes' : 'no'));
$progress = $io->createProgressBar($total);
$progress->setFormat('debug');
$progress->start();
foreach ($this->importer->stream($file, $batch) as $chunk) {
if ($this->shouldStop) {
$io->warning('Graceful stop requested. Flushing…');
break;
}
$this->importer->process($chunk, $dryRun);
$progress->advance(\count($chunk));
}
$progress->finish();
$io->newLine(2);
$io->success('Done.');
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error($e->getMessage());
return Command::FAILURE;
} finally {
$this->lock?->release();
}
}
public function getSubscribedSignals(): array
{
return [\SIGINT, \SIGTERM];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->shouldStop = true;
return false; // continue, let execute() return naturally
}
}bin/console app:user:import users.csv --batch=1000 --dry-run -vv
bin/console list app
bin/console app:user:import --help🎯 Patterns courants
- Lock pour cron — toute commande lancée par cron doit acquérir un lock non-bloquant. Sinon, un job qui dépasse son intervalle se retrouve en N copies parallèles.
SymfonyStylepartout — uniformité de l'output, supporte non-interactive (CI).$io->ask(),$io->confirm(),$io->choice(),$io->table().- Idempotence + checkpoint — pour les imports longs, persister un curseur (
last_processed_id) et reprendre. Le SIGTERM ne doit jamais détruire de progrès. - Commande = wrapper d'un service — la commande parse l'input, délègue à un service métier testable. La commande elle-même reste fine.
- Scheduler 6.3+ — au lieu de crontab système, déclare un
ScheduleProviderInterfacequi produit desRecurringMessage. Le worker Messenger les dispatche au bon moment. - Verbosity-aware logs —
if ($output->isVerbose()) ..., ou utiliseConsoleLoggerqui mappe les niveaux PSR-3 aux flags-v / -vv / -vvv.
📅 Scheduler 6.3+ — le cron in-app
Le Scheduler remplace la crontab système par un déclencheur géré dans le worker Messenger. Avantage : versionné dans le code, testable, observable dans le profiler, pas de drift entre crontab -l et le repo. Inconvénient : il faut un worker qui tourne en permanence (messenger:consume scheduler_default), donc un superviseur (Supervisor/systemd/K8s Deployment) — ce n'est pas du fire-and-forget.
// src/Scheduler/StockSyncScheduleProvider.php
namespace App\Scheduler;
use App\Message\SyncStockBatch;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule('default')]
final class StockSyncScheduleProvider implements ScheduleProviderInterface
{
public function __construct(
private readonly CacheInterface $cache,
private readonly LockFactory $lockFactory,
) {}
public function getSchedule(): Schedule
{
return (new Schedule())
// expression cron classique, avec fuseau explicite
->add(
RecurringMessage::cron('0 23 * * *', new SyncStockBatch())
->withTimezone('Europe/Paris'),
)
// période relative, plus lisible
->add(RecurringMessage::every('15 minutes', new HealthPing()))
// dédup au boot : si le worker redémarre, on ne rejoue pas les jobs ratés
->stateful($this->cache)
// un seul worker exécute, même en cas de scale-out horizontal
->lock($this->lockFactory->createLock('scheduler-default'));
}
}# le worker qui « est » le cron — à superviser, à redémarrer, à monitorer
bin/console messenger:consume scheduler_default -vv --time-limit=3600
# inspecter le planning sans rien exécuter
bin/console debug:schedulerComment un staff engineer raisonne : le Scheduler est at-most-once par déclenchement uniquement si ->stateful() + ->lock() sont posés. Sans lock(), sur 3 répliques du Deployment, 3 workers déclenchent le même job → triple exécution. Sans stateful(), un worker qui crashe et redémarre à 23h05 rejoue le job de 23h00 (catch-up) — parfois voulu, souvent un bug. Le choix catch-up vs skip est une décision produit, pas un détail technique.
| Crontab système | Symfony Scheduler | |
|---|---|---|
| Versionné dans le repo | Non (drift garanti) | Oui |
| Déclenchement | Process neuf à chaque run | Worker long-running |
| Anti-concurrence | À ta charge (flock) | ->lock() natif |
| Catch-up après downtime | Non | ->stateful() |
| Observabilité | Logs cron éparpillés | Profiler + Messenger |
| Coût d'un job < 1s, rare | Idéal (rien ne tourne) | Surcoût worker permanent |
| Granularité | 1 minute | Sub-minute possible |
Règle : crontab pour les jobs rares et isolés (un dump nocturne), Scheduler quand tu as déjà un worker Messenger et veux du cron versionné/observable.
🔭 Observabilité & production
Une commande qui tourne 4h en prod sans télémétrie est une boîte noire. Le minimum vital d'un staff engineer :
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$start = hrtime(true);
// 1. Logger structuré avec contexte stable (corrélation)
$runId = bin2hex(random_bytes(8));
$log = $this->logger->withContext(['command' => 'app:stock:sync', 'run_id' => $runId]);
// 2. Sortie machine-readable si demandé (pipelines CI/observabilité)
$json = $output->isVeryVerbose() === false && $input->getOption('format') === 'json';
// ... travail ...
// 3. Métriques : durée, volume, taux d'échec → StatsD/Prometheus pushgateway
$durationMs = (hrtime(true) - $start) / 1e6;
$this->metrics->timing('command.duration', $durationMs, ['command' => 'sync']);
$this->metrics->gauge('command.last_success_ts', time());
return Command::SUCCESS;
}Les quatre signaux à exposer pour toute commande planifiée (les « golden signals » version batch) :
| Signal | Mesure | Alerte si |
|---|---|---|
| Liveness | timestamp du dernier succès | pas de succès depuis > 2× la période |
| Durée | wall-clock du run | p95 dépasse la fenêtre allouée |
| Throughput | items/seconde | chute brutale (fournisseur lent) |
| Taux d'échec | échecs / total | > seuil métier (ex. 5 %) |
ConsoleLogger mappe PSR-3 → verbosity : error/warning toujours, notice/info à -v, debug à -vvv. En prod planifiée, on log en JSON vers stdout (collecté par le sidecar/agent) et on émet des métriques — les deux, jamais l'un sans l'autre. Le --time-limit et --memory-limit sur les workers ne sont pas optionnels : ils transforment une fuite mémoire lente en redémarrage propre plutôt qu'en OOM-kill.
🔄 Versions
- 5.4 :
#[AsCommand](5.3).SignalableCommandInterfaceintroduite 5.2.LazyCommandpour autoload paresseux.Command::SUCCESS|FAILURE|INVALIDconstants. - 6.3 : Scheduler component introduit (
symfony/scheduler) — cron in-process via Messenger.RecurringMessage::every('5 minutes', $msg),RecurringMessage::cron('0 * * * *', $msg). - 6.4 (LTS) : Scheduler stabilisé.
#[AsSchedule]attribut. Profiler intègre les schedules.RecurringMessage::trigger()pour des triggers custom (JitterTrigger, etc.). - 7.x :
SignalableCommandInterface::handleSignal()signature change (retourneint|falseau lieu devoid, déjà le cas depuis 6.4). PHP 8.2+ requis.#[AsCommand]supportealiases+hidden. 7.3 : commandes invokables via__invoke()avec arguments/options en paramètres typés (#[Argument],#[Option]).
⚠️ Pitfalls
- Lancer une commande depuis un controller —
new ApplicationTester(...)ouexec('bin/console ...')= très BAD. Bloque le worker HTTP, double-init du kernel, exit codes perdus. OK : dispatcher un Messenger message qui fait la même chose en async. - Pas de gestion SIGTERM — Kubernetes/Supervisor envoie SIGTERM puis SIGKILL après 30s. Sans
SignalableCommandInterface, ton job long est tué brutalement au milieu d'une transaction. - Lock TTL trop court — TTL 60s sur un job qui prend 5min → un second job acquiert le lock pendant que le premier tourne. Toujours TTL > durée pire cas + marge, ou utiliser un lock auto-refresh.
- Doctrine connection morte — commande tourne 2h, MySQL idle timeout 8h → ça va, mais avec
ProcessIsolation, fork qui hérite de la connexion → corruption. Toujours$em->clear()régulièrement et reconnect. - Output non bufferisé —
dump()dans une commande spam la sortie. Utilise--no-debugen prod et respecte le verbosity. - Tests d'I/O réels — une commande qui écrit dans
/var/logou appelle une API en test = mauvaise idée. Injecte les services. - Mémoire qui enfle — boucle qui charge 1M users avec
findAll(). Toujoursiterate()Doctrine +$em->clear()toutes les N entités. - Crontab vs Scheduler — mixer les deux duplique les jobs. Choisir un système et s'y tenir.
🧪 Testing
CommandTester boote la commande sans process externe : rapide, déterministe, idéal pour les assertions sur l'output et l'exit code. ApplicationTester teste l'application entière (résolution du nom, options globales --env). Pour un vrai SIGTERM end-to-end, il faut un sous-process (symfony/process) — réservé aux tests d'intégration lents.
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class ImportUsersCommandTest extends KernelTestCase
{
public function testItImportsUsers(): void
{
self::bootKernel();
$app = new Application(self::$kernel);
$cmd = $app->find('app:user:import');
$tester = new CommandTester($cmd);
$tester->execute([
'file' => __DIR__ . '/fixtures/users.csv',
'--dry-run' => true,
'--batch' => 100,
]);
$tester->assertCommandIsSuccessful();
$this->assertStringContainsString('Done', $tester->getDisplay());
}
public function testItPromptsWhenFileMissing(): void
{
self::bootKernel();
$cmd = (new Application(self::$kernel))->find('app:user:import');
$tester = new CommandTester($cmd);
// setInputs() simule le STDIN ; interact() est appelé car le tester est interactif par défaut
$tester->setInputs([__DIR__ . '/fixtures/users.csv']);
$tester->execute([]); // pas de 'file' → interact() le réclame
$tester->assertCommandIsSuccessful();
}
}Piège classique : si tu passes
['interactive' => false]en 2ᵉ argument deexecute(),interact()est sauté et le test de prompt échoue silencieusement. À l'inverse, en CI tu veux souvent forcerinteractive: falsepour garantir qu'aucune commande ne se bloque sur un prompt oublié.
Test du signal handling : difficile en unit (pas de vrai SIGTERM), mais on peut appeler handleSignal() directement et asserter que le state interne (shouldStop) bascule.
Test du Lock : utilise Symfony\Component\Lock\Store\InMemoryStore ou FlockStore sur tmp.
$store = new InMemoryStore();
$factory = new LockFactory($store);
$lock1 = $factory->createLock('x');
$this->assertTrue($lock1->acquire());
$lock2 = $factory->createLock('x');
$this->assertFalse($lock2->acquire(false));🎬 Cas d'usage concrets
Scénario 1 — Commande batch d'export comptable FEC pour SaaS comptable
Le SaaS comptable doit produire, à la demande de chaque client, un fichier FEC (Fichier des Écritures Comptables) conforme à l'art. A. 47 A-1 du LPF lorsqu'un contrôle fiscal est annoncé. Le format impose un strict respect des règles d'horodatage, d'encodage UTF-8 BOM, de séparateur pipe, et de tri chronologique sur 12 mois ou plus. La commande app:fec:export accepte les arguments --tenant, --exercice et écrit dans un répertoire S3 chiffré côté serveur via SSE-KMS. Elle utilise LockableTrait pour empêcher deux exports concurrents sur le même tenant, un ProgressBar pour suivre la progression sur les jeux jusqu'à 4 millions d'écritures, et une stratégie de batch flush avec EntityManager::clear() toutes les 1 000 lignes pour rester sous 256 Mo de RAM. La commande est invoquée par un opérateur expert-comptable depuis le backoffice ou par un job Kubernetes CronJob nocturne pour les pré-générations automatiques. La sortie est signée HMAC pour détecter toute manipulation après export.
Scénario 2 — Commande de génération de rapports pour cabinet juridique
Le cabinet édite chaque trimestre des rapports d'activité par associé : nombre de dossiers ouverts, taux de facturation, temps passés non facturés, ratio dossiers gagnés/perdus en contentieux. La commande app:rapport:trimestriel orchestre la génération de 80 rapports PDF (un par associé/équipe) avec parallélisme contrôlé : elle dispatch sur Messenger un message par associé puis attend la complétion via un Symfony Lock partagé avec timeout 30 minutes. Le ProgressBar est alimenté par un compteur Redis incrémenté par chaque handler. Les PDF sont générés via wkhtmltopdf wrappé dans Process avec timeout 60 s, signés numériquement et déposés sur S3. Un mode --dry-run ne génère que les statistiques sans produire les PDF, utile pour valider les calculs avant publication. La commande retourne un code d'exit explicite (SUCCESS, FAILURE, ou INVALID selon contexte) interprété par le scheduler Argo Workflows.
Scénario 3 — Synchronisation nocturne de stocks e-commerce avec fournisseurs
La marketplace de bricolage synchronise chaque nuit les stocks de ses 1 800 fournisseurs via API REST ou flux CSV/EDI. La commande app:stock:sync-nocturne orchestre 1 800 syncs avec parallélisme limité à 20 (via worker pool maison utilisant Process non-bloquant) pour ne pas saturer la base ni les fournisseurs partenaires. Chaque sync est isolé : un crash sur un fournisseur ne plombe pas le reste, l'erreur est loguée et le fournisseur est rejoué en fin de batch. La commande accepte --depuis=2026-05-23T22:00:00 pour ne traiter que les deltas depuis le dernier run réussi, persisté dans une table sync_state. Un signal SIGTERM (envoyé par Kubernetes à la fin de la fenêtre nocturne) déclenche un arrêt gracieux qui termine les syncs en cours, marque les autres comme pending et exit code 130. Le déclencheur est un Kubernetes CronJob (0 23 * * *) supervisé par un alerting PagerDuty si la commande n'a pas terminé en 4 heures.
🛠️ Exemple end-to-end
Use case : commande nocturne de sync stocks fournisseurs avec lock, signal handler pour arrêt gracieux, ProgressBar et batch.
<?php
// src/Infrastructure/Console/SyncStockNocturneCommand.php
declare(strict_types=1);
namespace App\Infrastructure\Console;
use App\Application\Stock\StockSyncService;
use App\Domain\Fournisseur\Repository\FournisseurRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use Psr\Log\LoggerInterface;
#[AsCommand(
name: 'app:stock:sync-nocturne',
description: 'Synchronise les stocks fournisseurs depuis leur dernière mise à jour',
)]
final class SyncStockNocturneCommand extends Command implements SignalableCommandInterface
{
use LockableTrait;
private bool $shouldStop = false;
public function __construct(
private readonly FournisseurRepository $fournisseurs,
private readonly StockSyncService $sync,
private readonly LockFactory $lockFactory,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('depuis', null, InputOption::VALUE_REQUIRED, 'Date ISO 8601 du delta')
->addOption('parallel', null, InputOption::VALUE_REQUIRED, 'Workers parallèles', 20);
}
public function getSubscribedSignals(): array
{
return [\SIGTERM, \SIGINT];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->logger->warning('Signal reçu, arrêt gracieux', ['signal' => $signal]);
$this->shouldStop = true;
return false; // continuer pour cleanup
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$this->lock('stock:sync', blocking: false)) {
$output->writeln('<comment>Sync déjà en cours, abandon</comment>');
return Command::INVALID;
}
$io = new SymfonyStyle($input, $output);
$depuis = $input->getOption('depuis')
? new \DateTimeImmutable($input->getOption('depuis'))
: new \DateTimeImmutable('-24 hours');
$fournisseurs = iterator_to_array($this->fournisseurs->actifs());
$progress = new ProgressBar($output, count($fournisseurs));
$progress->setFormat('verbose');
$progress->start();
$echecs = [];
foreach ($fournisseurs as $f) {
if ($this->shouldStop) {
$io->warning('Arrêt demandé, ' . count($echecs) . ' échecs jusqu\'à présent');
$progress->finish();
return 130;
}
try {
$resultat = $this->sync->synchroniser($f, $depuis);
$this->logger->info('Sync ok', ['fournisseur' => $f->getId(), 'maj' => $resultat->nbMaj]);
} catch (\Throwable $e) {
$echecs[] = $f->getId();
$this->logger->error('Sync échec', ['fournisseur' => $f->getId(), 'error' => $e->getMessage()]);
}
$progress->advance();
}
$progress->finish();
$output->writeln('');
if ($echecs !== []) {
$io->warning(sprintf('%d échec(s) : %s', count($echecs), implode(', ', $echecs)));
return Command::FAILURE;
}
$io->success('Tous les fournisseurs synchronisés');
return Command::SUCCESS;
}
}🔁 Quand utiliser / éviter
Utiliser :
- Tâches d'admin/maintenance (migrations data, ré-indexation, cleanups).
- Imports/exports batch.
- Cron jobs (avec Scheduler ou crontab système).
- Outils de debug en dev (
bin/console debug:*). - Workers Messenger eux-mêmes sont des commandes.
Éviter :
- Comme handler de tâche utilisateur — passe par Messenger.
- Pour appeler depuis du PHP web — duplique la logique dans un service partagé.
- Pour des opérations < 100ms — surcoût boot Symfony non rentable côté CLI.
- Comme API substitut — exposer une commande au web, c'est ouvrir RCE par mauvaise validation.
🏋️ Exercices
Pré-requis : un projet Symfony 7.x,
symfony/console,symfony/lock,symfony/scheduler,symfony/messenger. Chaque exercice est plus dur que le précédent.
1. Implémenter — app:report:generate idempotente
Objectif : écrire une commande qui agrège des stats et écrit un fichier, avec arguments/options typés, SymfonyStyle, et un exit code correct (SUCCESS/FAILURE/INVALID).
Indice/Solution : argument period (REQUIRED), option --format (csv|json, VALUE_REQUIRED, défaut csv), option --force (VALUE_NONE). Valide period dans execute(), retourne Command::INVALID si invalide (réserve FAILURE aux vraies erreurs runtime). Le métier vit dans un service injecté ; la commande ne fait que parser et formater.
2. Production-grade — lock auto-refresh + checkpoint reprenable
Objectif : un import long (> 5 min) qui (a) prend un lock non-bloquant dont le TTL ne peut jamais expirer pendant le travail, et (b) reprend exactement où il s'est arrêté après un crash.
Indice/Solution : LockFactory::createLock('import', ttl: 120) puis appelle $lock->refresh() à chaque batch (le TTL court + refresh évite le lock-orphelin si le process meurt — le TTL le libère). Persiste last_processed_id dans une table import_checkpoint ; au démarrage, lis le curseur et WHERE id > :cursor ORDER BY id. Commit le curseur dans la même transaction que les données, sinon tu peux dupliquer ou perdre une ligne au crash.
3. Production-grade — arrêt gracieux sous SIGTERM avec worker pool
Objectif : Process parallèles (pool de 20), un SIGTERM doit arrêter le lancement de nouveaux jobs, laisser finir les en cours, marquer les non démarrés en pending, et exit 130.
Indice/Solution : SignalableCommandInterface → handleSignal() met $this->shouldStop = true et retourne false (ne pas exit dans le handler — laisse execute() nettoyer). Boucle de pool : while (!empty($running) || (!$shouldStop && $queue)). À chaque tour, $process->isRunning() pour récolter les finis ; ne start() un nouveau process que si !$shouldStop. Important : les sous-process n'héritent pas du signal handler PHP du parent — relaie explicitement ($process->signal(SIGTERM)) si tu veux qu'ils s'arrêtent aussi.
4. Scheduler — cron in-app sans double exécution
Objectif : déclarer un ScheduleProviderInterface qui lance un job toutes les heures, qui ne double-exécute jamais sur 3 répliques, et qui ne rejoue pas les jobs ratés pendant un downtime.
Indice/Solution : #[AsSchedule], RecurringMessage::cron('0 * * * *', $msg)->withTimezone('Europe/Paris'), ->lock($lockFactory->createLock('sched')) (anti-concurrence multi-réplique), ->stateful($cache) (pas de catch-up). Vérifie avec bin/console debug:scheduler. Casse-le volontairement : retire ->lock(), scale à 3 workers, observe la triple exécution dans les logs — c'est le bug le plus courant en prod.
5. Break-then-fix — la commande qui mange toute la RAM
Objectif : on te donne une commande qui fait findAll() sur 2M lignes et OOM-kill à ~30 % d'avancement. Diagnostique et corrige sans changer le résultat fonctionnel.
Indice/Solution : symptômes → mémoire monotone croissante, OOM. Causes cumulées : (1) findAll() charge tout l'hydration en RAM → remplace par toIterable() (Doctrine) ou requête paginée par curseur ; (2) l'EntityManager garde toutes les entités en identity map → $em->clear() (ou detach) toutes les N entités ; (3) le SQLLogger Doctrine accumule chaque requête en dev → $em->getConnection()->getConfiguration()->setMiddlewares([]) ou tourner avec --no-debug. Mesure avant/après avec memory_get_peak_usage(true).
6. Break-then-fix — le scheduler qui prend du retard
Objectif : un Scheduler avec 12 RecurringMessage ; certains jobs ne se déclenchent plus à l'heure et le messenger:consume consomme 100 % CPU.
Indice/Solution : le worker scheduler_default est mono-thread — si un message handler est synchrone et lent (appel API 30s), il bloque tout le planning derrière lui. Fix : le handler du message planifié ne fait rien sauf re-dispatcher vers un autre transport async (#[AsMessage('async')]), traité par un pool de workers séparé. Le worker scheduler doit rester quasi-instantané. Ajoute --time-limit + --memory-limit pour le redémarrage propre.
🎤 En entretien
Q : Un controller doit lancer un import lourd. Pourquoi ne jamais appeler bin/console ou Application depuis le controller ? R : Ça bloque le worker HTTP (PHP-FPM) pendant toute la durée, ré-boote un kernel dans le kernel, et perd les exit codes/signals. La bonne réponse est de dispatcher un message Messenger consommé par un worker async — le controller répond 202 Accepted immédiatement.
Q : Comment garantis-tu qu'un cron job ne tourne jamais en double, même en cas de scale horizontal ? R : Lock non-bloquant (acquire(false)) avec un store partagé (Redis/DB), pas FlockStore qui est local à la machine. Pour le Scheduler Symfony, ->lock() sur le Schedule. Le TTL du lock doit dépasser le pire-cas d'exécution, ou utiliser refresh() périodique pour ne jamais expirer pendant le travail.
Q : Kubernetes envoie SIGTERM puis SIGKILL 30s après. Comment ta commande survit à un rolling deploy au milieu d'un batch ? R : SignalableCommandInterface : getSubscribedSignals() déclare SIGTERM/SIGINT, handleSignal() lève un flag shouldStop (sans exit), et la boucle de travail teste ce flag entre deux unités, flushe, libère le lock, persiste le checkpoint, et retourne 130. Combiné à un curseur reprenable, le prochain pod reprend sans perte ni doublon. La fenêtre des 30s impose des unités de travail courtes.
Q : Crontab système ou Symfony Scheduler — comment tranches-tu ? R : Scheduler si j'ai déjà un worker Messenger long-running et que je veux du cron versionné, observable (profiler), avec anti-concurrence native — au prix d'un process permanent à superviser. Crontab pour les jobs rares et isolés où faire tourner un worker 24/7 juste pour ça est du gâchis. Jamais les deux sur le même job (double déclenchement garanti).