mnapoli/silly
Silly is a micro-framework for building PHP command-line applications on top of Symfony Console. Commands are defined with simple callables and signatures like 'greet [name] [--yell]', while dependency injection, console helpers, and PHP-DI or Pimple integration are included.
Silly is the shortest path from "I keep running this script by hand" to a real console binary. For a Laravel developer the mental model is: Artisan, minus the framework, with the command class deleted.
Install and scaffold a binary (5 minutes):
composer require mnapoli/silly
mkdir -p bin
#!/usr/bin/env php
<?php
// bin/tool
require __DIR__.'/../vendor/autoload.php';
use Silly\Application;
use Symfony\Component\Console\Output\OutputInterface;
$app = new Application('mytool', '1.0.0');
$app->command('greet [name] [--yell]', function ($name, $yell, OutputInterface $output) {
$text = $name ? "Hello, $name" : 'Hello';
$output->writeln($yell ? strtoupper($text) : $text);
});
$app->run();
chmod +x bin/tool
./bin/tool greet john --yell
Where to look first — in this order:
docs/command-definition.md — the signature syntax ([name], [name=], [name=*], [--flag], [--opt=], [--opt=*]). This is the whole API surface you'll use daily.docs/command-callables.md — how parameters, options, services, InputInterface, OutputInterface, and SymfonyStyle get injected into the closure.docs/helpers.md — SymfonyStyle, tables, progress bars, runCommand().docs/dependency-injection.md — only when you outgrow plain closures.First real use case for a Laravel shop: a repo that isn't a Laravel app (a shared package, a provisioning repo, a data-import tool, a bin/ shipped with a library). Booting Laravel for a 200ms task is the smell that says "use this instead." Second use case: a throwaway workflow you want to validate as a CLI before deciding whether it deserves an Artisan command and a UI.
Thin command, thick service. Keep the closure to argument marshalling and IO; put the logic in injectable classes. This is the pattern that survives contact with code review.
$app->command('import [file] [--dry-run]', function (string $file, bool $dryRun, Importer $importer, SymfonyStyle $io) {
$result = $importer->run($file, $dryRun);
$io->success(sprintf('%d rows, %d skipped', $result->imported, $result->skipped));
return $result->hasErrors() ? 1 : 0; // exit code is honoured
});
Register services once, resolving by type-hint:
$container = new DI\Container(); // or any PSR-11 container
$app->useContainer($container);
Group commands across files. Silly has no module system; use a plain function per domain and call it from the binary:
// src/Commands/DbCommands.php
return function (Application $app): void {
$app->command('db:drop', ...);
$app->command('db:create', ...);
};
// bin/tool
(require __DIR__.'/../src/Commands/DbCommands.php')($app);
Compose commands. Inside a command, call siblings — this is how you build deploy out of pull/migrate/cache:clear without shelling out:
$app->command('init', function (OutputInterface $output) use ($app) {
$app->runCommand('db:drop --force', $output);
$app->runCommand('db:create', $output);
$app->runCommand('db:fixtures --verbose', $output);
});
Enrich the command object. command() returns a Symfony Command, so the fluent setters keep working:
$app->command('greet [name] [--age=]', $callable)
->descriptions('Say hello', ['--age' => 'Years of the person'])
->defaults(['age' => 25])
->setAliases(['hi']);
Defaults can also be inferred from the PHP signature: function ($name = 'John', $age = 25). Prefer this — one source of truth.
Single-purpose binaries. $app->setDefaultCommand('greet', true) (the true makes it run without a subcommand) turns bin/tool john into the whole interface.
Testing. Because Silly\Application extends Symfony\Console\Application, the Symfony testers work unchanged:
$app = new Application();
$app->command(...);
$app->setAutoExit(false);
$tester = new ApplicationTester($app);
$tester->run(['command' => 'import', 'file' => 'x.csv', '--dry-run' => true]);
$this->assertSame(0, $tester->getStatusCode());
$this->assertStringContainsString('0 rows', $tester->getDisplay());
Distribution. Add "bin": ["bin/tool"] to composer.json so consumers get a vendor/bin/tool.
Interop with a Laravel app. Two clean shapes: (a) Silly binary that does not boot Laravel, talking to the app over HTTP/DB/queue; (b) Silly binary that boots the framework deliberately and then reuses Eloquent/services — acceptable for ops tooling where connection config must come from .env. Do not install Silly purely to replace Artisan inside an app; that's what Laravel Zero is for.
$app->command('greet', ['MyClass', 'foo']) raises an exception by design. Use [new MyClass, 'foo'] or let the container build it.[--dry-run] binds to $dryRun, [number-of-clicks] to $numberOfClicks. Silently getting null almost always means a name mismatch here.--option=?) were removed as unusable. Model "flag or value" as two options, or a required option with a default via ->defaults().= in the signature. [--age] is a boolean flag; [--age=] takes a value; [--tag=*] takes an array. This trips up everyone once.$this inside closures. Capture $app explicitly with use ($app); the binding is not something to design around.$app->run() inside the binary, not in an includable file. Requiring a file that calls run() from a test or another script is the classic first-day bug. Symfony's --no-interaction, -v/-vvv, -q, --ansi all come free — use $input->isInteractive() before prompting, or CI will hang.return 1; from the closure propagates. With setAutoExit(false) you own the exit(); with the default, Symfony calls it for you.$app->setCatchExceptions(false) before run() to get raw stack traces instead of the pretty error box; -vvv for verbosity-gated logging.symfony/console 8, 1.9 supports 7; a Laravel app pins its own console version. Before adding Silly to an existing app run composer why symfony/console — a bump here can affect Artisan's behaviour, and vice versa. Standalone binaries are immune..env, no helpers. Silly gives you container injection and console IO; it deliberately gives you nothing for config, logging, or dotenv. Bring vlucas/phpdotenv and your own config/ loader, or accept env vars only.$this->argument(), $this->info(), app(), config(). You get plain typed parameters, SymfonyStyle, and whatever the container resolves.HelperSet entries, $app->add(new MyCommand()) for heavy commands, overriding getDefaultCommands() to strip list/help from a shipped binary. Migrating a closure to a Command class is localised and requires no changes elsewhere.useContainer() (DI), setDefaultCommand($name, true) (single-command UX), and returning ints (CI-friendliness). Everything else you'll need is Symfony's, which is the point — the package is 900 stars precisely because it stays small and delegates.How can I help you explore Laravel packages today?