Eine CLI App eignet sich hervorragend, um wiederkehrende Probleme der Software-Entwicklung nachhaltig zu lösen. Shell-Code (wie z.B. für BASH) und composer-Skripte sind ebenfalls bestens geeignet, um kleine Aufgaben schnell und bei Bedarf zu automatisieren. Eine in PHP geschriebene CLI bietet aber sehr viel mehr Power und ist wesentlich wiederverwendbarer als eher kurze Code-Schnipsel.
Bei MXP setzen wir auf symfony-basierte CLI Apps für diese Zwecke. symfony ist die Grundlage der meisten von uns eingesetzten Software-Pakete wie Magento 2, Shopware und TYPO3. Und so liegt es für uns auf der Hand, bei eigenen CLIs auch darauf zu setzen. Dadurch können wir die entstehenden CLI-Kommandos sowohl Standalone in einer eigenen CLI App benutzen, alsauch in die CLI unserer Standard-Software integrieren. Bestens!
PHARs (ausführbare PHP-Archive) sind ein Segen für portable PHP CLIs, im Gegensatz zu losen Skript-Sammlungen. Jedes Tool, das man direkt als composer-Bibliothek einem Projekt hinzufügt, kann die Abhängigkeiten des Projekts durcheinander bringen. Oder gar nicht zu diesen passen. Versucht man, eine solche Bibliothek mal eben als schnelle Hilfe irgendwo hochzuladen, benötigt man nicht selten längere Debugging-Sessions, um alles zum Laufen zu bringen. Und, natürlich, wenn alles in einer Datei steckt, ist es super einfach, das Tool herunter zu laden, wieder zu löschen, per Mail zu verschicken oder…
OK, wir wollen also eine portable symfony CLI, in ein PHAR gepackt. In den folgenden Schritten erkläre ich dir anhand einer neuen CLI, worauf es ankommt.
 

Neue Konsolen-App my-cli erstellen

Für dieses Tutorial benutzen wir eine neue Konsolen-Applikation namens my-cli, ganz frisch erzeugt. Wir benutzen dazu symfony 5.3, die aktuelle Version als dieses Tutorial geschrieben wurde:
composer create-project symfony/skeleton my-cli "5.3.*"
cd my-cli
bin/console -V
Voila, eine neue CLI! Nichts besonderes bisher.
Bitte versuche nicht, deine vorhandene CLI in ein PHAR zu packen, bevor du nicht zumindest einmal erfolgreich eine neue, unveränderte CLI in ein PHAR gepackt hast. Sei gewarnt: andere symfony-Versionen haben andere Hürden, die man überwinden muss. Und die Probleme, die du beim Schreiben deiner vorhandenen CLI selbst geschaffen hast, kommen da noch oben drauf!
 

Aus my-cli ein PHAR machen

Wir müssen ein paar Änderungen an der CLI App vornehmen, bevor wir daraus ein PHAR erstellen können. Und damit wir das PHAR nicht von Hand machen müssen, benutzen wir ein paar Tools dafür.
 

Entwicklungs- von Laufzeit-Abhängigkeiten trennen

Wenn man mit composer require –dev einen PHAR-Builder hinzufügt, kommt man in eine Zwickmühle. Entweder man hat alle Entwicklungsabhängigkeiten mit im PHAR, oder man hat nach einem composer install –no-dev den PHAR-Builder nicht zur Verfügung. Packt man den Builder in die –no-dev-Abhängigkeiten, landet der Builder mit im PHAR. Wir müssen also die Entwicklungstools so von den Laufzeitabhängigkeiten trennen, dass composer zwar alles verwalten kann, aber am Ende nicht alles vermischt ist.
Glücklicherweise müssen wir uns das nicht selbst ausdenken: bamarni/composer-bin-plugin kann genau das. Der Hauptzweck dieses Plugins ist es, die Laufzeit-Abhängigkeiten des Projektes von denen der Entwicklungstools zu trennen (was grundsätzlich eine gute Sache ist). Und zu diesem Zweck speichert das Plugin nicht nur die Dateien in einem anderen Verzeichnis, sondern hält auch die Entwicklungstools aus dem Autoloader des Projektes heraus. Perfekt!
composer require --dev bamarni/composer-bin-plugin
OK, fast perfekt. Das Plugin selbst wird Teil des PHARs sein. Da es aber selbst sehr klein ist, im Verhältnis zu PHAR-Builder und anderen Entwicklertools, kann man damit gut leben.
Nun können wir den Entwickler-Kram ausserhalb der Projekt-/composer.json einbinden und die Dateien landen in einem Extra-Verzeichnis /vendor-bin/. Das Konzept des Plugins erlaubt es, für verschiedene Tools verschiedene benannte „Umgebungen” zu erstellen.
Bevor wir das tun, erleichtern wir uns die künftige Handhabung mit ein paar Automatismen für Installation und Update. Das Verlinken der Tools nach /vendor/bin stellen wir dabei auch ab, da es sehr oft Konflikte erzeugt. Ersetze die Sektion „scripts” in der generierten /composer.json durch die folgende und füge den Eintrag zu „extra” hinzu:
    "scripts": {
        "auto-scripts": [
        ],
        "post-install-cmd": [
            "@cache:drop",
            "@composer bin all install --ansi"
        ],
        "post-update-cmd": [
            "@cache:drop",
            "@composer bin all update --ansi"
        ],
        "cache:drop": [
            "[ ! -d var ] || rm -rf var"
        ]
    },
    "extra": {
        "bamarni-bin": {
            "bin-links": false
        }
    },
Es ist Absicht, dass die „auto-scripts” leer sind. Ohne den Eintrag läuft symfony/flex nicht mehr und verhindert die composer-Benutzung. Mit den dort möglichen Kommando-Referenzen kann dann aber wiederum der PHAR-Builder nicht erfolgreich ausgeführt werden. So wie oben geht nun alles wie benötigt.
 

Den PHAR-Builder in isolierte Umgebung installieren

box ist einer der am Meisten verwendeten PHAR-Builder. Das Tool hat zahlreiche Optionen zum Optimieren des PHAR und des Erstellungsprozesses. Die ausführliche Anleitung hilft dir beim Einsatz für deine bestehende CLI.
Das Tool in die isolierte composer-Umgebung „box” installieren:
composer bin box config minimum-stability dev
composer bin box require --dev humbug/box '^3.13.0'
Das neu erzeugte /vendor-bin/-Verzeichnis enthält ein paar Dateien, die man aufheben sollte, und die nicht zum Aufheben gedachten Abhängigkeiten der Tools. Ergänze die /.gitignore im Projekt-Root, um die Abhängigkeiten nicht in git zu versionieren, die nötigen /vendor-bin/**/composer.* aber schon. Und für das Arbeitsverzeichnis des PHAR-Builders fügen wir auch gleich einen Eintrag hinzu:
...

###> Isolated dev tools ###
/vendor-bin/**/vendor
###< Isolated dev tools ###
###> Box PHAR generator ###
/.box_dump/
###< Box PHAR generator ###
Erstelle nun noch eine /box.json in deinem Projekt-Root, um dem PHAR-Builder eine zur symfony CLI passende Grundkonfiguration zu geben:
{
    "directories": [
        "config",
        "src",
        "var/cache/prod"
    ],
    "files": [
        ".env.local.php"
    ],
    "finder": [
        {
            "name": "*.php",
            "exclude": ["Tests"],
            "in": "vendor"
        }
    ],
    "git-version": "application_version",
    "main": "bin/console",
    "output": "build/console",
    "dump-autoload": false
}
 

Die CLI für die PHAR-Nutzung anpassen

Damit eine symfony CLI aus einem PHAR laufen kann, muss man ein paar Änderungen vornehmen. box gibt dazu ein paar Hinweise, aber wie zuvor gesagt: Mit jeder Version von symfony ändern sich die nötigen Anpassungen.
Für die neue CLI steht alles hier im Tutorial. Vor allem darf man für symfony 5.3 nicht die /composer.json anpassen, wie box das angibt. composer dump-autoload geht nicht. Stattdessen wärmen wir den Cache für die prod-Umgebung vor und kopieren den Cache mit in das PHAR (die nötigen Schritte folgen später). Und nochmal zur Erinnerung: es gibt unendliche Möglichkeiten, warum deine bereits vorhandene CLI sich nicht ungeändert in ein PHAR packen lässt…
In src/Kernel.php schalten wir die automatische Ermittlung des Projekt-Root-Verzeichnisses aus. Die Erkennung kommt nicht damit zurecht, dass die Dateien nun in einem phar://-Stream liegen. Zum Glück können wir die Erkennung einfach ersetzen, in dem wir die geerbte Methode getProjectDir() überschreiben:
...
class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // In PHAR, Kernel cannot auto-detect this path
    public function getProjectDir(): string
    {
        return \dirname(__DIR__);
    }

    protected function configureContainer(ContainerConfigurator $container): void
...
Die mit symfony 5.3 neu eingeführte runtime-Komponente macht ebenfalls Probleme. Hier versucht symfony, mit $_SERVER[‚SCRIPT_FILENAME‘] zu arbeiten. Diese Variable enthält das aufgerufene Skript, also die PHAR-Datei selbst. symfony benötigt dort aber den Pfad der aufgerufenen PHP-Datei, um diese zu includieren. Das reparieren wir in bin/console:
....

// In PHAR, $_SERVER['SCRIPT_FILENAME'] is the PHAR itself. symfony runtime
// needs it to point to current file to include it:
$_SERVER['SCRIPT_FILENAME'] = __FILE__;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
...
Überprüfe nun, ob my-cli nach den Änderungen noch funktioniert:
bin/console -V
 

PHAR-Build-Prozess

box benötigt ein git tag oder einen Commit, da damit immer eine Ersetzung der aktuellen Version im PHAR versucht wird. Wir müssen also einen Commit machen, bevor wir das PHAR erstellen können:
git init --initial-branch=main
git add .
git commit -m "Base commit"
Im PHAR muss symfony im prod-Modus laufen. Und es kann den nötigen Cache für die Dependency-Injection nicht im PHAR selbst erzeugen. Wir müssen das also vorab machen, damit wir alles mit ins PHAR reinpacken können:
composer dump-env prod
bin/console cache:clear
Damit haben wir nun das prod-Environment vorkompiliert in die Datei /env.local.php und den Cache vorgewärmt in /var/cache/prod. Beide Pfade sind in der /box.json zum Einfügen ins PHAR konfiguriert.
OK, erstellen wir das PHAR und stellen symfony zurück in den dev-Modus:
vendor-bin/box/vendor/bin/box compile
rm .env.local.php
build/console -V
Hurra! Die CLI wurde zum ersten Mal aus dem PHAR ausgeführt!
Damit du die Build-Befehle nicht ständig wiederholen musst, füge sie als Skript build:phar in die /composer.json ein:
 "scripts": {
        "build:phar": [
            "@composer dump-env prod",
            "bin/console cache:clear",
            "vendor-bin/box/vendor/bin/box compile",
            "rm .env.local.php"
        ]
    }
Ab jetzt kannst die eine neue PHAR-Version mit nur einem Kommando erstellen:
composer build:phar
build/console -V
Und wo wir gerade von „Version“ reden, als Vorbereitung für den nächsten Teil:
git add .
git commit -m "PHARize CLI app"
git tag 0.0.1 -m '0.0.1'
 

Ein bischen PHAR-Builder-Magie

Der PHAR-Builder box kann bei der Zusammenstellung der PHAR-Inhalte ein paar Zaubertricks. Einer davon ist das Ersetzen von Zeichenketten gegen den aktuellen git tag oder die Commit-SHA. Das ist sehr praktisch, um mit dem -V immer die tatsächlich gepackte Version der CLI auszugeben!
Zunächst muss die CLI die Version überhaupt ausgeben. Dafür gibt man der Applikation einen richtigen Namen und liefert die Versionsnummer als Platzhalter mit. Du änderst dazu die bin/console wie folgt:
...
use Symfony\Bundle\FrameworkBundle\Console\Application;

define('APP_NAME', 'My CLI application');
define('APP_VERSION', '@application_version@');

if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
...

return function (array $context) {
    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

    $application = new Application($kernel);
    $application->setName(APP_NAME);
    $application->setVersion(APP_VERSION);
    return $application;
};
Das war es auch schon. Einmal neu erstellen:
bin/console -V
My CLI application @application_version@ (env: dev, debug: true)
composer build:phar
build/console -V
My CLI application 0.0.1 (env: prod, debug: false)
Magie! Da passiert sie!
git add .
git commit -m "Add version to CLI"
git tag 0.0.2 -m '0.0.2'
composer build:phar
build/console -V
My CLI application 0.0.2 (env: prod, debug: false)
 

Ein Kommando der CLI hinzufügen

Um zu zeigen, dass auch eigene Kommandos korrekt aus dem PHAR aufgerufen werden können, fügen wir nun ein hello:world Kommando hinzu. In eine neue Datei src/Command/HelloWorldCommand.php einfügen:
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class HelloWorldCommand extends Command
{
protected static $defaultName = 'hello:world';

protected function configure(): void
{
$this 
->setDescription('Say Hello! to the world')
->setHelp('My very first symfony CLI command. Very friendly!')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln('Hello, world!');

        return Command::SUCCESS;
    }
}
bin/console list
bin/console hello:world --help
bin/console hello:world
git add .
git commit -m "Add command to CLI"
git tag 0.0.3 -m '0.0.3'
composer build:phar
build/console list
build/console hello:world --help
build/console hello:world
 

Den Namespace App in My\Cli umbenennen

Es natürlich völlig OK und praktikabel, den Standard-Namespace App\ einer symfony CLI Applikation zu benutzen. Aber es ist bestimmt Teil deiner Marke und in deinen Test-Tools voreingestellt, dass dein eigener Namespace My\Cli benutzt wird.
Das gelingt sehr einfach mit einer globalen Suche & Ersetzen von App\\ -> My\\Cli\\ (JSON und PHP String-Literale) vor App\ -> My\Cli\ (Namespaces und YAML Konfigurationsdateien). Zuletzt noch in der Datei src/Kernel.php das namespace App; -> namespace My\Cli; umbenennen.
bin/console hello:world
git add .
git commit -m "Use namespace My\Cli for app"
git tag 0.0.4 -m '0.0.4'
composer build:phar
build/console hello:world
Es funktioniert hoffentlich noch alles. Dann weiter…
 

Das Binary console in my-cli umbenennen

Das sinnvolle Benennen des fertigen PHAR-Binaries und des Konsolen-Skriptes sollte auch noch sein. Sonst wimmelt es bald von bin/console bei dir.
Globales Suchen und Ersetzen von bin/console durch bin/my-cli (vor allem in /composer.json und /box.json) und natürlich die Skript-Datei selbst.
Solltest du in der /composer.json nicht, wie am Anfang empfohlen, alles in „scripts” ersetzt haben, musst du nun alles aus „auto-scripts” herauslöschen und die Aufrufe von @auto-scripts durch konkrete Aufrufe ersetzen. In symfony/flex ist bin/console leider fest einprogrammiert, und es wird weiterhin für alle Einträge in „auto-scripts” dieses Skript erwarten. Ersetze „@auto-scripts” mindestens mit „bin/my-cli cache:clear” in der /composer.json. Ein composer update && composer validate teilt dir mit, ob alles weiterhin funktioniert.
bin/my-cli -V
git add .
git commit -m "Rename CLI to my-cli"
git tag 0.0.5 -m '0.0.5'
composer build:phar
build/my-cli -V
 

Weitere Hinweise zu PHARs

Innerhalb des PHARs zeigen alle von symfony automatisch ermittelten Pfade in das PHAR. Um auf die „Aussenwelt” zuzugreifen, liefert getcwd() das aktuelle Arbeitsverzeichnis (von dem aus die CLI gestartet wurde).
Man muss aufpassen, welche PHP-Funktionen man für Datei-Zugriffe benutzt. Nicht alle PHP-Funktionen arbeiten mit dem phar:// Stream-Wrapper (or irgendeinem der Stream-Wrapper) – z.B. die Funktionen, die direkt auf C-Funktionen zugreifen wie realpath(), chdir(), symlink(). Sogar ext/zip benutzt solche Zugriffe – man kann also keine ZIPs ins PHAR packen und diese direkt öffnen. Es muss also nicht unbedingt an deinem Code liegen, wenn etwas nicht wie erwartet funktioniert.
Die im PHAR liegenden Dateien und Verzeichnisse sind read-only. Man kann also keine Dateien direkt im PHAR ändern. Ja, es gibt PHP-Funktionen, die das ermöglichen, aber die funktionieren dann beim Testen ausserhalb des PHARs nicht. Ausserdem ist das PHAR „portabel”, was heisst, dass der nächste Aufruf in einem völlig anderen Umfeld erfolgen kann als der aktuelle. Daher Daten und Zustände stets ausserhalb ablegen.
Wenn man eine CLI in ein PHAR packt, können unzählige weitere Probleme auftreten. Trotz allem werden PHARs in vielen Projekten erstellt, wie z.B. PHPUnit, composer usw. Es lohnt sich, dort nach Lösungen für die bei dir auftretenden Probleme zu suchen.

Mach´s einfach digital