Cogeze
Core

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.

php
use App\Plugins\Commander;

$commander = app(Commander::class);

register()#

php
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()#

php
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.

php
$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.

php
$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 during migrate: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#

php
$commander->register('article.stats', fn () => Article::count(), [
    'auth' => 'articles.view',
]);

Resolution order:

  1. Command declares no auth → public.
  2. Running inside asSystem() → allowed.
  3. An authorizer is installed and returns true → allowed.
  4. 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.

php
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#

php
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:

php
['command' => 'article.list', 'caller' => 'storefront', 'args' => ['page'], 'ms' => 2.41]

args holds keys only, never values — arguments can carry personal data.