Console commands
OrangeHRM has two console entry points, each serving a different audience:
| Entry point | Audience | Path | Composer dependencies |
|---|---|---|---|
bin/console |
Production / runtime ops | repo root | Uses src/vendor/autoload.php (the main app) |
devTools/core/console.php |
Developer-only | devTools/core/ |
Has its own composer.json + vendor/ |
Both extend Symfony\Component\Console\Application (via OHRM's thin OrangeHRM\Framework\Console\Console subclass). They wire up commands differently — see "Where commands live" below.
This skill covers writing new commands, registering them, and choosing the right console. For specific existing commands, see migrations (instance:*, migration:up), dev-environment (i:create-test-db), rest-openapi (generate-open-api-doc), scheduled-jobs (orangehrm:run-schedule).
The two consoles
bin/console — production runtime
bin/console lives at the repo root and only depends on the main app's autoload. It's safe to run anywhere the app is deployed.
// bin/console (simplified)
require_once $pathToAutoload;
new Framework($env, $debug); // boot the framework
$console = new Console();
foreach ($pluginConfigs as $pluginConfig) { // iterate registered plugins
$configClass = new $pluginConfig['classname']();
$configClass->initialize($request);
if ($configClass instanceof ConsoleConfigurationInterface) {
$configClass->registerCommands($console); // ← plugins surface their commands
}
}
$console->add(new CacheClearCommand());
$console->add(new GenerateDoctrineProxiesCommand());
$console->run();
Commands available in bin/console come from two sources:
- Plugins that implement
ConsoleConfigurationInterfaceand surface commands inregisterCommands(Console $console). - The hardcoded
CacheClearCommandandGenerateDoctrineProxiesCommandthatbin/consoleadds directly (used by composer'spost-autoload-dump).
php bin/console list # see all commands
php bin/console cache:clear
php bin/console orm:generate-proxies
php bin/console orangehrm:run-schedule # cron scheduler — see scheduled-jobs skill
devTools/core/console.php — developer tools
devTools/core/ is a separate composer project with its own composer.json and vendor/. It depends on the main app's autoload at runtime but isolates dev dependencies (PHP-CS-Fixer, swagger-php).
php devTools/core/console.php list
php devTools/core/console.php php-cs-fix
php devTools/core/console.php instance:create-test-db -p root
php devTools/core/console.php instance:reset
php devTools/core/console.php instance:reinstall
php devTools/core/console.php migration:up "\OrangeHRM\Installer\Migration\V5_9_0\Migration"
php devTools/core/console.php add-data-group
php devTools/core/console.php add-role-permission
php devTools/core/console.php generate-open-api-doc --throw
php devTools/core/console.php event-dispatcher:debug
Commands live in devTools/core/src/Command/ and are registered directly in devTools/core/console.php (no plugin discovery). This console isn't deployed to production — devTools/ is excluded from the build artifacts.
Which console to put a new command in
| If your command… | Use |
|---|---|
| Is needed in production (ops, scheduled jobs, cache management, migration assistance) | bin/console |
| Is for developers only (code generation, test setup, lint, doc generation) | devTools/core/console.php |
Is invoked by composer install's post-autoload-dump |
bin/console (it's the one composer reaches) |
| Should be available in an offline / CI environment without dev deps | bin/console |
| Touches dev-only dependencies (PHPUnit fixtures, swagger-php, php-cs-fixer) | devTools/core/console.php |
When in doubt: devTools/core/ for tools the team uses while developing, bin/console for tools that production ops needs.
The Command base class
OrangeHRM\Framework\Console\Command is a thin wrapper around Symfony's Command:
abstract class Command extends \Symfony\Component\Console\Command\Command
{
protected ?SymfonyStyle $io = null;
public function __construct()
{
parent::__construct($this->getCommandName()); // ← name comes from abstract method
}
protected function getIO(): SymfonyStyle
{
return $this->io;
}
public function run(InputInterface $input, OutputInterface $output): int
{
$this->setIO($input, $output); // ← auto-sets $this->io before execute()
return parent::run($input, $output);
}
abstract public function getCommandName(): string; // ← subclasses implement
}
Three things to note:
getCommandName(): stringis abstract — every subclass provides its name. Symfony'ssetName()is set automatically from this.$this->getIO()returns aSymfonyStyleready-to-use — no need to construct one yourself inexecute(). It's set up automatically beforeexecute()runs.- Symfony's standard
Commandis still the parent —configure(),execute(InputInterface, OutputInterface),addArgument,addOptionall work as usual.
Anatomy of a typical command
<?php
namespace OrangeHRM\X\Command;
use OrangeHRM\Framework\Console\Command;
use OrangeHRM\X\Traits\Service\WidgetServiceTrait;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class CleanupWidgetsCommand extends Command
{
use WidgetServiceTrait; // ← compose like a service
public function getCommandName(): string
{
return 'widget:cleanup'; // ← <namespace>:<action>
}
protected function configure(): void
{
$this->setDescription('Delete orphaned widgets')
->addArgument('olderThan', InputArgument::OPTIONAL, 'In days', '30')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview without deleting');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$olderThan = (int) $input->getArgument('olderThan');
$dryRun = (bool) $input->getOption('dry-run');
$count = $this->getWidgetService()->countOrphaned($olderThan);
$this->getIO()->info("Found $count orphaned widgets older than $olderThan days");
if ($dryRun) {
$this->getIO()->note('Dry run — nothing deleted');
return self::SUCCESS;
}
if (!$this->getIO()->confirm('Delete them?', false)) {
$this->getIO()->warning('Cancelled');
return self::INVALID;
}
$deleted = $this->getWidgetService()->deleteOrphaned($olderThan);
$this->getIO()->success("Deleted $deleted widgets");
return self::SUCCESS;
}
}
Standard pattern observed across all OHRM commands:
- Extends
OrangeHRM\Framework\Console\Command getCommandName()returns the user-typed name (e.g.widget:cleanup)configure()declares arguments, options, descriptionexecute()reads input, calls services via Traits, uses$this->getIO()for output, returnsself::SUCCESS/self::FAILURE/self::INVALID- Compose traits exactly like services do —
WidgetServiceTrait,EventDispatcherTrait,LoggerTrait, etc.
Naming conventions
Commands use Symfony's standard namespace:action shape. Conventional namespaces in OHRM:
| Namespace | Use |
|---|---|
cache:* |
Cache management (cache:clear) |
orm:* |
Doctrine operations (orm:generate-proxies) |
instance:* |
DB/app instance management (instance:reset, instance:reinstall, instance:create-test-db). i:* is the shorthand alias. |
migration:* |
Migration utilities (migration:up) |
orangehrm:* |
Cron scheduler (orangehrm:run-schedule) |
<plugin>:<action> |
Plugin-specific commands (leave:export, ldap:sync-user) |
| Single word | Top-level commands like php-cs-fix, add-data-group, add-role-permission, generate-open-api-doc, event-dispatcher:debug — used in devTools/core/console.php where there's no need for a plugin namespace |
When choosing, match an existing namespace if your command logically fits there; reach for a new one only for genuinely new domains.
Registering commands for bin/console
Commands surface in bin/console via the plugin's <Plugin>PluginConfiguration class implementing ConsoleConfigurationInterface in addition to PluginConfigurationInterface.
namespace OrangeHRM\X;
use OrangeHRM\Framework\Console\Console;
use OrangeHRM\Framework\Console\ConsoleConfigurationInterface;
use OrangeHRM\Framework\Http\Request;
use OrangeHRM\Framework\PluginConfigurationInterface;
use OrangeHRM\X\Command\CleanupWidgetsCommand;
class XPluginConfiguration implements PluginConfigurationInterface, ConsoleConfigurationInterface
{
public function initialize(Request $request): void
{
// … normal service registration …
}
public function registerCommands(Console $console): void
{
$console->add(new CleanupWidgetsCommand());
}
}
Then:
php bin/console widget:cleanup
Multiple commands? Multiple $console->add() calls.
Conditional registration (e.g. dev-only)
Wrap in a Config check:
public function registerCommands(Console $console): void
{
$console->add(new RunScheduleCommand()); // always available
if (Config::PRODUCT_MODE !== Config::MODE_PROD) {
$console->add(new EnableTestLanguagePackCommand()); // dev only
}
}
The core plugin's CorePluginConfiguration::registerCommands() does this for the test-language-pack command. Commands that depend on dev-only state should be gated this way (or moved to devTools/core/).
Registering commands for devTools/core/console.php
Different mechanism — no plugin scan. Edit devTools/core/console.php directly and add to the $application->add(...) calls. Same pattern as bin/console, just without the plugin abstraction.
// devTools/core/console.php (representative)
$application = new Console();
$application->add(new PHPFixCodingStandardsCommand());
$application->add(new GenerateOpenApiDocCommand());
$application->add(new CreateTestDatabaseCommand());
$application->add(new ResetInstallationCommand());
$application->add(new ReInstallCommand());
$application->add(new AddDataGroupCommand());
$application->add(new AddRolePermissionCommand());
$application->add(new RunMigrationClassCommand());
$application->add(new EventDispatcherDebugCommand());
$application->run();
For a new dev-tool command, drop the class in devTools/core/src/Command/ and add one line to devTools/core/console.php.
SymfonyStyle helpers — $this->getIO()
SymfonyStyle (returned by getIO()) has rich output helpers:
$this->getIO()->title('Cleanup widgets');
$this->getIO()->section('Phase 1: discovery');
$this->getIO()->success('Done'); // green box
$this->getIO()->error('Failed'); // red box
$this->getIO()->warning('Risk'); // yellow box
$this->getIO()->note('FYI'); // grey box
$this->getIO()->info('FYI'); // blue
$this->getIO()->caution('Heads up'); // red banner
$this->getIO()->writeln('Plain text line');
$this->getIO()->write('No newline');
// Interactive
$value = $this->getIO()->ask('Enter value', 'default');
$value = $this->getIO()->ask('Enter value', null, fn ($v) => $v ?: throw new \InvalidArgumentException('Required'));
$password = $this->getIO()->askHidden('Password');
$yes = $this->getIO()->confirm('Proceed?', false);
$choice = $this->getIO()->choice('Pick', ['a', 'b', 'c']);
$this->getIO()->table(['Col1', 'Col2'], [['a', 1], ['b', 2]]);
$this->getIO()->listing(['item 1', 'item 2', 'item 3']);
$bar = $this->getIO()->createProgressBar(100);
// $bar->advance(); $bar->finish();
Use these consistently — they match how every other OHRM command formats output. Don't roll your own colored output via <info> tags unless SymfonyStyle doesn't cover what you need.
Interactive vs non-interactive
Commands often need to support both interactive use (devs at terminal) and non-interactive (CI, scripts). Symfony provides $input->isInteractive():
if ($input->isInteractive() && !$input->hasOption('confirmed')) {
if (!$this->getIO()->confirm('Are you sure?', false)) {
return self::INVALID;
}
}
The installer commands (install:on-new-database, upgrade:run) check this — interactive mode prompts, non-interactive mode requires every option as a flag.
Composing helpers in commands
Commands can use any of the trait helpers that services and subscribers use — LoggerTrait, ConfigServiceTrait, EventDispatcherTrait, plugin-specific *ServiceTrait. Same DI container, same lazy-getter mechanics.
class SyncCommand extends Command
{
use EmployeeServiceTrait;
use ConfigServiceTrait;
use LoggerTrait;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->getLogger()->info('Sync started');
$batchSize = (int) $this->getConfigService()->getConfigValue('sync.batch_size', '100');
$employees = $this->getEmployeeService()->getActiveEmployees();
// …
}
}
See services skill for the full trait pattern.
Existing OHRM dev commands (devTools/core)
Already documented in their respective skills, but the catalog:
| Command | Skill it lives in |
|---|---|
php-cs-fix |
(linting; mentioned in CLAUDE.md) |
generate-open-api-doc --throw |
rest-openapi |
instance:create-test-db (i:create-test-db) |
dev-environment, testing |
instance:reset (i:reset) |
migrations |
instance:reinstall (i:reinstall) |
migrations |
migration:up <Class> |
migrations |
add-data-group |
authorization |
add-role-permission |
authorization |
event-dispatcher:debug |
this skill (it lists all subscriber registrations — useful for debugging "did my subscriber register?") |
Existing OHRM bin/console commands
From the core plugin's registerCommands():
| Command | What it does |
|---|---|
cache:clear |
Clears the symfony Cache adapters (orangehrm, doctrine_metadata, doctrine_queries) |
orm:generate-proxies |
Regenerates Doctrine proxy classes (see doctrine-bootstrap) |
orangehrm:run-schedule |
Runs all scheduled tasks for the current time (see scheduled-jobs) |
orangehrm:enable-test-lang-pack |
Dev-only — enables a test language pack |
Plus per-plugin commands like ldap:sync-user from the LDAP plugin.
Recipes
Recipe 1 — A new bin/console command for a feature plugin
<?php
namespace OrangeHRM\X\Command;
use OrangeHRM\Framework\Console\Command;
use OrangeHRM\X\Traits\Service\WidgetServiceTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class WidgetExportCommand extends Command
{
use WidgetServiceTrait;
public function getCommandName(): string
{
return 'widget:export';
}
protected function configure(): void
{
$this->setDescription('Export widgets to CSV')
->addOption('path', null, InputOption::VALUE_REQUIRED, 'Output file', 'widgets.csv');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$path = $input->getOption('path');
$count = $this->getWidgetService()->exportToCsv($path);
$this->getIO()->success("Exported $count widgets to $path");
return self::SUCCESS;
}
}
Register in XPluginConfiguration:
class XPluginConfiguration implements PluginConfigurationInterface, ConsoleConfigurationInterface
{
public function registerCommands(Console $console): void
{
$console->add(new WidgetExportCommand());
}
}
Run:
php bin/console widget:export --path=/tmp/out.csv
Recipe 2 — A dev-only command in devTools
<?php
namespace OrangeHRM\DevTools\Command;
use OrangeHRM\Framework\Console\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SeedDevDataCommand extends Command
{
public function getCommandName(): string
{
return 'dev:seed';
}
protected function configure(): void
{
$this->setDescription('Seed development fixtures');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// … insert dev fixtures …
$this->getIO()->success('Seeded');
return self::SUCCESS;
}
}
Edit devTools/core/console.php:
$application->add(new SeedDevDataCommand());
Run:
php devTools/core/console.php dev:seed
Recipe 3 — Interactive command with arg validation
class ImportFileCommand extends Command
{
public function getCommandName(): string
{
return 'data:import';
}
protected function configure(): void
{
$this->addArgument('path', InputArgument::OPTIONAL, 'CSV file path');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$path = $input->getArgument('path');
if (!$path && $input->isInteractive()) {
$path = $this->getIO()->ask('Path to CSV', null, function ($value) {
if (!is_file($value)) {
throw new \InvalidArgumentException("File not found: $value");
}
return $value;
});
}
if (!$path) {
$this->getIO()->error('--path is required in non-interactive mode');
return self::INVALID;
}
// … process …
return self::SUCCESS;
}
}
The pattern: optional argument, prompt for it interactively if missing, fail with a clear message if missing and non-interactive.
Recipe 4 — Command that runs another command (sub-command invocation)
class FullResetCommand extends Command
{
public function getCommandName(): string
{
return 'dev:full-reset';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$resetCmd = $this->getApplication()->find('instance:reset');
$reinstallCmd = $this->getApplication()->find('instance:reinstall');
$resetCmd->run(new \Symfony\Component\Console\Input\ArrayInput([]), $output);
$reinstallCmd->run(new \Symfony\Component\Console\Input\ArrayInput([]), $output);
return self::SUCCESS;
}
}
Use $this->getApplication()->find('command:name')->run() to invoke another command. Each gets its own ArrayInput for arguments.
Checklists
Add a new bin/console command
- Class in
src/plugins/orangehrm{X}Plugin/Command/<Name>Command.phpextendingOrangeHRM\Framework\Console\Command -
getCommandName()returns the user-typed name (<namespace>:<action>convention) -
configure()for arguments + options + description -
execute()reads input, uses$this->getIO()for output, returnsself::SUCCESS|FAILURE|INVALID - Compose any services/helpers via Traits (
WidgetServiceTrait,LoggerTrait, etc.) - Plugin's
<Plugin>PluginConfigurationimplementsConsoleConfigurationInterfaceand registers the command inregisterCommands(Console $console) - Test by running
php bin/console list— your command should appear
Add a new devTools command
- Class in
devTools/core/src/Command/<Name>Command.phpextendingOrangeHRM\Framework\Console\Command - Standard
getCommandName,configure,execute - Add
$application->add(new <Name>Command())todevTools/core/console.php - Test:
php devTools/core/console.php list
Things that bite
- Forgetting
ConsoleConfigurationInterface— the plugin must implement bothPluginConfigurationInterfaceANDConsoleConfigurationInterfaceforregisterCommands()to be called. Implementing only the second silently fails (the framework only checks for both). - Forgetting to register in
devTools/core/console.php— for dev-tool commands, there's no plugin scan. The class file alone isn't enough; the$application->add(...)line is mandatory. - Mixing dev dependencies into a
bin/consolecommand — if your command pulls in something only present indevTools/core/vendor/, it'll fail on production deploys. Keepbin/consolecommands using only what's insrc/vendor/. $this->getIO()returns null beforeexecute()— the framework sets it up inrun(), but if yourconfigure()tries to use it, you'll get a TypeError.configure()runs without IO; do all IO insideexecute().composer install'spost-autoload-dumprunscache:clearandorm:generate-proxies. If you break either of those commands, everycomposer installafterwards fails. Test these specifically when modifying them.- Commands run with no HTTP request context — composables expecting
getCurrentInstance()or session won't work. Most things do (DI container, EM, traits) but some Vue-side helpers and request-coupled subscribers might not. getCommandName()typos are silent. If you misspellwidget:expott,bin/console listshows it as-is andbin/console widget:exportsays "command not found." The typed name and the constant should match.- In dev mode the cache is
ArrayAdapter—cache:clearclears nothing visible because nothing was persisted. The command still reports success. Use it in prod where it actually matters.