Commander
The cross-plugin command bus — API reference, authorisation, and the DTO rule.
App\Plugins\Commander is a singleton command bus. A plugin that owns data registers named
commands; any other plugin executes them by name. No imports, no cross-plugin queries.
use App\Plugins\Commander;
$commander = app(Commander::class);
register()#
public function register(string $name, Closure $handler, array $meta = []): void
| Parameter | Type | Notes |
|---|---|---|
$name |
string |
Dot-namespaced by owner: article.findBySlug. |
$handler |
Closure |
Receives one array $args. Returns anything serialisable. |
$meta |
array |
Optional. See below. |
$meta keys:
| Key | Type | Effect |
|---|---|---|
description |
string |
Shown in the plugin detail screen. |
args |
array |
Argument hints: ['name' => …, 'type' => …, 'required' => bool, 'about' => …]. |
returns |
string |
Free-text shape description. |
auth |
bool|string|string[] |
true = login required. String or array = required permissions. Omit for public. |
Registering a name that already exists replaces the entire descriptor — handler, meta, auth and owner. It does not merge. A warning is logged when one plugin overwrites another plugin's command.
Registering a public command under a name already used by a permission-gated one used to inherit that permission, because only the handler was replaced. The result was a 500 on the storefront whose only trace was one log line.
run() and tryRun()#
public function run(string $name, array $args = [], ?string $caller = null): mixed
public function tryRun(string $name, array $args = [], mixed $default = null, ?string $caller = null): mixed
run() throws:
| Exception | When |
|---|---|
CommandNotFound |
Name is not registered. |
CommandUnauthorized |
Command declares auth and the check fails. |
CommandCycle |
The command re-enters itself, directly or transitively. |
tryRun() returns $default only when the command is not registered. It does not
swallow runtime errors — a broken handler still throws.
That is deliberate. Swallowing everything would make a broken plugin return empty data
everywhere, silently. The caller decides its own tolerance: Pages lets optional
needs fall back and logs the error, while a required need still propagates.
$caller is a free-form label recorded in the trace. Pass something identifiable.
$article = $commander->run('article.findBySlug', ['slug' => 'hello'], 'storefront');
$list = $commander->tryRun('article.list', ['page' => 1], [], 'storefront');
The DTO rule#
Return arrays, never Eloquent models.
A model ties the consumer to your schema. It will call $article->category->name, and any
change to your table structure breaks the storefront with no warning until runtime.
$commander->register('article.findBySlug', function (array $args) {
if (! Schema::hasTable('articles')) {
return null;
}
$a = Article::query()
->with('category:id,name,slug')
->where('slug', $args['slug'] ?? null)
->where('status', 'published')
->first();
if (! $a) {
return null;
}
return [
'name' => $a->name,
'slug' => $a->slug,
'summary' => $a->summary,
'content' => $a->content,
'category' => $a->category
? ['name' => $a->category->name, 'slug' => $a->category->slug]
: null,
'seo' => [
'title' => data_get($a->seo, 'title') ?: $a->name,
'description' => data_get($a->seo, 'description') ?: $a->summary,
],
];
}, ['description' => 'Published article by slug (read-only DTO)']);
Two details worth copying:
- Guard the table.
boot()runs duringmigrate:fresh, so a command may execute before its own migrations have. - Eager-load inside the handler. The consumer cannot fix your N+1 — it never sees your query.
Authorisation#
$commander->register('article.stats', fn () => Article::count(), [
'auth' => 'articles.view',
]);
Resolution order:
- Command declares no
auth→ public. - Running inside
asSystem()→ allowed. - An authorizer is installed and returns
true→ allowed. - Otherwise →
CommandUnauthorized.
Step 4 is the default. No authorizer means gated commands do not run. Failing closed is the only safe default for a bus that fronts other plugins' data.
public function asSystem(Closure $fn): mixed
Skips authorisation for trusted contexts — CLI, queue workers, seeding. Restores the previous state afterwards, including on exception. Do not expose it to plugin code.
Cycles#
The bus keeps a stack of running commands. If one re-enters itself the call throws
CommandCycle with the full chain:
Command cycle: a.list → b.related → a.list
Plugins do not need cycle guards of their own.
Introspection#
public function has(string $name): bool
public function names(): array
public function ownerOf(string $name): ?string
public function namesOwnedBy(string $owner): array
public function metaOf(string $name): ?array
public function authOf(string $name): ?array
public function allWithOwner(): array
public function traceLog(): array
Owner attribution is captured automatically: PluginManager sets Commander::$booting
before each plugin's boot().
traceLog() returns one entry per run() in the current request:
['command' => 'article.list', 'caller' => 'storefront', 'args' => ['page'], 'ms' => 2.41]
args holds keys only, never values — arguments can carry personal data.