Skip to content

Grav Plugin CLI — Console Commands, Task Scheduling and Cron

DodaTech Updated 2026-06-27 8 min read

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/grav commands
  • 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

  1. Not extending the correct base class: CLI commands must extend Grav\Console\ConsoleCommand, not Symfony\Component\Console\Command\Command. The Grav base class provides access to the Grav container.

  2. Forgetting to call setupConsole(): This method initializes Grav services in the CLI context. Without it, $this->grav is not available and the command fails.

  3. Heavy operations blocking the CLI: Long-running commands should show progress. Use progress bars and write status messages to keep the user informed.

  4. 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.

  5. Commands not discoverable: Register commands in onCliInit event. Without it, the command does not appear in bin/grav list and cannot be executed.

Practice Questions

  1. What event registers CLI commands in Grav? Answer: The onCliInit event. Inside the handler, call $this->grav['cli']->addCommand() to register each command.

  2. 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.

  3. How do you add a command-line option like --verbose? Answer: In the configure() method, use $this->addOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose output'). Access it with $input->getOption('verbose').

  4. 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.

  5. 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

Can I run CLI commands from a web request?

Yes, but it is not recommended. Use exec() or shell_exec() with caution. For web-triggered tasks, use the Scheduler plugin or a webhook endpoint instead.

How do I pass arguments to a CLI command?

Arguments are passed after the command name: bin/grav backup:run my-backup-name. Options use --option=value or --flag syntax.

Can I create interactive CLI commands?

Yes. Use the getIO() helper to ask questions, get hidden input, show choices, and confirm actions. This is useful for setup and configuration commands.

How do I list all available CLI commands?

Run bin/grav list to see all registered commands. Custom plugin commands appear alongside built-in commands.

What is the difference between `bin/grav` and `bin/gpm`?

bin/grav is the main CLI tool for site operations (server, cache, install). bin/gpm (Grav Package Manager) handles plugin and theme installation, updates, and removal.

Mini Project

Goal: Build a complete CLI utility plugin for site maintenance.

  1. Create a CacheWarmCommand that pre-caches all pages (walks every page, renders it, stores in cache)
  2. Create a HealthCheckCommand that validates: PHP version, extensions, file permissions, writable directories, YAML syntax on all pages, and broken internal links
  3. Create a ReportCommand that generates a site report with: page count, user count, plugin count, disk usage, last cache clear, and last backup
  4. Add progress bars to all long-running commands
  5. Add colored output (success, warning, error) for results
  6. Add options for output format (table, JSON, CSV)
  7. Register all commands and test them
  8. Add error handling and edge case coverage
  9. Create a cron job configuration example
  10. 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