marko/cli
Global command-line tool --- run marko from any project directory to execute commands. Install once globally, use in any Marko project. The CLI finds your project root, boots the application, and runs commands registered by your modules. No per-project CLI setup needed.
Installation
Section titled “Installation”composer global require marko/cliEnsure Composer’s global bin directory is in your PATH.
Running Commands
Section titled “Running Commands”From any directory within a Marko project:
marko list # Show all available commandsmarko module:list # List installed modulesmarko discovery:cache # Compile the discovery cache (run on deploy)marko discovery:clear # Remove the discovery cachemarko cache:clear # Clear cache (if cache module installed)marko db:migrate # Run migrations (if database module installed)Creating Commands
Section titled “Creating Commands”Register commands in your modules using the #[Command] attribute:
use Marko\Core\Attributes\Command;use Marko\Core\Command\CommandInterface;use Marko\Core\Command\Input;use Marko\Core\Command\Output;
/** @noinspection PhpUnused */#[Command(name: 'greet', description: 'Say hello')]class GreetCommand implements CommandInterface{ public function execute(Input $input, Output $output): int { $name = $input->getArgument(0) ?? 'World'; $output->writeLine("Hello, $name!");
return 0; // Exit code }}IDE Note: PhpStorm may report command classes as “unused” since they’re discovered via attributes rather than direct instantiation. The
@noinspection PhpUnusedannotation suppresses this false positive.
Run it:
marko greet# Hello, World!
marko greet Mark# Hello, Mark!Arguments and Options
Section titled “Arguments and Options”Input parses the command line once into positional arguments and options. Options can appear before, between, or after arguments --- getArgument() only ever sees the positionals.
| Syntax | Result |
|---|---|
--queue=emails | getOption('queue') returns 'emails' |
--queue emails | getOption('queue') returns 'emails' (the next token is the value when it doesn’t start with -) |
--queue at the end, or followed by another option | getOption('queue') returns 'true' |
--queue a --queue b | getOption('queue') returns 'b' (the last value); getOptionValues('queue') returns ['a', 'b'] |
-p=8000, -p 8000 | getOption('p') returns '8000' |
-d | getOption('d') returns 'true' |
-- | Ends option parsing --- every token after it is a positional argument, even if it starts with - |
Single-character names refer to short options (-d), longer names to long options (--detach). hasOption('d') does not match --detach, so check both when a command offers both forms.
Declaring Flags
Section titled “Declaring Flags”A bare option followed by a non-dash token takes that token as its value. For a value-less boolean flag that is wrong: in marko queue:retry --force 5, an undeclared --force would swallow 5. Declare boolean flags on the #[Command] attribute so they never consume the next token:
use Marko\Core\Attributes\Command;use Marko\Core\Command\CommandInterface;use Marko\Core\Command\Input;use Marko\Core\Command\Output;
#[Command(name: 'report:send', description: 'Send a report', flags: ['force', 'f'])]class SendReportCommand implements CommandInterface{ public function execute(Input $input, Output $output): int { $reportId = $input->getArgument(0); $force = $input->hasOption('force') || $input->hasOption('f'); $recipients = $input->getOptionValues('to');
// ...
return 0; }}With force declared, marko report:send --force 42 --to [email protected] --to [email protected] and marko report:send 42 --force [email protected] [email protected] behave the same: argument 0 is '42', force is set, and to has both addresses. Every option that is not declared as a flag is treated as a value option.
Declared flags are applied by the command runner, so they take effect when the command runs through marko or the CommandRunner. In unit tests that call execute() directly, pass the same flags to Input: new Input(['marko', 'report:send', '--force', '42'], ['force', 'f']).
--no-interaction is a global flag: every command accepts it without declaring it, and it never takes a value. It tells commands not to ask questions; see Asking for Confirmation.
Command Namespacing
Section titled “Command Namespacing”Group related commands with colons:
marko db:migratemarko db:rollbackmarko db:seedmarko cache:clearmarko cache:warmupHow It Works
Section titled “How It Works”- CLI searches upward for a Marko project (looks for
vendor/marko/core) - Loads the project’s autoloader
- Boots the application
- Runs the requested command
The CLI itself has no commands --- all commands come from modules in your project.
API Reference
Section titled “API Reference”CommandInterface
Section titled “CommandInterface”use Marko\Core\Command\CommandInterface;use Marko\Core\Command\Input;use Marko\Core\Command\Output;
interface CommandInterface{ public function execute(Input $input, Output $output): int;}use Marko\Core\Command\Input;
class Input{ /** * @param array<int, string> $arguments Raw argv (index 0 = script, index 1 = command) * @param list<string> $flags Option names that never take a value */ public function __construct(array $arguments, array $flags = []); public function withFlags(array $flags): self; public function getCommand(): ?string; public function getArgument(int $index): ?string; // Positional arguments only public function hasArgument(int $index): bool; public function getArguments(): array; // list<string> of positional arguments public function getOption(string $name): ?string; // Last value, 'true' for a bare flag, null if absent public function getOptionValues(string $name): array; // list<string> of every value given public function hasOption(string $name): bool; public function isInteractive(): bool; // false when --no-interaction is passed}Output
Section titled “Output”use Marko\Core\Command\Output;
class Output{ public function write(string $text): void; public function writeLine(string $text): void;}Command Attribute
Section titled “Command Attribute”use Marko\Core\Attributes\Command;
#[Command( name: 'namespace:name', description: 'What it does', aliases: ['alias'], // Optional alternative names flags: ['force', 'f'], // Optional value-less options that never consume the next token destructive: true, // Optional: the command changes or deletes stored state (default false))]Destructive Commands
Section titled “Destructive Commands”Mark a command that changes or deletes stored state (database rows or schema, cache entries, queued jobs, log files, sessions, tokens) with destructive: true:
use Marko\Core\Attributes\Command;
#[Command(name: 'report:purge', description: 'Delete old reports', flags: ['force'], destructive: true)]The marker is how callers that run commands on someone else’s behalf tell a state-changing command from a read-only one, without guessing from its name or options. The MCP run_console_command tool refuses every destructive command unless the server operator sets mcp.console.allow_destructive. The marker is copied onto the command’s CommandDefinition as $definition->destructive, so your own tooling can read it from the CommandRegistry.
The marker does not change how the command runs from a terminal. Asking for --force or a confirmation is still the command’s job: database commands use DestructiveCommandGuard, and any command can use ConfirmationPrompterInterface.
Every shipped command that changes or deletes stored state carries the marker: db:migrate, db:reset, db:rollback, db:rebuild, db:seed, cache:clear, page-cache:clear, page-cache:purge, queue:clear, log:clear, session:gc, auth:clear-tokens, discovery:clear, indexer:rebuild, admin-auth:permissions:sync and devai:install. A repository test fails when a shipped command named *:clear, *:reset, *:rollback, *:rebuild, *:fresh, *:seed, *:truncate, *:purge or *:clear-tokens, or one that uses DestructiveCommandGuard, is missing it.
Exceptions
Section titled “Exceptions”The CLI provides rich error messages with context and suggestions via CliException and its subclasses:
ProjectNotFoundException--- thrown when no Marko project is found in the current directory or any parent directory.CommandNotFoundException--- thrown when the requested command does not exist. Suggests runningmarko list.BootstrapException--- thrown when the application fails to boot.
All CLI exceptions extend CliException, which includes getContext() and getSuggestion() methods for helpful error output.