
Why I chose Laravel Zero for a CLI tool - and what it taught me about framework selection
I needed to build a CLI tool that could orchestrate Docker containers, manage Git worktrees, allocate ports, and run a 9-stage pipeline in under a minute. I evaluated five options. The winner surprised me.
The Problem Statement
When I started building what would become our team's dev orchestrator - a local CI/CD pipeline that validates code before it leaves your machine - I had a clear set of requirements. The tool needed to manage Docker Compose services, create and clean up Git worktrees, dynamically allocate ports, render configuration files from templates, run database migrations, and execute test suites. It needed to be fast, reliable, and usable by developers who do not care about the tool's internals - they just want to push code.
I also knew it would grow. What starts as a script always becomes a system. I needed something that could handle complexity without becoming complex to use.
I spent a week evaluating options before writing a single line of code. That week was the best investment I made in the entire project.
The Candidates
I considered five approaches, each with genuine strengths:
Plain Bash. The obvious starting point. Every developer has Bash. No installation, no dependencies, no compilation. I have written hundreds of Bash scripts. For simple automation - start a server, run a test, deploy a file - Bash is unbeatable. But the orchestrator was not simple. It needed argument parsing, configuration management, error handling with rollback, parallel process management, and user-friendly output formatting. Bash can do all of that. It just does it in ways that make you question your life choices at 2 AM when a missing quote breaks everything.
Python with Click. Python's Click library is elegant for CLI tools. Declarative argument parsing, automatic help generation, nested command groups. The ecosystem is massive. But our entire team writes PHP. Introducing Python as a dependency for a developer tool means everyone needs Python installed, everyone needs to understand pip, and debugging means context-switching to a different language. For a team tool, the language should match the team.
Go. Compiles to a single binary. Fast. Cobra library for CLI scaffolding. No runtime dependencies. If I were building a tool for distribution to unknown environments, Go would be the answer. But I was building for my team - PHP developers on known machines. The compilation step adds friction to development. Every change requires a build cycle. For a tool I was iterating on daily, that friction adds up.
Node.js with Commander. JavaScript everywhere. Commander.js is mature. The npm ecosystem is vast. But Node CLI tools have a startup penalty that you feel on every invocation. Our pipeline runs 9 stages - the orchestrator is called repeatedly. A 200ms startup overhead per invocation adds up to noticeable lag. Also, node_modules for a CLI tool feels wrong in a way I cannot fully articulate but deeply believe.
Laravel Zero. A stripped-down Laravel specifically for CLI applications. Same Artisan command structure, same service container, same Eloquent (if you want it), same collection methods - but without HTTP routing, sessions, views, or anything web-specific. It compiles to a single Phar file. And every developer on the team already thinks in Laravel.
Why Laravel Zero Won
The decision came down to three factors that I think apply to any framework selection, not just CLI tools.
Factor 1: Team fluency. Every developer on our team writes Laravel daily. They understand Artisan commands, the service container, dependency injection, configuration files, and the testing framework. Choosing Laravel Zero meant zero learning curve for the team. When someone needs to add a new pipeline stage, they create an Artisan command - something they have done hundreds of times. When they need to test it, they use Pest - same as our main application.
This matters more than most developers think. A tool that the team cannot maintain is a tool with a bus factor of one. I have been that single point of failure before. It is not fun for anyone.
Factor 2: Structure without overhead. Laravel Zero gives you the Laravel skeleton without the web framework weight. You get the service container for dependency injection, configuration management via .env and config files, the Artisan command structure with argument parsing and output formatting, and the full testing framework. You do not get Blade, routing, middleware, sessions, or any HTTP concern.
For a CLI tool that needs to manage 9 stages with complex interdependencies, the service container alone justified the choice. Each stage is a class with injected dependencies. The container resolves the dependency graph. I never write new StageThree(new DockerCompose(new Config(...))). I write app(StageThree::class) and the framework handles the rest.
Factor 3: Phar compilation. Laravel Zero compiles to a single .phar file. One file, no dependencies, runs on any machine with PHP installed. Our developers already have PHP. They run ./orchestrator pipeline:run and everything works. No npm install, no pip install, no go build. Download the Phar, make it executable, done.
This is not a small thing. Developer tools live or die by installation friction. If it takes more than 30 seconds to set up, people will find excuses not to use it.
The Architecture That Emerged
Laravel Zero's structure naturally guided the architecture. Each pipeline stage became an Artisan command. The main pipeline command orchestrated the stages sequentially, each one receiving the output of the previous stage through a shared pipeline context object.
The nine stages:
- BranchValidation - verifies required branches exist
- PortAllocation - finds free ports for HTTP, database, SMTP, Vite
- WorktreeCreation - creates isolated Git worktrees on ext4
- BranchMerging - merges feature branches with conflict detection
- DockerServices - starts PostgreSQL, Mailpit, Redis via dynamic Compose files
- Configuration - renders .env and config from templates
- Build - runs composer install, npm install, asset compilation
- Database - imports dumps, runs migrations, seeds fixtures
- ServeAndTest - starts servers, executes test suite
Each stage is a standalone class that implements a PipelineStage interface with three methods: validate(), execute(), and rollback(). If stage 5 fails, stages 4 through 1 roll back in reverse order. Docker containers get stopped, worktrees get removed, ports get freed. No zombie processes, no orphaned containers.
This pattern - validate, execute, rollback - was trivial to implement because Laravel's service container handles the dependency wiring. Each stage declares what it needs in its constructor. The container provides it. The orchestrator calls stages in order. Clean, testable, maintainable.
The DX That Made It Stick
A CLI tool is only as good as its developer experience. Laravel Zero brings several DX features that I did not have to build myself:
Interactive prompts. Laravel's Prompts package works out of the box. When the orchestrator needs to ask which branches to merge, it presents a multi-select with search. When it needs confirmation before a destructive action, it shows a styled confirmation dialog. Not just "y/n" - actual interactive UI in the terminal.
Formatted output. Tables, progress bars, color-coded status messages - all built into the Artisan output system. When the pipeline runs, each stage shows a progress indicator, timing information, and a pass/fail status. Errors are red. Warnings are yellow. Success is green. It sounds trivial, but when you are staring at terminal output waiting for tests to pass, visual hierarchy matters.
Configuration management. The tool reads a YAML configuration file per project. Which branches to validate, which Docker services to start, which database dumps to import, which test suites to run. Laravel's config system handles all of this natively. config('pipeline.docker.services') returns an array. No custom YAML parsing, no config class, no boilerplate.
Testing. Every pipeline stage has tests written in Pest. I can test each stage in isolation by mocking its dependencies through the container. I can test the full pipeline with a fixture project. I can test error handling by simulating Docker failures. The testing story was one of the biggest reasons I chose a framework over raw PHP.
What I Wish I Had Known
Laravel Zero is not perfect. Here are the things that bit me:
Memory usage. The service container loads eagerly by default. For a CLI tool that runs and exits, this is fine. But the orchestrator runs a 9-stage pipeline that can take several minutes. Memory grew over the pipeline's lifetime because the container held references to completed stages. I had to manually unset references and call gc_collect_cycles() between stages. Not elegant, but effective.
Phar limitations. Phar files cannot write to themselves. This means auto-updates require downloading a new Phar and replacing the old one. I built a self-update command that checks a version endpoint and replaces the binary. It works, but it took more effort than expected.
Eloquent temptation. Laravel Zero includes Eloquent as an optional component. The temptation to use it for the orchestrator's internal state management was strong. I resisted. A CLI tool should not depend on a database for its own operation. I used flat JSON files for state and SQLite only for logging. Keeping the tool's dependencies minimal kept it reliable.
Startup time. The Phar file takes about 120ms to load. For a tool that is called once per push, this is imperceptible. But during development, when I was running the orchestrator dozens of times per hour, I noticed it. The compiled Phar is faster than running from source (which requires Composer autoloading), but it is not instant. Go would have been faster here. The tradeoff was acceptable.
The Broader Lesson About Framework Selection
After building tools in Bash, Python, Node, and PHP over the years, I have arrived at a framework selection philosophy that has nothing to do with benchmarks:
Pick what your team already knows. The best framework is the one your team can maintain when you are on vacation. This is not about being lazy or avoiding learning. It is about recognizing that a tool's value is measured over years, not the afternoon you build it. A beautifully engineered Rust CLI that nobody else on the team can modify is worse than a pedestrian PHP tool that anyone can fix.
Optimize for the thing you will do most. For the orchestrator, the most common action is adding a new pipeline stage. Laravel Zero makes that trivial - create a class, implement three methods, register it. If the most common action were processing gigabytes of log data, I would have chosen Go. If it were interactive terminal UI, I would have chosen Rust with Ratatui. Match the framework to the primary use case, not to your aspirations.
Evaluate the boring stuff. Every framework comparison focuses on features: routing, ORM, CLI parsing, async support. The boring stuff matters more: error messages, documentation quality, debugging experience, community size, upgrade path. Laravel Zero inherits all of this from Laravel - which means excellent documentation, a massive community, and error messages that actually tell you what went wrong.
Start with constraints, not features. I started my evaluation by listing what I could NOT accept: a runtime dependency the team does not have, a language the team does not write, a tool that requires more than 60 seconds to install, a framework that makes testing harder than raw PHP. Those constraints eliminated three of five options immediately. The feature comparison only mattered between the remaining two.
The Result
The dev orchestrator has been in daily use by our team for over six months. Every developer runs it on every push. It has executed thousands of pipeline runs. Three developers other than me have added stages, fixed bugs, or improved output formatting - all without asking me for help, because they already knew how Laravel commands work.
The Phar file is 4.2 MB. It installs in two seconds. It runs on every developer machine, the CI server, and in Docker containers. It has caught hundreds of issues before they reached the remote repository.
None of that would be different if I had chosen Go or Node or Rust. The pipeline logic would work in any language. But the adoption, the maintenance, the contributions from the team - those are directly attributable to choosing a framework that fit the team, not just the problem.
Framework selection is not a technical decision. It is a team decision. The sooner you accept that, the sooner your tools stop being your burden and start being the team's asset.
The best framework is not the one that performs best in benchmarks. It is the one that performs best when you are not the one maintaining it.
