Un framework CLI pour Laravel. Orchestration de pipelines, contexte partagé, découverte automatique, appels internes - avec ou sans Laravel.
composer require andydefer/laravel-directive
php artisan vendor:publish --tag=directive-config
Prérequis : PHP 8.1+ | Laravel 12.x, 13.x, 14.x ou 15.x
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.
✅ 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
#!/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
<?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');
}
}
# 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
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.
public function getSignature(): string
{
return 'backup {source} {destination} {format=zip} {env=?} ::level->[low,medium,high]=medium {excludes*} {purpose*} {--force} {--verbose}';
}
Les arguments requis sont obligatoires. La commande échoue si l'utilisateur ne les fournit pas.
public function getSignature(): string
{
return 'backup {source} {destination}';
}
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}';
}
Les arguments nullables peuvent recevoir la valeur null explicitement.
public function getSignature(): string
{
return 'deploy {environment} {version=?} {config=?}';
}
Les arguments variadiques capturent zéro, une ou plusieurs valeurs.
public function getSignature(): string
{
return 'delete {files*} {directories*} {--force}';
}
Les flags sont des options booléennes. Présents = true, absents = false.
public function getSignature(): string
{
return 'deploy {environment} {--force} {--verbose} {--dry-run}';
}
Les énumérations restreignent les valeurs autorisées.
::name->[value1,value2,value3]=state
| État | Syntaxe | Description |
|---|---|---|
| Requis | =* |
Doit être fourni |
| Optionnel | =? |
Peut être ~ (skip) |
| Défaut | =default |
Valeur par défaut |
// 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"';
// Dans une directive
$level = $this->getEnum('level');
// Vérifications
$allowed = $this->getEnumAllowedValues('level');
$isRequired = $this->isEnumRequired('level');
$isAllowed = $this->isEnumValueAllowed('level', 'master');
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"';
}
⚠️ 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"> |
// ✅ 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"'
// ❌ Enum après variadic
'backup {source} {excludes*} ::level->[low,high]=medium'
// ❌ Required après default
'backup {format=zip} {source}'
^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 [email protected]
# name = "John Doe"
? (null explicite)Permet de passer explicitement null comme valeur.
# env = null
./bin/my-app deploy staging ?
~ (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 ~
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';
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;
}
getArgument()getArgument() recherche dans l'ordre de priorité :
// 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)
}
Le contexte est un MapCollection mutable accessible par toutes les directives.
// 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);
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;
}
}
La méthode call() permet d'exécuter d'autres directives depuis une directive.
// 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");
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;
}
}
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;
}
}
Laravel Directive découvre automatiquement les directives via AST (Abstract Syntax Tree).
| 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 |
$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');
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');
}
Chaque exécution est automatiquement journalisée au format JSONL.
{
"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
}
}
}
$kernel->setLogBasePath('/var/log/directive');
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.
Le mode verbose affiche automatiquement les problèmes rencontrés.
./bin/my-app deploy staging --verbose
$kernel->verbose(true);
./bin/my-app kernel:audit
./bin/my-app kernel:audit --verbose
| 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 |
Les directives peuvent être exécutées dans n'importe quel contexte PHP.
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);
}
}
class ProcessDataJob implements ShouldQueue
{
public function handle(DirectiveKernel $kernel): void
{
$kernel->runSignature('process:batch --limit=1000');
}
}
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 protected]');
return ['status' => 'completed'];
}
}
#!/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);
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());
}
}
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;
}
}
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;
}
}
// BON
class UserController
{
public function __construct(
private readonly UniqueTaskServiceInterface $taskService
) {}
}
// ÉVITER (facade)
use AndyDefer\Task\Facades\Task;
Task::register(...);
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');
}
}
// BON
$this->contextSet('user_id', $user->id);
$userId = $this->contextGet('user_id');
// ÉVITER
global $userId;
$userId = 42;
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");
}
#!/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);
| 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 |
MIT © Andy Defer
How can I help you explore Laravel packages today?