Budowa narzędzia CLI w PHP

PHP kojarzy się głównie z aplikacjami webowymi, ale bardzo dobrze sprawdza się też w narzędziach uruchamianych z terminala. CLI tool w PHP jest prosty do napisania, łatwy do wdrożenia i wygodny tam, gdzie potrzebujesz automatyzacji, przetwarzania plików, integracji z API albo szybkiego narzędzia dla zespołu. W praktyce taki program może robić wszystko: od porządkowania logów, przez generowanie raportów, po uruchamianie zadań administracyjnych. Jeśli dobrze go zaprojektujesz, stanie się małym, ale bardzo użytecznym elementem workflow.

Dlaczego PHP do CLI

Największą zaletą PHP w CLI jest niski próg wejścia. Jeśli zespół zna ten język, może szybko zbudować narzędzie bez dokładania nowego stacku. To szczególnie sensowne w środowiskach, gdzie backend i automatyzacja żyją blisko siebie. Drugą zaletą jest prosty model dystrybucji. Skrypt PHP można uruchomić niemal wszędzie, a przy użyciu Composera łatwo zorganizować zależności, autoloading i wersjonowanie. Dla wielu wewnętrznych narzędzi to wystarczy w zupełności.

Minimalna struktura projektu

Dobry CLI tool nie powinien być zbiorem przypadkowych plików. Już na starcie warto rozdzielić punkt wejścia, logikę biznesową i warstwę infrastrukturalną. Przykładowa struktura:
my-tool/
├── bin/
│   └── my-tool
├── src/
│   ├── Command/
│   ├── Service/
│   └── Infrastructure/
├── vendor/
├── composer.json
└── .env
Punkt wejścia w bin/my-tool powinien być cienki. Jego zadaniem jest uruchomienie aplikacji, a nie zawieranie całej logiki.

Punkt wejścia

Najprostszy start wygląda tak:
#!/usr/bin/env php
<?php

require __DIR__ . '/../vendor/autoload.php';

echo "Hello from CLI tool" . PHP_EOL;
Po nadaniu plikowi prawa do wykonania możesz uruchamiać go bezpośrednio z terminala. To rozwiązanie jest banalne, ale stanowi dobrą bazę pod dalszą rozbudowę.
chmod +x bin/my-tool
./bin/my-tool

Argumenty i opcje

W narzędziach CLI najważniejsza jest umiejętność przyjmowania argumentów. PHP daje do tego kilka prostych mechanizmów, a dla małych narzędzi często wystarczy globalna tablica $argv.
#!/usr/bin/env php
<?php

require __DIR__ . '/../vendor/autoload.php';

$args = $argv;
array_shift($args);

$command = $args[0] ?? null;

if ($command === null) {
    fwrite(STDERR, "Brak komendy.\n");
    exit(1);
}

echo "Uruchomiono komendę: {$command}" . PHP_EOL;
W bardziej rozwiniętej wersji warto parsować opcje nazwane, np. --file=... albo --dry-run. Do małego narzędzia można to zrobić ręcznie, ale przy większej liczbie opcji lepiej użyć gotowej biblioteki. Przykład własnego parsowania:
$options = getopt('', ['file:', 'dry-run']);

$file = $options['file'] ?? null;
$dryRun = array_key_exists('dry-run', $options);

if ($file === null) {
    fwrite(STDERR, "Musisz podać --file.\n");
    exit(1);
}

Komendy jako osobne klasy

Jeśli narzędzie ma więcej niż jedną akcję, nie trzymaj wszystkiego w jednym pliku. Lepszym podejściem jest potraktowanie każdej komendy jako osobnej klasy.
namespace App\Command;

final class ImportCommand
{
    public function run(string $file, bool $dryRun = false): int
    {
        if (!file_exists($file)) {
            fwrite(STDERR, "Plik nie istnieje.\n");
            return 1;
        }

        if ($dryRun) {
            echo "Tryb testowy: import z {$file}\n";
            return 0;
        }

        echo "Importuję dane z {$file}\n";
        return 0;
    }
}
Taki układ ułatwia testowanie i rozwój. Każda komenda ma własną odpowiedzialność, a logika nie miesza się z obsługą wejścia i wyjścia.

Warstwa usług

W CLI toolu warto oddzielić interfejs użytkownika od logiki działania. Dzięki temu komenda tylko odbiera argumenty i wywołuje serwis, a cała właściwa praca ląduje w warstwie usług.
namespace App\Service;

final class ReportGenerator
{
    public function generate(array $rows): string
    {
        $lines = ["id,name,email"];

        foreach ($rows as $row) {
            $lines[] = implode(',', [
                $row['id'],
                $row['name'],
                $row['email'],
            ]);
        }

        return implode(PHP_EOL, $lines) . PHP_EOL;
    }
}
To podejście sprawia, że kod staje się bardziej przewidywalny. Komenda może wyświetlić błąd, serwis wykonuje pracę, a testy jednostkowe obejmują głównie logikę biznesową.

Odczyt i zapis plików

Jednym z najczęstszych zastosowań CLI w PHP jest praca na plikach. Warto robić to strumieniowo, zwłaszcza jeśli pliki mogą być duże.
$input = fopen($file, 'r');

if ($input === false) {
    fwrite(STDERR, "Nie można otworzyć pliku.\n");
    exit(1);
}

while (($line = fgets($input)) !== false) {
    echo trim($line) . PHP_EOL;
}

fclose($input);
Przy zapisie podobnie:
$output = fopen('output.txt', 'w');

if ($output === false) {
    fwrite(STDERR, "Nie można utworzyć pliku wyjściowego.\n");
    exit(1);
}

fwrite($output, "Gotowe\n");
fclose($output);
Jeśli narzędzie ma działać na dużych danych, unikaj wczytywania całych plików do pamięci. CLI często kusi prostotą, ale właśnie dlatego łatwo nie zauważyć problemów ze skalą.

Logowanie i błędy

Narzędzie terminalowe musi być czytelne. Użytkownik powinien od razu widzieć, co się dzieje, a w razie błędu dostać komunikat, który coś znaczy. Dobrym standardem jest:
  • STDOUT dla zwykłych informacji.
  • STDERR dla błędów.
  • kody wyjścia do sygnalizowania sukcesu lub porażki.
fwrite(STDOUT, "Start procesu...\n");
fwrite(STDERR, "Wystąpił błąd.\n");

exit(0);
W większych narzędziach warto dodać poziomy logowania, na przykład info, warn, error, a także tryb -v lub --verbose.

Integracja z Composerem

Composer bardzo dobrze pasuje do CLI tooli. Dzięki niemu można ustawić autoloading klas i zarządzać zależnościami bez ręcznego ładowania plików. composer.json może wyglądać tak:
{
  "name": "acme/my-tool",
  "require": {
    "php": "^8.2"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "bin": [
    "bin/my-tool"
  ]
}
Po zmianie autoloadingu trzeba wykonać:
composer dump-autoload
To niewielki krok, ale znacząco poprawia organizację projektu i ułatwia dalszy rozwój.

Przykład pełnego przepływu

Poniżej prosty, ale realistyczny przykład: komenda importuje plik CSV i wypisuje liczbę przetworzonych rekordów.
namespace App\Command;

final class ImportCsvCommand
{
    public function run(string $file): int
    {
        if (!file_exists($file)) {
            fwrite(STDERR, "Plik nie istnieje.\n");
            return 1;
        }

        $handle = fopen($file, 'r');

        if ($handle === false) {
            fwrite(STDERR, "Nie można otworzyć pliku.\n");
            return 1;
        }

        $count = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $count++;
        }

        fclose($handle);

        echo "Przetworzono {$count} rekordów.\n";
        return 0;
    }
}
A punkt wejścia:
#!/usr/bin/env php
<?php

require __DIR__ . '/../vendor/autoload.php';

use App\Command\ImportCsvCommand;

$file = $argv[1] ?? null;

if ($file === null) {
    fwrite(STDERR, "Użycie: my-tool <plik.csv>\n");
    exit(1);
}

$command = new ImportCsvCommand();
exit($command->run($file));
To już jest sensowne, małe narzędzie, które można rozbudowywać bez przepisywania wszystkiego od zera. Największy błąd to potraktowanie CLI jak jednego długiego skryptu. Taki kod szybko robi się nieczytelny, trudny do utrzymania i praktycznie niemożliwy do rozwoju. Narzędzie CLI w PHP jest świetnym wyborem, gdy potrzebujesz prostego, szybkiego i praktycznego narzędzia dla backendu, DevOps albo automatyzacji. Dobrze zaprojektowany program terminalowy może być mały, ale bardzo użyteczny, zwłaszcza jeśli opiera się na jasnym podziale odpowiedzialności i pracy strumieniowej.