Problem

A plugin (or the site itself) has a maintenance task that should run on demand from the command line: clear a cache, run a one-off migration, dump a sanity report. You want composer my-task to be the invocation; nothing about installing a separate console binary.

Recipe

Composer's scripts block runs an arbitrary PHP callable when the user runs composer <name>. The callable is a fully qualified Class::method reference; Composer loads it through the Composer-managed autoloader (so the class can live anywhere your composer.json autoload covers).

In the plugin's (or site's) composer.json:

{
    "name": "acme/maintenance",
    "type": "scriptor-plugin",
    "autoload": { "psr-4": { "Acme\\Maintenance\\": "src/" } },
    "extra": { "scriptor": { "plugin": "Acme\\Maintenance\\Plugin" } },
    "scripts": {
        "cache-clear": "Acme\\Maintenance\\Cli::cacheClear",
        "sanity-report": "Acme\\Maintenance\\Cli::sanityReport"
    }
}

Implement the callable as a static method on a small Cli class:

namespace Acme\Maintenance;

use Scriptor\Boot\App;
use Scriptor\Boot\Frontend\PageRepository;

final class Cli
{
    public static function cacheClear(): int
    {
        self::bootScriptor();

        $cache = App::container()->get(MyCacheService::class);
        $count = $cache->clearAll();

        fwrite(\STDOUT, "Cleared {$count} entries.\n");
        return 0;
    }

    public static function sanityReport(): int
    {
        self::bootScriptor();

        $pages = App::container()->get(PageRepository::class)->findAll();
        fwrite(\STDOUT, sprintf("Pages: %d total\n", count($pages)));
        foreach ($pages as $p) {
            if (! $p->active()) {
                fwrite(\STDOUT, "  inactive: {$p->name} ({$p->slug})\n");
            }
        }
        return 0;
    }

    private static function bootScriptor(): void
    {
        if (\PHP_SAPI !== 'cli') {
            fwrite(\STDERR, "Maintenance scripts only run from the CLI.\n");
            exit(3);
        }
        require_once getcwd() . '/boot.php';
    }
}

Run it from the project root:

$ composer cache-clear
> Acme\Maintenance\Cli::cacheClear
Cleared 42 entries.

$ composer sanity-report
> Acme\Maintenance\Cli::sanityReport
Pages: 17 total
  inactive: Draft post (draft-post)

Three pieces worth flagging:

  • The SAPI guard belongs in your script. Composer scripts generally run from CLI, but a future scenario (a build tool that proxies composer through a web hook) could expose them over PHP-FPM. The two-line \PHP_SAPI !== 'cli' guard is cheap insurance.
  • bootScriptor() does the work index.php would have done. In a web request, public/index.php requires boot.php and the container is ready by the time _ext.php runs. CLI scripts do not pass through that path; the maintenance script has to invoke boot.php itself before App::container() is callable.
  • Return an exit code from the callable. Composer surfaces it as the script's exit status; 0 means success, anything else means failure. Build pipelines (CI, deploy hooks) read it.

Variants

Standalone bin script instead of composer scripts

For users who want to run the task without composer (cron, a deploy script), ship a bin/my-task executable alongside the composer entry:

#!/usr/bin/env php
<?php
// bin/cache-clear

declare(strict_types=1);

if (\PHP_SAPI !== 'cli') {
    fwrite(\STDERR, "bin/cache-clear only runs from the command line.\n");
    exit(3);
}

require dirname(__DIR__) . '/vendor/autoload.php';

exit(\Acme\Maintenance\Cli::cacheClear());

Make it executable (chmod +x bin/cache-clear); cron + deploy scripts can call it as /path/to/site/bin/cache-clear. The composer cache-clear shortcut still works because both paths go through the same Cli::cacheClear method.

Arguments forwarded from composer

Composer forwards everything after -- to the script's $_SERVER['argv']:

$ composer cache-clear -- --dry-run

Read them inside the callable:

public static function cacheClear(): int
{
    self::bootScriptor();
    $dry = in_array('--dry-run', $_SERVER['argv'] ?? [], strict: true);

    // ... clear or pretend to clear
}

For non-trivial argument parsing the symfony/console component becomes worth its weight; until then a plain in_array check is enough.

Cron pattern

Compose the binary path with a no-op-on-failure cron line:

*/15 * * * * cd /path/to/site && composer cache-clear >/dev/null 2>&1 || true

The || true keeps cron from sending an email on a transient composer hiccup (lock file conflict, network timeout fetching the classmap). For real failures you want to know about, route the output to a log file and watch it from monitoring instead.

See also

  • App: App::container() is the locator the CLI script reaches for; the second of two legitimate uses documented on that page (the first is a theme's _ext.php)
  • PageRepository: the sanity-report example walks the page tree through it
  • Concept: Plugin Discovery: for why composer install is sufficient to make a scriptor-plugin package discoverable, including its scripts block
  • Composer scripts documentation upstream