nunomaduro/laravel-zero
Laravel Zero is a micro-framework for building elegant console applications in PHP. Powered by Laravel components, it offers Artisan commands, a familiar structure, and a lightweight footprint for fast, testable CLI tools.
Laravel Zero is a stripped-down Laravel distribution tuned for CLI applications: no HTTP kernel, no routing, no Blade by default — just the console kernel, the service container, config, and whichever Illuminate components you pull in.
Scaffold a project (don't composer require into an existing app; the skeleton is the app):
composer create-project laravel-zero/laravel-zero my-cli
cd my-cli
php my-cli list # the binary in the project root is your entry point
Where to look first, in order:
my-cli — the root PHP binary. It bootstraps bootstrap/app.php; this is your public/index.php equivalent.app/Commands/ — every command class lives here. This is 90% of your day-to-day editing.config/commands.php — controls command auto-discovery and the app:build output path/name.config/app.php — the providers array; register your own service providers here.app/Console/Kernel.php — where you register scheduled tasks.First use case — the canonical one: a command that hits an API, formats output, and can be shipped as a single binary.
// app/Commands/FetchUsers.php
namespace App\Commands;
use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command;
class FetchUsers extends Command
{
protected $signature = 'users:fetch {--limit=25 : Max records}';
protected $description = 'Fetch users from the API';
public function handle(): int
{
$users = Http::get('https://api.example.com/users', [
'limit' => (int) $this->option('limit'),
])->throw()->json();
$this->table(['ID', 'Name', 'Email'], collect($users)->map(fn ($u) => [
$u['id'], $u['name'], $u['email'],
])->all());
return self::SUCCESS;
}
}
Run it with php my-cli users:fetch --limit=5. No registration step needed — auto-discovery picks it up.
Command anatomy. Always extend LaravelZero\Framework\Commands\Command (it extends the Illuminate command and wires up the app instance). Return self::SUCCESS / self::FAILURE so exit codes are meaningful in shell pipelines and CI.
Constructor injection beats facades for anything you want to unit test. Container resolution works the same as Laravel:
public function __construct(private UserRepository $users) {
parent::__construct();
}
public function handle(): int { /* $this->users is ready */ }
Interactive workflows are the reason people pick this over Symfony Console. Chain prompts rather than flag-stuffing:
$name = $this->ask('Project name?');
$type = $this->choice('Type?', ['api', 'worker', 'web'], 0);
if ($this->confirm('Create the project?', true)) { /* ... */ }
Pair with $this->progressBar / $this->withProgressBar($items, $fn) for long loops, and $this->task('Label', fn () => ...) for step reporting. Use $this->components->info() / ->error() for modern Laravel output.
Long-running loops. Call $this->output->setVerbosity() carefully; for daemon-style commands use $this->trap() (SIGINT/SIGTERM) to shut down cleanly and sleep() between iterations.
Scheduling. Register in app/Console/Kernel.php exactly as in Laravel:
protected function schedule(Schedule $schedule): void
{
$schedule->command('users:fetch')->dailyAt('02:00')->appendOutputTo(storage_path('logs/fetch.log'));
}
Then drive it from cron: * * * * * cd /path/to/my-cli && php my-cli schedule:run >> /dev/null 2>&1.
Persistence. SQLite is the natural fit — ship the schema as migrations and run php my-cli migrate on install:
php my-cli make:migration create_users_table
# config/database.php: 'default' => 'sqlite', database => storage_path('app/database.sqlite')
php my-cli migrate --force
Eloquent, models, factories and seeders all work. Keep the DB file under storage/ — never inside the compiled PHAR.
Splitting logic from the command. Treat commands as thin controllers. Put business rules in plain classes under app/Services or app/Actions and inject them. This is what lets you test without $this->artisan().
Building a distributable. php my-cli app:build compiles the app into builds/my-cli (PHAR). Configure the name and entry point in config/commands.php:
'build' => [
'phar_name' => 'my-cli',
'version' => '1.0.0',
],
Ship that single file — no PHP install assumptions beyond the PHAR runtime.
Testing. Use $this->artisan('users:fetch')->assertExitCode(0) inside a Tests\TestCase extending the Laravel Zero test case, and Http::fake() for the network. For pure logic, test the injected service class directly.
routes/, no Http\Kernel. If you need a web UI, this is the wrong skeleton. Some people add one back manually; don't — use a full Laravel app.__DIR__, realpath(), and file_put_contents() relative to the binary will fail because the PHAR is read-only. Always resolve writable locations via storage_path() or base_path(), and remember base_path() inside a PHAR points into the archive. Configure storage outside the build or use $_SERVER['HOME']..env loading is subtle. Laravel Zero loads .env from the project root and also looks next to the binary. When distributing a PHAR, prefer --flag options or a config file in the user's home directory over expecting a .env.php my-cli list, run php my-cli clear-compiled (or delete bootstrap/cache/). Namespace/class-name mismatches in app/Commands are the usual cause.app:build needs a clean state. Clear config and compiled caches before building; a cached config baked into the PHAR will override the user's real one and produce very confusing bug reports. Consider config:cache off entirely for distributed builds.laravel-zero/framework to the version matching the Illuminate components you need, and check the upgrade guide between majors — config keys (config/app.php, config/commands.php) have moved across releases.Request, URL, Session) will throw or misbehave. Stick to DB, Http, Cache, Log, Config, Artisan.-vvv for the full exception trace, and --ansi when piping into files. For container problems, dump with $this->getApplication()->getLaravel() or register a tiny dump:container command.$this->error() does not set a non-zero exit code. Return self::FAILURE explicitly or your CI job will go green on failure.providers array in config/app.php for bootstrapping, config/commands.php's commands array to explicitly register commands outside app/Commands, the Commands\Kernel-level defaultCommands, and publishing framework config with php my-cli vendor:publish. Overriding a framework-provided command is a matter of registering your class after auto-discovery and giving it the same signature.How can I help you explore Laravel packages today?