Skip to content

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.

Terminal window
composer global require marko/cli

Ensure Composer’s global bin directory is in your PATH.

From any directory within a Marko project:

Terminal window
marko list # Show all available commands
marko module:list # List installed modules
marko discovery:cache # Compile the discovery cache (run on deploy)
marko discovery:clear # Remove the discovery cache
marko cache:clear # Clear cache (if cache module installed)
marko db:migrate # Run migrations (if database module installed)

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 PhpUnused annotation suppresses this false positive.

Run it:

Terminal window
marko greet
# Hello, World!
marko greet Mark
# Hello, Mark!

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.

SyntaxResult
--queue=emailsgetOption('queue') returns 'emails'
--queue emailsgetOption('queue') returns 'emails' (the next token is the value when it doesn’t start with -)
--queue at the end, or followed by another optiongetOption('queue') returns 'true'
--queue a --queue bgetOption('queue') returns 'b' (the last value); getOptionValues('queue') returns ['a', 'b']
-p=8000, -p 8000getOption('p') returns '8000'
-dgetOption('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.

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.

Group related commands with colons:

Terminal window
marko db:migrate
marko db:rollback
marko db:seed
marko cache:clear
marko cache:warmup
  1. CLI searches upward for a Marko project (looks for vendor/marko/core)
  2. Loads the project’s autoloader
  3. Boots the application
  4. Runs the requested command

The CLI itself has no commands --- all commands come from modules in your project.

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
}
use Marko\Core\Command\Output;
class Output
{
public function write(string $text): void;
public function writeLine(string $text): void;
}
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)
)]

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.

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 running marko list.
  • BootstrapException --- thrown when the application fails to boot.

All CLI exceptions extend CliException, which includes getContext() and getSuggestion() methods for helpful error output.