| name | ddd-cli-command-specialist |
| description | Create Symfony console commands in the mgamadeus/ddd framework. Covers command structure, arguments/options, admin auth context setup, service access, output formatting, batch processing patterns, memory/time limits, and signal handling. |
| metadata | {"author":"mgamadeus","version":"1.0.0","framework":"mgamadeus/ddd"} |
DDD CLI Command Specialist
Symfony console commands within the DDD Core framework (mgamadeus/ddd).
When to Use
- Creating new console commands for data processing, maintenance, or automation
- Implementing batch operations (imports, recalculations, migrations)
- Creating scheduled/cron-triggered jobs
- Understanding command structure and output patterns
Namespace & Location
Framework commands: DDD\Symfony\Commands\{Base|Common}\
Application commands: App\Symfony\Commands\{Domain}\
Directory structure:
src/Symfony/Commands/
+-- Base/
| +-- Database/ShowEntitySql.php, ListEntities.php
| +-- DoctrineModels/CreateDoctrineModels.php
| +-- Messages/ProcessCLIMessage.php
+-- Common/
| +-- Crons/CronsExecute.php, CronsList.php
Application commands follow the same pattern under src/Symfony/Commands/{Domain}/.
Command Template
<?php
declare(strict_types=1);
namespace {Namespace}\Symfony\Commands\{Domain};
use DDD\Infrastructure\Services\AuthService;
use DDD\Infrastructure\Services\DDDService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:domain:action-name',
description: 'Short description of what the command does'
)]
class ActionNameCommand extends Command
{
protected function configure(): void
{
$this
->addOption('worldId', null, InputOption::VALUE_REQUIRED, 'The World ID')
->addOption('dateFrom', null, InputOption::VALUE_OPTIONAL, 'Start date (Y-m-d)')
->addOption('dryRun', null, InputOption::VALUE_NONE, 'Preview changes without applying');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
ini_set('memory_limit', '1024M');
set_time_limit(3600);
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
if ($defaultAccount) {
AuthService::instance()->setAccount($defaultAccount);
}
$worldId = (int) $input->getOption('worldId');
$symfonyStyle = new SymfonyStyle($input, $output);
try {
$myService = MyEntities::getService();
$myService->throwErrors = true;
$myService->doWork($worldId, $symfonyStyle);
$symfonyStyle->success('Operation completed successfully.');
return Command::SUCCESS;
} catch (\Throwable $t) {
$symfonyStyle->error($t->getMessage());
return Command::FAILURE;
}
}
}
Critical Patterns
Admin Auth Context Setup
CLI commands run without an HTTP request, so there's no authenticated user. Most commands need admin privileges to access entities:
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
if ($defaultAccount) {
AuthService::instance()->setAccount($defaultAccount);
}
This loads a pre-configured admin account (from env/config) and sets it as the authenticated user for the command's execution context. Without this, entity rights restrictions will block most queries.
Memory & Time Limits
Set appropriate limits based on the operation:
ini_set('memory_limit', '1024M');
ini_set('memory_limit', '2048M');
set_time_limit(3600);
set_time_limit(120);
Service Access
Use the same patterns as the rest of the framework:
$myService = MyEntities::getService();
$myService = DDDService::instance()->getService(MyService::class);
Never instantiate services directly with new.
Arguments & Options
Arguments (Positional, Required by Default)
use Symfony\Component\Console\Input\InputArgument;
$this->addArgument('operation', InputArgument::REQUIRED, 'The operation to execute');
$this->addArgument('file', InputArgument::OPTIONAL, 'Optional file path');
Options (Named, Prefixed with --)
use Symfony\Component\Console\Input\InputOption;
$this->addOption('worldId', null, InputOption::VALUE_REQUIRED, 'The World ID');
$this->addOption('dateFrom', null, InputOption::VALUE_OPTIONAL, 'Start date', date('Y-01-01'));
$this->addOption('dryRun', null, InputOption::VALUE_NONE, 'Preview without applying');
$this->addOption('suite', 's', InputOption::VALUE_OPTIONAL, 'Test suite name');
Reading Input
$operation = $input->getArgument('operation');
$worldId = (int) $input->getOption('worldId');
$dateFrom = $input->getOption('dateFrom') ?? date('Y-01-01');
$dryRun = $input->getOption('dryRun');
Output Patterns
SymfonyStyle (Preferred for Formatted Output)
$symfonyStyle = new SymfonyStyle($input, $output);
$symfonyStyle->title('Command Title');
$symfonyStyle->section('Section Name');
$symfonyStyle->success('Operation completed.');
$symfonyStyle->error('Something failed.');
$symfonyStyle->warning('Check the results.');
$symfonyStyle->note('Additional info.');
$symfonyStyle->text('Regular text output.');
$symfonyStyle->newLine();
Tables
use Symfony\Component\Console\Helper\Table;
$table = new Table($output);
$table->setHeaders(['Name', 'Status', 'Created']);
foreach ($items as $item) {
$table->addRow([$item->name, $item->status, $item->created?->format('Y-m-d H:i:s') ?? '-']);
}
$table->render();
Progress Bars (for Batch Operations)
$symfonyStyle->progressStart(count($items));
foreach ($items as $item) {
$symfonyStyle->progressAdvance();
}
$symfonyStyle->progressFinish();
Direct Output (Simple Status Messages)
$output->writeln("Processing entity {$entity->id}...");
$output->writeln("<info>Done.</info>");
$output->writeln("<error>Failed: {$error}</error>");
Passing Output to Services
Services can accept OutputInterface or SymfonyStyle to provide progress feedback during long operations:
$myService->recalculateAll($worldId, $symfonyStyle);
public function recalculateAll(int $worldId, ?SymfonyStyle $output = null): void
{
$entities = $this->findAll();
$output?->progressStart($entities->count());
foreach ($entities->getElements() as $entity) {
$output?->progressAdvance();
}
$output?->progressFinish();
}
Common Command Types
Data Processing / Recalculation
#[AsCommand(name: 'app:recalculate-journals', description: 'Recalculates journals for current year')]
class RecalculateJournals extends Command
{
protected function configure(): void
{
$this
->addOption('worldId', null, InputOption::VALUE_OPTIONAL, 'Limit to specific World')
->addOption('year', null, InputOption::VALUE_OPTIONAL, 'Year', date('Y'));
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
ini_set('memory_limit', '1024M');
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
AuthService::instance()->setAccount($defaultAccount);
$symfonyStyle = new SymfonyStyle($input, $output);
$journalsService = Journals::getService();
$journalsService->recalculateForYear(
(int) $input->getOption('year'),
$input->getOption('worldId') ? (int) $input->getOption('worldId') : null,
$symfonyStyle
);
return Command::SUCCESS;
}
}
Scheduled / Cron Jobs
#[AsCommand(name: 'app:send-scheduled-notifications', description: 'Sends pending scheduled notifications')]
class SendScheduledNotifications extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
ini_set('memory_limit', '2048M');
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
AuthService::instance()->setAccount($defaultAccount);
$notificationsService = Notifications::getService();
$notificationsService->sendScheduledNotifications();
return Command::SUCCESS;
}
}
Import / Migration
#[AsCommand(name: 'app:import-data', description: 'Imports data from config or external source')]
class ImportData extends Command
{
protected function configure(): void
{
$this
->addArgument('operation', InputArgument::REQUIRED, 'Import operation')
->addOption('dryRun', null, InputOption::VALUE_NONE, 'Preview only');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
ini_set('memory_limit', '1024M');
set_time_limit(3600);
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
AuthService::instance()->setAccount($defaultAccount);
$operation = $input->getArgument('operation');
$symfonyStyle = new SymfonyStyle($input, $output);
match ($operation) {
'allergens' => Allergens::getService()->importFromConfig(),
'ingredients' => Ingredients::getService()->importFromConfig(Allergens::getService()),
default => $symfonyStyle->error("Unknown operation: {$operation}"),
};
$symfonyStyle->success("Import '{$operation}' completed.");
return Command::SUCCESS;
}
}
Async Dispatch (Trigger Background Processing)
#[AsCommand(name: 'app:process-geo-hashes', description: 'Dispatches geo hash processing for tracks')]
class ProcessGeoHashes extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
ini_set('memory_limit', '2048M');
$defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations();
AuthService::instance()->setAccount($defaultAccount);
$tracksService = Tracks::getService();
$tracksService->processGeoHashesForTracks(async: true);
$output->writeln('<info>Async processing dispatched.</info>');
return Command::SUCCESS;
}
}
Advanced Patterns
Signal Handling for Graceful Interruption
For long-running batch operations, handle SIGINT for clean shutdown:
protected function execute(InputInterface $input, OutputInterface $output): int
{
$interrupted = false;
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGINT, function () use (&$interrupted, $output) {
$output->writeln("\n<comment>Interrupt received, finishing current item...</comment>");
$interrupted = true;
});
}
foreach ($items as $item) {
if ($interrupted) {
break;
}
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
}
return Command::SUCCESS;
}
Distributed Execution via Fraction Parameter
For commands that run on multiple servers or schedules:
$this->addOption('fraction', null, InputOption::VALUE_OPTIONAL, 'Fraction of items to process (0.0-1.0)', '1');
$fraction = (float) $input->getOption('fraction');
$totalConnections = count($connections);
$connectionsToProcess = (int) ceil($totalConnections * $fraction);
$connections = array_slice($connections, 0, $connectionsToProcess);
Run * * * * * app:import --fraction=0.25 four times per hour to cover all items.
Framework-Provided Commands
The DDD Core framework ships these commands — run them via the consuming app's bin/console.
Schema inspection — understand the SQL an entity produces
| Command | Arguments | Purpose |
|---|
app:entity:show-sql [entity] | entity (optional): short name (Account) or FQN; omit ⇒ every entity | Prints the generated CREATE TABLE + index / foreign-key DDL for the entity, derived from its attributes. Read-only — executes nothing. The fastest way to see the exact schema an entity maps to: default per-column indexes, FK indexes, spatial/vector/fulltext indexes, and trait columns (id, created/updated). |
app:entity:list [filter] | filter (optional): case-insensitive substring over name / table / FQN | Lists every DB-mapped entity with its SQL table name and FQN. Use it to discover entity names/tables before show-sql. STI subclasses are shown as folding into their parent table. |
php bin/console app:entity:show-sql Account
php bin/console app:entity:show-sql 'DDD\Domain\Common\Entities\Accounts\Account'
php bin/console app:entity:show-sql
php bin/console app:entity:list memory
Agentic tip: when reasoning about an entity's persistence, run app:entity:show-sql <Entity> to see the exact DDL the generator emits instead of guessing from the PHP. For how those indexes/columns are decided, see ddd-entity-specialist → "Database Indexes & Virtual Columns"; for migrating a live database to this target schema, see ddd-database-schema-diff-specialist.
Code generation, messaging, scheduling
| Command | Arguments | Purpose |
|---|
app:generate-doctrine-models-for-entities | — | Generates DB*Model.php Doctrine model classes from entity attributes (wired into the apps' composer post-update-cmd) |
app:process-cli-message <message> [--useTempFile] | message (required), --useTempFile (flag) | Decodes an AppMessage and invokes its handler (cross-workspace / CLI message handling) |
app:crons:execute | — | Executes all cron jobs that are due |
app:crons:list | — | Lists all registered cron jobs with status |
Naming Conventions
| Element | Convention | Example |
|---|
| Command name | app:{domain}:{action} | app:challenges:recalculate-journals |
| Class name | PascalCase action | RecalculateJournals |
| Options | camelCase | --worldId, --dateFrom, --dryRun |
| Arguments | camelCase | operation, filePath |
Checklist
Cross-Reference
- Service resolution & business logic — commands are thin entry points that delegate to services; see
ddd-service-specialist.
- Async dispatch from a command — when a command enqueues background work (
async: true), the message + handler side lives in ddd-message-handler-specialist.
- Loading entities by ID / sets — see
ddd-entity-specialist for the byId / repository patterns commands use after setting the auth context.