Grav Plugin CLI — Console Commands, Task Scheduling and Cron
In this tutorial, you'll learn Grav plugin CLI development — creating console commands, scheduling automated tasks, integrating with system cron, building maintenance utilities, and extending the bin/grav command line tool.
What You'll Learn
- The Grav CLI architecture and
bin/gravcommands - Creating custom CLI commands in plugins
- Command arguments, options, and input/output
- Task scheduling and automation patterns
- Cron job integration with Grav
- Best practices for CLI plugin development
Why It Matters
In WordPress, WP-CLI provides a command-line interface for common tasks. In Grav, the bin/grav command and the GPM (Grav Package Manager) provide similar functionality. But you are not limited to built-in commands — plugins can register their own CLI commands. This is essential for automated maintenance, scheduled tasks (cache clearing, sitemap generation, backup), and operations that are easier from the command line than through a web interface.
Real-World Use
A backup plugin needs to run a database backup every night at 2 AM. Rather than relying on a web request (which can time out), the plugin registers a bin/grav backup:run CLI command. A system cron job calls this command daily. The command creates a compressed backup, uploads it to S3, and notifies the admin by email — all without involving a web server.
Learning Path
flowchart LR
A["Plugin Admin"] --> B["Plugin CLI
← You are here"]:::current
B --> C["Multilingual"]
C --> D["User Management"]
D --> E["Media Handling"]
E --> F["Grav API"]
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
CLI Architecture
Grav's CLI uses the Symfony Console component. Commands are registered in plugin Bootstrap files and become available through bin/grav.
Built-in Commands
| Command | What It Does |
|---|---|
bin/grav server |
Start dev server |
bin/grav cache |
Clear all caches |
bin/grav install |
Install dependencies |
bin/grav new-admin-user |
Create admin user |
bin/gpm install |
Install plugins/themes |
bin/gpm update |
Update plugins/themes |
Creating a CLI Command
Step 1: Create the Command Class
user/plugins/backup/cli/BackupRunCommand.php:
<?php
namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class BackupRunCommand extends ConsoleCommand
{
protected function configure()
{
$this
->setName('backup:run')
->setDescription('Create a full site backup')
->setHelp('Creates a compressed backup of pages, config, and plugins')
->addOption(
'include-uploads',
'u',
InputOption::VALUE_NONE,
'Include upload files in backup'
)
->addOption(
'output',
'o',
InputOption::VALUE_REQUIRED,
'Output directory',
'user/data/backups'
)
->addArgument(
'name',
InputArgument::OPTIONAL,
'Backup filename (without extension)'
);
}
protected function serve(InputInterface $input, OutputInterface $output)
{
$this->setupConsole($input, $output);
$includeUploads = $input->getOption('include-uploads');
$outputDir = $input->getOption('output');
$name = $input->getArgument('name') ?: 'backup-' . date('Y-m-d-His');
$io = $this->getIO();
$io->title('Running Backup');
$io->writeln('Starting backup: ' . $name);
$io->writeln('');
// Confirm if include-uploads is not set
if (!$includeUploads) {
$question = new ConfirmationQuestion(
'Include upload files? [y/N] ',
false
);
$includeUploads = $io->askQuestion($question);
}
// Progress indicator
$io->section('Creating backup...');
$io->progressStart(3);
// Step 1: Backup pages
$io->progressAdvance();
$this->backupDirectory('pages', $outputDir, $name);
// Step 2: Backup config
$io->progressAdvance();
$this->backupDirectory('config', $outputDir, $name);
// Step 3: Compress
$io->progressAdvance();
$archiveFile = $this->compress($outputDir, $name);
$io->progressFinish();
$io->success([
'Backup completed successfully!',
'File: ' . $archiveFile,
'Size: ' . $this->formatBytes(filesize($archiveFile)),
]);
}
private function backupDirectory($dir, $outputDir, $name)
{
$source = GRAV_ROOT . '/user/' . $dir;
$dest = $outputDir . '/' . $name . '/' . $dir;
$this->copyRecursive($source, $dest);
}
private function compress($outputDir, $name)
{
$source = $outputDir . '/' . $name;
$archive = $outputDir . '/' . $name . '.tar.gz';
exec("tar -czf $archive -C $outputDir $name 2>&1", $output, $code);
// Cleanup uncompressed files
exec("rm -rf $source");
return $archive;
}
private function copyRecursive($source, $dest)
{
if (!is_dir($dest)) {
mkdir($dest, 0755, true);
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
$target = $dest . '/' . $iterator->getSubPathName();
if ($item->isDir()) {
mkdir($target, 0755, true);
} else {
copy($item, $target);
}
}
}
private function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < 4) {
$bytes /= 1024;
$i++;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
}
Step 2: Register the Command
user/plugins/backup/backup.php:
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
class BackupPlugin extends Plugin
{
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
'onCliInit' => ['onCliInit', 0],
];
}
public function onCliInit()
{
$this->grav['cli']->addCommand(
new \Grav\Plugin\Console\BackupRunCommand()
);
}
public function onPluginsInitialized()
{
// Web-accessible functionality
}
}
Step 3: Run the Command
bin/grav backup:run
bin/grav backup:run --include-uploads
bin/grav backup:run --output=/tmp/backups --include-uploads
bin/grav backup:run "pre-update-backup"
Command Input Types
protected function configure()
{
$this
// String argument (required or optional)
->addArgument('name', InputArgument::REQUIRED, 'Backup name')
// Array argument (accepts multiple values)
->addArgument('files', InputArgument::IS_ARRAY, 'Files to process')
// Boolean option (flag)
->addOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose output')
// Value option with default
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format', 'json')
// Optional value option
->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Config file')
;
}
Interactive Commands
protected function serve(InputInterface $input, OutputInterface $output)
{
$this->setupConsole($input, $output);
$io = $this->getIO();
// Ask for input
$name = $io->ask('Enter site name', 'My Grav Site');
$email = $io->ask('Admin email');
$confirm = $io->confirm('Enable caching?', true);
// Choice
$format = $io->choice('Output format', ['json', 'yaml', 'csv'], 'json');
// Hidden input (password)
$password = $io->askHidden('Enter password');
}
Colored Output
$io->title('Backup Utility'); // Blue header
$io->section('Creating backup'); // Yellow section header
$io->writeln('Processing pages...'); // Normal text
$io->success('Backup complete!'); // Green success message
$io->warning('Low disk space'); // Yellow warning
$io->error('Backup failed'); // Red error
$io->note('This may take a while'); // Blue note
$io->caution('Cannot be undone'); // Red caution
// Progress bar
$io->progressStart(100);
for ($i = 0; $i < 100; $i++) {
$io->progressAdvance();
usleep(50000);
}
$io->progressFinish();
Task Scheduling
Create a scheduler in your plugin:
public function onCliInit()
{
$this->grav['cli']->addCommand(new BackupRunCommand());
$this->registerScheduledTasks();
}
private function registerScheduledTasks()
{
$scheduler = $this->grav['scheduler'] ?? null;
if ($scheduler) {
$scheduler->addTask('daily_backup', 'backup:run', [
'--include-uploads',
'daily-' . date('Y-m-d')
], '0 2 * * *'); // Every day at 2 AM
}
}
The cron expression 0 2 * * * means:
* * * * * (minute, hour, day of month, month, day of week)
0 2 * * * = At 02:00 every day
Cron Integration
Add to system crontab:
# Run Grav backup every night at 2 AM
0 2 * * * /usr/bin/php /var/www/grav/bin/grav backup:run --include-uploads >> /var/log/grav-backup.log 2>&1
# Clear cache every 6 hours
0 */6 * * * /usr/bin/php /var/www/grav/bin/grav cache:clear >> /var/log/grav-cache.log 2>&1
Learning Path
flowchart LR
A["Plugin Admin"] --> B["Plugin CLI
← You are here"]:::current
B --> C["Multilingual"]
C --> D["User Management"]
D --> E["Media Handling"]
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
Common Mistakes
Not extending the correct base class: CLI commands must extend
Grav\Console\ConsoleCommand, notSymfony\Component\Console\Command\Command. The Grav base class provides access to the Grav container.Forgetting to call
setupConsole(): This method initializes Grav services in the CLI context. Without it,$this->gravis not available and the command fails.Heavy operations blocking the CLI: Long-running commands should show progress. Use progress bars and write status messages to keep the user informed.
Not handling errors gracefully: CLI commands should catch exceptions and output user-friendly error messages. Uncaught exceptions dump stack traces that confuse non-technical users.
Commands not discoverable: Register commands in
onCliInitevent. Without it, the command does not appear inbin/grav listand cannot be executed.
Practice Questions
What event registers CLI commands in Grav? Answer: The
onCliInitevent. Inside the handler, call$this->grav['cli']->addCommand()to register each command.What base class should Grav CLI commands extend? Answer:
Grav\Console\ConsoleCommand. It extends Symfony's Command class and provides access to Grav services through$this->grav.How do you add a command-line option like
--verbose? Answer: In theconfigure()method, use$this->addOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose output'). Access it with$input->getOption('verbose').What is the purpose of cron expressions in task scheduling? Answer: Cron expressions define when a scheduled task runs.
0 2 * * *means "at 2:00 AM every day". They control the timing of automated maintenance tasks.Challenge: Build a complete CLI utility plugin with 3 commands: one to generate a sitemap (walks all pages and creates XML), one to check site health (validates page YAML, checks for broken links, reports performance metrics), and one to import content from a CSV file (creates pages from rows). Each command should have options for output format, progress bars for long operations, and proper error handling. Register all three commands and test them from the command line.
FAQ
Mini Project
Goal: Build a complete CLI utility plugin for site maintenance.
- Create a
CacheWarmCommandthat pre-caches all pages (walks every page, renders it, stores in cache) - Create a
HealthCheckCommandthat validates: PHP version, extensions, file permissions, writable directories, YAML syntax on all pages, and broken internal links - Create a
ReportCommandthat generates a site report with: page count, user count, plugin count, disk usage, last cache clear, and last backup - Add progress bars to all long-running commands
- Add colored output (success, warning, error) for results
- Add options for output format (table, JSON, CSV)
- Register all commands and test them
- Add error handling and edge case coverage
- Create a cron job configuration example
- Document the CLI commands for site administrators
What's Next
Now you can build CLI commands. Next, learn multilingual features:
Continue to Lesson 29: Multilingual — Language switcher, translations workflow, and fallback strategies.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro