andydefer/laravel-directive

A flexible CLI command system for Laravel that breaks free from Artisan's constraints. Directives introduces a clean separation between what your command does (business logic) and how it's presented (output/UI).

Maintainers

Package info

github.com/andydefer/laravel-directive

pkg:composer/andydefer/laravel-directive

Transparency log

Statistics

Installs: 985

Dependents: 5

Suggesters: 0

Stars: 0

Open Issues: 0

v3.59.2 2026-07-26 21:46 UTC

README

Un framework CLI pour Laravel. Orchestration de pipelines, contexte partagé, découverte automatique, appels internes - avec ou sans Laravel.

PHP Version Laravel Version License

Table des matières

  1. Installation
  2. Philosophie : Créez votre propre CLI
  3. Première directive
  4. Signature des commandes
  5. Accès aux arguments et options
  6. Contexte partagé
  7. Appels internes (call)
  8. Découverte automatique
  9. Journalisation JSONL
  10. Suggestions de commandes
  11. Mode verbose et débogage
  12. Directives intégrées
  13. Exécution hors CLI
  14. Mode autonome (sans Laravel)
  15. Tests des directives
  16. Cas d'usage concrets
  17. Bonnes pratiques
  18. Référence des codes de sortie

Installation

composer require andydefer/laravel-directive

Prérequis : PHP 8.1+ | Laravel 12.x, 13.x, 14.x ou 15.x

Philosophie : Créez votre propre CLI

Ce package n'est pas un simple binaire préfabriqué. C'est un framework pour construire votre propre système CLI adapté à vos besoins métier.

Le binaire fourni (vendor/bin/directive) est un exemple de démonstration, pas une solution finale.

Pourquoi créer votre propre CLI ?

Contrôle total - Vous décidez des commandes, des providers, des sources ✅ Logique métier - Intégration parfaite avec votre domaine applicatif ✅ Providers personnalisés - Enregistrez vos propres services et dépendances ✅ Performance - Chargez uniquement ce dont vous avez besoin ✅ Sécurité - Contrôle d'accès granulaire ✅ Évolutivité - Facile à étendre avec votre code

Créer votre point d'entrée

#!/usr/bin/env php
<?php

// bin/my-app
require_once __DIR__ . '/vendor/autoload.php';

use AndyDefer\Directive\Bootstrap\ApplicationBuilder;
use AndyDefer\Directive\DirectiveKernel;
use AndyDefer\Directive\DirectiveServiceProvider;
use App\Providers\MyAppServiceProvider;

// ✅ Personnalisation complète
$app = ApplicationBuilder::internal([
    DirectiveServiceProvider::class,
    MyAppServiceProvider::class,      // Vos providers
])->withConfig([
    'app.name' => 'My Application CLI',
    'app.debug' => getenv('APP_DEBUG') === 'true',
])->build();

$kernel = $app->make(DirectiveKernel::class);

// ✅ Ajouter vos sources
$kernel->addSource(__DIR__ . '/src/Directives');
$kernel->addSource(__DIR__ . '/modules/*/Directives');

// ✅ Configuration
$kernel->verbose(getenv('VERBOSE') === 'true')
    ->setLogBasePath('/var/log/my-app');

$exitCode = $kernel->run($argv);
exit($exitCode->value);
chmod +x bin/my-app
./bin/my-app deploy staging

Première directive

1. Créer la classe

<?php

namespace App\Directives;

use AndyDefer\Directive\AbstractDirective;
use AndyDefer\Directive\Enums\ExitCode;
use AndyDefer\DomainStructures\Collections\Utility\StringTypedCollection;

class GreetDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'greet {name} {--formal}';
    }

    public function getDescription(): string
    {
        return 'Say hello to someone';
    }

    public function getAliases(): StringTypedCollection
    {
        return StringTypedCollection::from(['hello', 'hi']);
    }

    protected function beforeExecute(): void
    {
        if ($this->getFlag('formal')) {
            $this->info('Formal mode enabled');
        }
    }

    protected function execute(): ExitCode
    {
        $name = $this->getArgument('name');
        $formal = $this->getFlag('formal');

        $greeting = $formal ? "Good day, $name" : "Hello, $name";
        $this->info($greeting);

        return ExitCode::SUCCESS;
    }

    protected function afterExecute(ExitCode $exitCode): void
    {
        $this->newLine();
        $this->line('Execution completed');
    }
}

2. Exécuter avec votre CLI

# Avec votre propre CLI
./bin/my-app greet John
# Hello, John

# Avec option
./bin/my-app greet John --formal
# Formal mode enabled
# Good day, John

# Avec alias
./bin/my-app hello John
# Hello, John

Signature des commandes

La signature est la clé de voûte du système. Elle définit la structure complète d'une commande : son nom, ses arguments, ses flags, ses énumérations et plus encore.

Le parseur de signature est basé sur php-signature-parser et supporte une syntaxe riche et expressive.

Structure de la signature

public function getSignature(): string
{
    return 'backup {source} {destination} {format=zip} {env=?} ::level->[low,medium,high]=medium {excludes*} {purpose*} {--force} {--verbose}';
}

Arguments requis

Les arguments requis sont obligatoires. La commande échoue si l'utilisateur ne les fournit pas.

public function getSignature(): string
{
    return 'backup {source} {destination}';
}

Arguments par défaut

Les arguments par défaut fournissent une valeur si l'utilisateur n'en spécifie pas.

public function getSignature(): string
{
    return 'backup {source} {destination} {format=zip} {compression=9} {output=dist}';
}

Arguments nullables

Les arguments nullables peuvent recevoir la valeur null explicitement.

public function getSignature(): string
{
    return 'deploy {environment} {version=?} {config=?}';
}

Arguments variadiques

Les arguments variadiques capturent zéro, une ou plusieurs valeurs.

public function getSignature(): string
{
    return 'delete {files*} {directories*} {--force}';
}

Flags

Les flags sont des options booléennes. Présents = true, absents = false.

public function getSignature(): string
{
    return 'deploy {environment} {--force} {--verbose} {--dry-run}';
}

Énumérations

Les énumérations restreignent les valeurs autorisées.

Syntaxe

::name->[value1,value2,value3]=state

États possibles

État Syntaxe Description
Requis =* Doit être fourni
Optionnel =? Peut être ~ (skip)
Défaut =default Valeur par défaut

Exemples

// Avec valeur par défaut
$signature = 'set-level ::level->[beginner,middle,master]=medium';

// Requis
$signature = 'set-level ::level->[beginner,middle,master]=*';

// Optionnel
$signature = 'set-level ::level->[beginner,middle,master]=?';

// Avec commentaire
$signature = 'set-level ::level->[beginner,middle,master]=medium#"The skill level"';

Accès aux énumérations

// Dans une directive
$level = $this->getEnum('level');

// Vérifications
$allowed = $this->getEnumAllowedValues('level');
$isRequired = $this->isEnumRequired('level');
$isAllowed = $this->isEnumValueAllowed('level', 'master');

Commentaires inline

Les commentaires documentent chaque argument directement dans la signature.

public function getSignature(): string
{
    return 'backup {source}#"Source directory" {destination}#"Destination" {format=zip}#"Archive format" {--force}#"Force overwrite"';
}

Ordre strict des éléments

⚠️ L'ordre des éléments dans la signature est STRICT et IMPÉRATIF.

Ordre Type Syntaxe Exemple
1 Source command backup
2 Requis {name} {source} {destination}
3 Par défaut {name=value} {format=zip} {output=dist}
4 Nullable {name=?} {env=?} {port=?}
5 Enum ::name->[values]=state ::level->[low,high]=medium
6 Variadique {name*} {excludes*} {purpose*}
7 Flags {--flag} {--force} {--verbose}
8 Tags personnalisés <key="value"> <user="admin">

Exemples d'ordre valide

// ✅ Ordre correct avec tous les types
'backup {source} {destination} {format=zip} {env=?} ::level->[low,high]=medium {excludes*} {--force}'

// ✅ Commentaires à n'importe quelle position
'backup {source}#"Source" {destination} {--force}#"Force"'

Exemples d'ordre invalide

// ❌ Enum après variadic
'backup {source} {excludes*} ::level->[low,high]=medium'

// ❌ Required après default
'backup {format=zip} {source}'

Formatage des espaces avec ^

Le parseur remplace automatiquement les caractères ^ par des espaces.

Saisie utilisateur Valeur réelle
John^Doe John Doe
Hello^World! Hello World!
C:/Program^Files C:/Program Files
./bin/my-app user:create John^Doe john@example.com
# name = "John Doe"

Tokens spéciaux

Le token ? (null explicite)

Permet de passer explicitement null comme valeur.

# env = null
./bin/my-app deploy staging ?

Le token ~ (skip)

Permet de sauter un argument.

Cas Comportement
Argument requis ~null
Argument par défaut ~ → utilise la valeur par défaut
Argument nullable ~null
Enum avec défaut ~ → utilise la valeur par défaut
Enum optionnel ~null
# format utilise la valeur par défaut (zip)
./bin/my-app backup /var/www ~

# level utilise la valeur par défaut (medium)
./bin/my-app set-level ~

Tags personnalisés

Les tags permettent d'ajouter des données supplémentaires sans modifier la signature.

./bin/my-app send John --verbose <greeting="Hello World"> <later="goodby">
// Dans la directive
$customData = $this->getCustomData();
$greeting = $customData['greeting'] ?? 'Default greeting';

Accès aux arguments et options

Méthodes principales

protected function execute(): ExitCode
{
    // Arguments requis
    $name = $this->getRequired('name');
    $email = $this->getRequired('email');

    // Tous les arguments requis
    $requireds = $this->getRequireds();

    // Arguments par défaut
    $format = $this->getDefault('format');

    // Tous les arguments par défaut
    $defaults = $this->getDefaults();

    // Arguments nullables
    $version = $this->getArgument('version');

    // Arguments variadiques
    $files = $this->getVariadic('files');

    // Tous les arguments variadiques
    $variadics = $this->getVariadics();

    // Collection plate de toutes les valeurs variadiques
    $allValues = $this->getVariadicArguments();

    // Flags
    $force = $this->getFlag('force');
    $verbose = $this->getFlag('verbose');

    // Tous les flags
    $flags = $this->getFlags();

    // Flags actifs
    $active = $this->getActiveFlags();

    // Énumérations
    $level = $this->getEnum('level');

    // Toutes les énumérations
    $enums = $this->getEnums();

    // Valeurs autorisées pour une enum
    $allowed = $this->getEnumAllowedValues('level');

    // Vérifications
    if ($this->hasArgument('email')) { /* ... */ }
    if ($this->hasFlag('force')) { /* ... */ }
    if ($this->hasRequireds()) { /* ... */ }
    if ($this->hasDefaults()) { /* ... */ }
    if ($this->hasEnums()) { /* ... */ }
    if ($this->hasFlags()) { /* ... */ }
    if ($this->hasVariadicArguments()) { /* ... */ }

    return ExitCode::SUCCESS;
}

Recherche d'arguments avec getArgument()

getArgument() recherche dans l'ordre de priorité :

  1. Arguments requis
  2. Arguments par défaut
  3. Énumérations
  4. Arguments variadiques (retourne un tableau)
  5. Flags (retourne un booléen)
// Peut retourner un string, un booléen ou un array
$value = $this->getArgument('key');

if (is_array($value)) {
    // C'est un variadic
} elseif (is_bool($value)) {
    // C'est un flag
} else {
    // C'est un argument (requis, default, nullable, enum)
}

Contexte partagé

Le contexte est un MapCollection mutable accessible par toutes les directives.

Méthodes du contexte

// Définir une valeur
$this->contextSet('key', 'value');

// Lire une valeur
$value = $this->contextGet('key', 'default');

// Vérifier l'existence
if ($this->contextHas('key')) {
    // ...
}

// Obtenir tout le contexte
$all = $this->contextAll();

// Fusionner des valeurs
$this->contextMerge([
    'user_id' => 42,
    'user_role' => 'admin',
]);

// Supprimer une clé
$this->contextRemove('temporary_data');

// Effacer tout le contexte
$this->contextClear();

// Incrémenter / Décrémenter
$this->contextIncrement('counter', 5);
$this->contextDecrement('counter', 2);

Exemple : Pipeline de traitement

class ProcessDataDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'data:process {file}';
    }

    protected function execute(): ExitCode
    {
        $file = $this->getArgument('file');

        $this->contextSet('file', $file);
        $this->call('data:load');
        $this->call('data:clean');
        $this->call('data:transform');

        $stats = $this->contextGet('processing_stats');
        $this->info("📊 Processed {$stats['total']} records");

        return ExitCode::SUCCESS;
    }
}

Appels internes (call)

La méthode call() permet d'exécuter d'autres directives depuis une directive.

Syntaxe

// Appel simple
$this->call('build --clean');

// Avec argument
$this->call('deploy:backup staging');

// Avec options
$this->call('deploy:migrate --force');

// Appel dynamique
$env = $this->getArgument('environment');
$this->call("deploy:validate $env");

// Multiple appels
$this->call('task:one');
$this->call('task:two');
$this->call('task:three');

// Avec parsing du contexte
$this->call("greet {$name} --formal");

Détection de circularité

Laravel Directive détecte automatiquement les appels circulaires :

class CircularDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'circular';
    }

    protected function execute(): ExitCode
    {
        $this->call('circular'); // ⚠️ Détecté automatiquement
        return ExitCode::SUCCESS;
    }
}

Pipeline de déploiement

class DeployDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'deploy {environment} {--skip-tests} {--force}';
    }

    protected function execute(): ExitCode
    {
        $env = $this->getArgument('environment');
        $skipTests = $this->getFlag('skip-tests');
        $force = $this->getFlag('force');

        $this->call("deploy:validate $env");
        $this->call("deploy:backup $env");

        if (!$skipTests) {
            $this->call('deploy:build --with-tests');
        } else {
            $this->call('deploy:build');
        }

        $forceFlag = $force ? '--force' : '';
        $this->call("deploy:migrate $env $forceFlag");

        $this->call("deploy:activate $env");
        $this->call('deploy:post --notify');
        $this->call('deploy:cleanup --keep-last=3');

        return ExitCode::SUCCESS;
    }
}

Découverte automatique

Laravel Directive découvre automatiquement les directives via AST (Abstract Syntax Tree).

Sources de découverte

Source Description Dossier par défaut
Built-in Directives intégrées src/BuiltIn/
Workspace Directives de l'application src/Directives/, app/Directives/
Vendor Directives des packages vendor/*/src/Directives/
Custom Sources configurées Configurable

Filtrer la découverte

$kernel = $app->make(DirectiveKernel::class);

// Ignorer une source
$kernel->ignoreSource(DiscoverySource::VENDOR);

// Ajouter une source
$kernel->addSource(__DIR__ . '/src/Commands');

// Filtrer par namespace
$kernel->onlyNamespace('App\\Directives\\');
$kernel->excludeNamespace('App\\Directives\\Internal\\');

// Filtrer par préfixe
$kernel->onlyPrefix('app:');
$kernel->excludePrefix('admin:');

// Ignorer une directive spécifique
$kernel->ignoreDirective('deprecated:command');

Système de problèmes

Le système de découverte collecte automatiquement les problèmes :

$kernel->discover();
$problems = $kernel->getProblems();

foreach ($problems as $problem) {
    echo $problem->get('key') . ': ' . $problem->get('message');
}

Journalisation JSONL

Chaque exécution est automatiquement journalisée au format JSONL.

Structure du log

{
  "time": "2026-07-09T11:45:23+00:00",
  "level": "info",
  "type": "directive_execution",
  "payload": {
    "command": "deploy staging",
    "directive_class": "App\\Directives\\DeployDirective",
    "signature": "deploy {environment} {--skip-tests} {--force}",
    "exit_code": 0,
    "exit_code_label": "Success",
    "success": true,
    "duration_seconds": 12.345,
    "memory_bytes": 2048,
    "memory_human": "2.00 KB",
    "peak_memory_bytes": 4096,
    "peak_memory_human": "4.00 KB",
    "calls_count": 7,
    "context": {
      "environment": "staging",
      "deployment_start": 1700000000
    }
  }
}

Configuration

$kernel->setLogBasePath('/var/log/directive');

Suggestions de commandes

Laravel Directive utilise un BK-tree (distance de Levenshtein) pour suggérer des commandes similaires.

$ ./bin/my-app depoy
Directive not found: depoy

💡 Did you mean:
  • deploy
  • deploy:validate
  • deploy:backup

Les alias sont également pris en compte.

Mode verbose et débogage

Le mode verbose affiche automatiquement les problèmes rencontrés.

Activation

./bin/my-app deploy staging --verbose
$kernel->verbose(true);

Audit du noyau

./bin/my-app kernel:audit
./bin/my-app kernel:audit --verbose

Directives intégrées

Directive Description Alias
help Affiche l'aide -h, --help
list Liste toutes les directives ls, -l, --list
version Affiche la version -v, --version
clean-logs [days] Nettoie les logs -
kernel:audit Audit du noyau audit

Exécution hors CLI

Les directives peuvent être exécutées dans n'importe quel contexte PHP.

Dans un contrôleur Laravel

use AndyDefer\Directive\DirectiveKernel;
use AndyDefer\Directive\Enums\ExitCode;

class TaskController extends Controller
{
    public function run(DirectiveKernel $kernel)
    {
        $exitCode = $kernel->runSignature('process:data --format=json');

        if ($exitCode === ExitCode::SUCCESS) {
            return response()->json(['message' => 'Task completed']);
        }

        return response()->json(['error' => 'Task failed'], 500);
    }
}

Dans un job Laravel

class ProcessDataJob implements ShouldQueue
{
    public function handle(DirectiveKernel $kernel): void
    {
        $kernel->runSignature('process:batch --limit=1000');
    }
}

Dans un service métier

class ReportService
{
    public function __construct(private DirectiveKernel $kernel) {}

    public function generateDailyReport(): array
    {
        $this->kernel->runSignature('report:fetch');
        $this->kernel->runSignature('report:process --format=pdf');
        $this->kernel->runSignature('report:send --email=admin@example.com');

        return ['status' => 'completed'];
    }
}

Mode autonome (sans Laravel)

Script d'entrée

#!/usr/bin/env php
<?php

// bin/standalone
require_once __DIR__ . '/vendor/autoload.php';

use AndyDefer\Directive\Bootstrap\ApplicationBuilder;
use AndyDefer\Directive\DirectiveKernel;
use AndyDefer\Directive\DirectiveServiceProvider;

$app = ApplicationBuilder::external([
    DirectiveServiceProvider::class,
])->withConfig([
    'app.name' => 'Standalone CLI',
])->build();

$kernel = $app->make(DirectiveKernel::class);
$kernel->addSource(__DIR__ . '/src/Directives');

exit($kernel->run($argv)->value);

Tests des directives

DirectiveTestingService permet de tester les directives en isolation.

<?php

namespace Tests\Directives;

use AndyDefer\Directive\Bootstrap\ApplicationBuilder;
use AndyDefer\Directive\DirectiveServiceProvider;
use AndyDefer\Directive\Services\DirectiveTestingService;
use AndyDefer\Directive\Enums\ExitCode;
use PHPUnit\Framework\TestCase;

class GreetDirectiveTest extends TestCase
{
    private DirectiveTestingService $testing;

    protected function setUp(): void
    {
        parent::setUp();

        $app = ApplicationBuilder::internal([
            DirectiveServiceProvider::class,
        ])->build();

        $this->testing = new DirectiveTestingService(
            $app,
            [__DIR__ . '/../app/Directives']
        );
    }

    protected function tearDown(): void
    {
        $this->testing->destroy();
        parent::tearDown();
    }

    public function test_greet_directive_returns_success(): void
    {
        $response = $this->testing->run('greet John');

        $this->assertSame(ExitCode::SUCCESS, $response->exit_code);
        $this->assertStringContainsString('Hello, John', $response->output);
    }

    public function test_unknown_command_returns_problems(): void
    {
        $response = $this->testing->run('unknown-command');

        $this->assertSame(ExitCode::NOT_FOUND, $response->exit_code);
        $this->assertFalse($response->problems->isEmpty());
    }
}

Cas d'usage concrets

Pipeline de déploiement

class DeployDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'deploy {environment} {--skip-tests} {--force}';
    }

    protected function execute(): ExitCode
    {
        $env = $this->getArgument('environment');

        $this->call("deploy:validate $env");
        $this->call("deploy:backup $env");
        $this->call('deploy:build' . ($this->getFlag('skip-tests') ? ' --skip-tests' : ''));
        $this->call("deploy:migrate $env" . ($this->getFlag('force') ? ' --force' : ''));
        $this->call("deploy:activate $env");
        $this->call('deploy:post --notify');

        return ExitCode::SUCCESS;
    }
}

Data processing pipeline

class ProcessDataDirective extends AbstractDirective
{
    public function getSignature(): string
    {
        return 'data:process {file} {--format=csv} {--dry-run}';
    }

    protected function execute(): ExitCode
    {
        $file = $this->getArgument('file');

        $this->call("data:load $file");
        $this->call('data:clean');
        $this->call('data:transform --aggressive');
        $this->call('data:validate');

        if (!$this->getFlag('dry-run')) {
            $format = $this->getArgument('format');
            $this->call("data:export --format=$format");
        }

        $stats = $this->contextGet('processing_stats');
        $this->info("📊 Processed {$stats['total']} records");

        return ExitCode::SUCCESS;
    }
}

Bonnes pratiques

✅ Injection de services

// BON
class UserController
{
    public function __construct(
        private readonly UniqueTaskServiceInterface $taskService
    ) {}
}

// ÉVITER (facade)
use AndyDefer\Task\Facades\Task;
Task::register(...);

✅ Validation des arguments

protected function beforeExecute(): void
{
    $name = $this->getArgument('name');

    if (empty($name) || strlen($name) < 3) {
        $this->error('Name must be at least 3 characters');
        throw new \RuntimeException('Invalid name');
    }
}

✅ Utiliser le contexte pour les données partagées

// BON
$this->contextSet('user_id', $user->id);
$userId = $this->contextGet('user_id');

// ÉVITER
global $userId;
$userId = 42;

✅ Hooks before/after

protected function beforeExecute(): void
{
    $this->contextSet('start_time', microtime(true));
    $this->info('Starting...');
}

protected function afterExecute(ExitCode $exitCode): void
{
    $duration = microtime(true) - $this->contextGet('start_time');
    $this->info("Completed in {$duration}s");
}

✅ Créer son propre CLI

#!/usr/bin/env php
<?php

// bin/my-app
require_once __DIR__ . '/vendor/autoload.php';

use AndyDefer\Directive\Bootstrap\ApplicationBuilder;
use AndyDefer\Directive\DirectiveKernel;
use AndyDefer\Directive\DirectiveServiceProvider;
use App\Providers\AppServiceProvider;

$app = ApplicationBuilder::internal([
    DirectiveServiceProvider::class,
    AppServiceProvider::class,
])->build();

$kernel = $app->make(DirectiveKernel::class);
$kernel->addSource(__DIR__ . '/src/Directives');

exit($kernel->run($argv)->value);

Référence des codes de sortie

Code Label Description
0 SUCCESS Exécution réussie
1 FAILURE Échec général
2 INVALID_ARGUMENT Argument invalide
3 NOT_FOUND Directive non trouvée
4 PERMISSION_DENIED Permission refusée
5 RUNTIME_ERROR Erreur d'exécution
6 INVALID_SIGNATURE Signature invalide
7 CONFLICT Conflit (circularité)
8 DEPENDENCY_ERROR Erreur de dépendance

Licence

MIT © Andy Defer