nunomaduro/zero-framework
Zero Framework is Nuno Maduro's minimalist, zero-configuration PHP framework for building console apps and small projects. It removes boilerplate and setup so you can focus on writing code, with sensible defaults and a tiny footprint.
Know what you're installing. laravel-zero/framework is the runtime core of a Laravel Zero console app — the Application bootstrapper, the base Command class, the component installers, and the app:build PHAR builder. It is not something you composer require into an existing Laravel web app. You consume it indirectly by scaffolding a project:
composer create-project laravel-zero/laravel-zero my-cli
cd my-cli
php my-cli list # the root executable *is* your app
The scaffold pins this package in composer.json; treat the repo as read-only reference (and the place to file framework bugs), not as code you vendor-patch.
First four files to open, in order:
| File | Why |
|---|---|
my-cli (root executable) |
Entry point; app:rename rewrites it |
bootstrap/app.php |
Bootstraps the container and kernel |
config/app.php |
providers array — where your service providers are registered |
config/commands.php |
Command discovery paths (default app/Commands) |
First use case — a real command in under five minutes:
php my-cli make:command ReportDaily
This writes app/Commands/ReportDailyCommand.php. Fill in the signature and handle():
namespace App\Commands;
use LaravelZero\Framework\Commands\Command;
class ReportDailyCommand extends Command
{
protected $signature = 'report:daily {date?} {--format=table}';
protected $description = 'Print the daily summary';
public function handle(): int
{
$this->info('Building report for '.($this->argument('date') ?? 'today'));
$this->table(['Metric', 'Value'], [['signups', 42], ['churn', 3]]);
return self::SUCCESS;
}
}
Run it with php my-cli report:daily. Commands in app/Commands are auto-discovered — no registration step.
Command anatomy. Prefer the $signature string over the older $name/$arguments properties; it's the only form that supports options, array arguments, and defaults in one place. Always declare handle(): int and return self::SUCCESS / self::FAILURE — exit codes are your contract with cron, CI, and shell scripts.
Input and output. Everything you'd expect from illuminate/console is available:
$name = $this->ask('Project name?', 'demo');
$force = $this->confirm('Overwrite?', false);
$env = $this->choice('Target', ['staging', 'prod'], 0);
$bar = $this->output->createProgressBar($total);
$bar->advance(); $bar->finish();
$this->components->task('Fetching', fn () => $this->fetch());
$this->line/info/comment/warn/error are the only output calls you need; verbosity is handled by -v/-vvv automatically.
Dependency injection. The container resolves handle() parameters, so type-hint services rather than resolving them manually:
public function handle(ReportBuilder $builder): int
Bind implementations in a provider registered inside config/app.php:
$this->app->singleton(ReportBuilder::class, fn ($app) => new ReportBuilder($app['config']->get('reports')));
Components are the framework's real day-to-day value. Add capability on demand instead of dragging in the full Laravel app:
php my-cli app:install database # Eloquent + migrations: php my-cli migrate
php my-cli app:install logging
php my-cli app:install schedule
php my-cli app:install dotenv
php my-cli app:install self-update
php my-cli app:install docker
Each installer edits composer.json, drops config files into config/, and scaffolds any needed provider/command class. Commit immediately after running one so you can see exactly what it changed.
Scheduling. After app:install schedule, define tasks in the schedule class the installer scaffolds. Because there's no long-running daemon, you drive it from cron:
* * * * * cd /path/to/app && php my-cli schedule:run >> /dev/null 2>&1
Testing. The scaffold ships with Pest; artisan() is the workhorse:
it('prints the report', function () {
$this->artisan('report:daily', ['date' => '2026-03-24'])
->expectsOutputToContain('signups')
->assertExitCode(0);
});
Prefer assertExitCode over output text matching where you can — output wording changes more often than behaviour.
Distribution. php my-cli app:build produces a PHAR in builds/ using Box. Pass --build-version=1.2.3 to stamp it. This is the artifact you hand to users; app:install self-update makes the binary update itself in place.
Config and env. Use config('x.y') everywhere in application code. env() is only reliable inside config/*.php, and only until config:cache is run.
The PHAR is read-only — this is the number one production surprise. When running from builds/my-cli, base_path() points inside the archive. Anything that writes — SQLite files, storage/logs, caches, downloads — must target a path outside it. Compute a writable home explicitly:
$home = getenv('HOME').'/.my-cli';
and point database.connections.sqlite.database and logging.channels.*.path there. Test the built PHAR, not just php my-cli — the two have different filesystem semantics.
Interactive prompts hang in CI and cron. $this->ask() on a non-TTY stdin blocks or throws. Guard with $this->input->isInteractive() or use $this->option('no-interaction'); always support a --no-interaction path for anything scheduled.
Commands outside app/Commands aren't discovered. If you namespace commands under src/ (common when extracting a package), add the path to config/commands.php under the commands key or register via $this->commands([...]) in a provider. Silent "command not found" is almost always this.
Config caching nulls your env vars. After config:cache, env() outside config files returns null. If a command works in dev and breaks after a cache, this is why. Run config:clear when debugging.
Keep illuminate/* versions in lockstep with the framework. Laravel Zero tracks Laravel majors; a mismatched illuminate/support produces confusing container or console failures that look like framework bugs. Let Composer resolve them together and don't hand-pin individual Illuminate packages.
Exceptions and exit codes. An uncaught exception yields a non-zero exit, but a caught-and-logged one doesn't — if your cron job is silently "succeeding" on failure, check that you return self::FAILURE in the catch block.
Long-running commands should trap signals so Ctrl-C and container SIGTERM exit cleanly:
$this->trap(SIGINT, fn () => $this->cleanup());
Component installers mutate your project. app:install touches composer.json, config/, and providers. In a dirty working tree you can't tell what it did — commit first, then review the diff.
Self-update only works in a PHAR. Running it from source either no-ops or errors; don't try to test the upgrade path with php my-cli.
Extension point of last resort: the Application class and kernel are extendable, but for 95% of needs a service provider plus a command is enough. Provider registration in config/app.php is the supported seam — reach for it before subclassing framework internals whose signatures may shift between majors.
How can I help you explore Laravel packages today?