Application commands
Application commands are plain invokable PHP classes. Place them under
src/Commands, add #[Command], and Gustav discovers them automatically. No
command registry or entrypoint setup is required.
namespace App\Commands;
use App\Services\UserSynchronizer;
use GustavPHP\Gustav\Attribute\{Argument, Command, Option, Validate};
use GustavPHP\Gustav\Validation\Common\Integer;
use Symfony\Component\Console\Output\OutputInterface;
#[Command('users:sync', description: 'Synchronize users')]
final readonly class SyncUsers
{
public function __construct(
private UserSynchronizer $users,
private OutputInterface $output,
) {
}
public function __invoke(
#[Argument(description: 'Tenant identifier')]
string $tenant,
#[Option(shortcut: 'l', description: 'Maximum users to synchronize')]
#[Validate(new Integer(min: 1, max: 1000))]
int $limit = 100,
#[Option('dry-run', description: 'Do not persist changes')]
bool $dryRun = false,
): int {
$count = $this->users->sync($tenant, $limit, $dryRun);
$this->output->writeln("Synchronized {$count} users");
return 0;
}
}
Run or inspect the command with the project CLI:
php gustav users:sync acme --limit=250 --dry-run
php gustav list
php gustav help users:sync
Command classes may return an integer exit code or declare void. A void
command exits successfully with code 0.
Typed arguments and options
Every __invoke() parameter declares exactly one input attribute:
#[Argument]reads a positional value.#[Option]reads a named option.
The PHP parameter name becomes the input name by default. Camel-case option
names are converted to kebab case, so $dryRun becomes --dry-run. Pass an
explicit name when the CLI contract should differ from the PHP name.
Gustav converts command input using the same deterministic conversion rules as HTTP input:
| PHP type | Command input |
|---|---|
string | The supplied text |
int | A valid whole number, including 0 and negative values |
float | A finite decimal number |
bool | A flag such as --dry-run; default-true flags support --no-color |
array | Repeated values such as --tag=api --tag=stable |
| backed enum | An exact string backing value or a valid integer backing value |
Nullable forms are supported. Required and optional inputs follow the PHP signature:
- A positional argument or value-taking option without a default is required.
- An omitted parameter with a default preserves that PHP default.
- Use
?string $format = nullwhen omission should resolve tonull. - Boolean options must declare a default:
falsecreates a regular flag andtruealso enables its--no-...form. - Nullable does not make a parameter optional without a PHP default.
- Ambiguous unions and unsupported object types fail during application boot.
Array element types are not inferred. DTO parameters are reserved for HTTP input and are not accepted as command arguments or options.
Validation and failures
Attach repeatable #[Validate] rules to arguments and options. Gustav converts
all supplied values, runs every rule, and reports the violations together:
Invalid command input
argument tenant [min_length] Value must contain at least 2 characters
option --limit [max_value] Value must be less than or equal to 1000
The handler is not invoked when input is invalid. Command exit codes are:
| Code | Meaning |
|---|---|
0 | Success |
1 | The command failed unexpectedly |
2 | Invalid command syntax, conversion, or validation |
Unexpected application exceptions are logged with the command name. Production
console output says only Command failed; it does not expose the exception
message or trace. The command scope is released after both success and failure,
so a failed invocation cannot contaminate a later one in the same process.
Constructor injection and scope
Command constructors use the same service container as controllers and
middleware. Inject application services, typed configuration, or
Psr\Log\LoggerInterface normally.
During a command, Gustav also provides:
Symfony\Component\Console\Input\InputInterfaceSymfony\Component\Console\Output\OutputInterfaceSymfony\Component\Console\Style\SymfonyStyle- the active
Service\Container
Services using the default Lifetime::Scoped lifetime are created once for
the command and released afterward. Request-only values such as
ServerRequestInterface and Http\RequestId are available only while handling
HTTP requests.
Project bootstrap and custom namespaces
Both the HTTP worker and project CLI use app/bootstrap.php as the single
configuration source:
// app/bootstrap.php
use GustavPHP\Gustav\Configuration;
return Configuration::forProject(
namespace: 'App',
root: dirname(__DIR__),
);
// app/index.php
use GustavPHP\Gustav\Application;
Application::run(require __DIR__ . '/bootstrap.php');
php gustav loads the bootstrap automatically and discovers commands
recursively under App\Commands. Modular applications can add namespaces:
Configuration::forProject(
namespace: 'App',
root: dirname(__DIR__),
commandNamespaces: ['Module\Billing\Commands'],
);
Testing commands
Use Symfony's in-process ApplicationTester; no subprocess or RoadRunner
server is needed:
use GustavPHP\Gustav\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\ApplicationTester;
$configuration = require dirname(__DIR__) . '/app/bootstrap.php';
$tester = new ApplicationTester((new Application($configuration))->console());
$status = $tester->run([
'command' => 'users:sync',
'tenant' => 'acme',
'--limit' => '25',
'--dry-run' => true,
]);
expect($status)->toBe(Command::SUCCESS);
expect($tester->getDisplay())->toContain('Synchronized');
This path performs normal command discovery, dependency injection, conversion, validation, exception handling, and scope cleanup.