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 workindex.phpwould have done. In a web request,public/index.phprequiresboot.phpand the container is ready by the time_ext.phpruns. CLI scripts do not pass through that path; the maintenance script has to invokeboot.phpitself beforeApp::container()is callable.- Return an exit code from the callable. Composer surfaces it
as the script's exit status;
0means 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 installis sufficient to make a scriptor-plugin package discoverable, including its scripts block - Composer scripts documentation upstream