Evaluating Heroku alternatives? Start here

Blog

Building polished CLI tools with MiniCLI v5

Learn how MiniCLI v5 transforms PHP scripts into professional CLI products with zero dependencies and instant boot times.

·by Steve McDougall

If you've spent any time in the PHP ecosystem, you've probably written that script. You know the one. It lives in a scripts/ directory, it takes arguments via $argv[1], and the only documentation is a comment that says "run this on Fridays." It works, technically, but you wouldn't call it a product.

PHP has always had a dual identity. In the browser, it powers everything from Wikipedia to Slack. In the terminal, though, it's historically been the language of duct tape and prayer. That's starting to change, and MiniCLI v5 is a big part of why.

The DX era has reached the terminal

If you've used tools like gh, stripe-cli, or kubectl, you know what good CLI DX feels like. These tools are responsive, discoverable, and visually coherent. They feel like products, not scripts.

Running gh pr create and getting an interactive prompt with help text and contextual feedback is a fundamentally different experience from running php scripts/create_pr.php repo main.

The question is: why should PHP developers settle for anything less?

MiniCLI has been around for a while as a minimalist, zero-dependency framework for building CLI tools in PHP. Version 5 takes that foundation and pushes it firmly into the "product" category by embracing PHP 8.2+ features and refining the architecture to treat the terminal as a first-class environment.

Why not Symfony Console or Laravel Zero?

This is the obvious question, and it deserves a direct answer. Symfony Console is excellent. If you're building a complex CLI tool that lives inside a larger Symfony application, it's the right choice. Laravel Zero is similarly great if you want the full Laravel ecosystem in a CLI context.

But both of those options come with a cost. Booting a tool with 40 chained dependencies and a 50MB vendor folder just to check a service status is overkill. More importantly, it feels slow, and in CLI tools, perceived speed matters enormously. A tool that responds in 10ms feels native. One that takes 200ms feels heavy, even if it's technically fast enough.

MiniCLI v5 is built around a zero-dependency philosophy. It uses native PHP to solve problems that other frameworks reach for libraries to handle. The result is something that boots almost instantly and has a tiny footprint, while still giving you the structure of a real framework.

The architecture: Your directory is your API

One of the most elegant ideas in MiniCLI is that your directory structure becomes your command map. There's no registration array to maintain, no router class to configure. The framework reads your app/Command folder and derives the available commands from the structure itself.

So if you have app/Command/User/CreateController.php, your users run ./tool user create. If you have app/Command/Db/MigrateController.php, they run ./tool db migrate. It's the same intuition that made filesystem-based routing click in Next.js, applied to the terminal.

This matters for onboarding. When a new developer joins your team and wants to understand what your internal CLI tool does, they don't need to read a 2,000-line entry point. They look at the Command folder and the structure tells the story.

Building something real: A system monitor CLI

Let's put this into practice. We'll build a small tool called devtool that checks the status of named services. It's simple enough to follow but realistic enough to demonstrate the patterns that matter.

Project structure

devtool/
├── app/
│   ├── Command/
│   │   └── System/
│   │       └── CheckController.php
│   └── Service/
│       └── SystemMonitor.php
├── bin/
│   └── devtool
└── composer.json

The manifest

{
  "name": "yourname/devtool",
  "description": "A polished CLI product built with MiniCLI v5",
  "require": {
    "php": ">=8.2",
    "minicli/minicli": "dev-v5"
  },
  "autoload": {
    "psr-4": {
      "App\\": "app/"
    }
  }
}

Run composer install and you're ready to start building.

The entry point

The bin/devtool file is the gateway into your application. This is where you configure the app, register services, and hand off to the router.

#!/usr/bin/env php
<?php
 
if (php_sapi_name() !== 'cli') {
    exit;
}
 
require __DIR__ . '/../vendor/autoload.php';
 
use Minicli\App;
use App\Service\SystemMonitor;
 
$app = new App([
    'app_path' => __DIR__ . '/../app/Command',
    'theme'    => 'dracula',
    'debug'    => true,
]);
 
$app->addService('monitor', new SystemMonitor());
 
$app->setSignature("
   ___            ______            __
  / _ \\___ _   __/_  __/___  ____  / /
 / // / -_) | / / / / / __ \\/ __ \\/ /
/____/\\__/| |/ / /_/  \\____/____/_/
          |___/  v1.0 - System Monitor
");
 
$app->runCommand($argv);

Two things worth calling out here. First, the theme key. MiniCLI v5 ships with themes like Dracula and Unicorn out of the box, and this single line gives your tool visual coherence without any custom styling work. Second, setSignature lets you display an ASCII logo on startup, which sounds cosmetic but genuinely affects how a tool feels to use. Branding matters even in the terminal.

Don't forget to make the entry point executable: chmod +x bin/devtool.

The service layer

The service layer is where MiniCLI's architecture pays off most clearly. Rather than instantiating your API clients or business logic directly inside a controller, you encapsulate them as services that get registered once and are available everywhere.

<?php
 
namespace App\Service;
 
use Minicli\ServiceInterface;
 
class SystemMonitor implements ServiceInterface
{
    public function load($app): void
    {
        // Boot logic goes here if needed
    }
 
    public function getStatus(string $serviceName): array
    {
        usleep(300000); // Simulate a real network call
 
        $status = rand(0, 1) ? 'ONLINE' : 'OFFLINE';
 
        return [
            'service'    => ucfirst($serviceName),
            'status'     => $status,
            'latency'    => rand(20, 150) . 'ms',
            'checked_at' => date('Y-m-d H:i:s'),
        ];
    }
}

Services are lazily loaded, meaning they don't initialise until you actually call them. This keeps your tool fast even when you have several services registered. Swapping implementations is also straightforward: if your status check moves from a mock to a real API, you change the service class and nothing else needs to know about it.

The controller

This is where everything comes together. The controller handles user input, interacts with the service layer, and formats the output.

<?php
 
namespace App\Command\System;
 
use Minicli\Command\CommandController;
 
class CheckController extends CommandController
{
    public function handle(): void
    {
        $printer     = $this->getPrinter();
        $serviceName = $this->getParam('name');
 
        if (!$serviceName) {
            $printer->error("You must provide a service name.");
            $printer->info("Usage: ./bin/devtool system check name=database");
            return;
        }
 
        $printer->display("Checking status for: {$serviceName}...");
 
        $monitor = $this->getApp()->monitor;
        $data    = $monitor->getStatus($serviceName);
 
        $printer->newline();
 
        if ($data['status'] === 'ONLINE') {
            $printer->success(" {$data['service']} is OPERATIONAL ");
        } else {
            $printer->error(" {$data['service']} is DOWN ");
        }
 
        $printer->newline();
 
        $printer->printTable([
            ['Property',  'Value'],
            ['Latency',   $data['latency']],
            ['Timestamp', $data['checked_at']],
        ]);
 
        $printer->newline();
    }
}

Notice the structure here. Input validation happens first, with clear error messages and usage hints. Then comes the feedback line so the user knows the tool is working. Then the service call. Then the styled output. This sequence is deliberate, and it's what separates a tool that feels polished from one that just dumps output and exits.

Running it

./bin/devtool system check name=redis

You'll see the ASCII signature, the status check message, and then a formatted table with the results. It's a small thing, but it feels like software that someone cared about.

Distribution: The single-binary finish line

One of the underrated advantages of building on MiniCLI is how well it packages. Using a tool like Box or Phar-Composer, you can compile your entire application, including its vendor directory, into a single .phar file.

The end user experience becomes:

curl -O https://your-site.com/devtool.phar
chmod +x devtool.phar
./devtool.phar system check name=mysql

No composer install. No PHP version conflicts. No cloning a repository. Just download and run. That's the product experience.

The shift worth making

The "script vs product" distinction isn't about complexity. You can have a very simple product, and you can have a very complex script. The distinction is about intent. When you treat something as a script, you accept that it's fragile, undiscoverable, and hard to hand off. When you treat it as a product, you build in the structure and feedback loops that make it actually usable by someone other than the person who wrote it.

MiniCLI v5 makes that product mindset accessible in PHP without the overhead of bringing in a full framework. The zero-dependency approach keeps things fast, the directory-based routing keeps things organised, and the typed architecture keeps things maintainable as tools grow.

If you've been writing PHP scripts for internal tooling and accepting the mess that usually comes with it, v5 is worth a proper look. Your team, and your future self, will notice the difference.

Deep dive into the cloud!

Stake your claim on the Interwebz today with Sevalla's platform!
Deploy your application, database, or static site in minutes.

Get started