Cogeze
Core

ShapeRegistry

How a plugin publishes metadata that other plugins can discover without importing it.

Commander answers "run this named thing". ShapeRegistry answers "what named things exist?" — it is the discovery half of the same boundary.

php
use App\Plugins\ShapeRegistry;

$shapes = app(ShapeRegistry::class);

API#

php
public function declare(string $kind, string $key, array $shape): void
public function of(string $kind): array
public function get(string $kind, string $key): ?array
Parameter Meaning
$kind Category of thing being published: entity, filter-source, metric.
$key Identifier within that kind.
$shape Flat serialisable array. Never a class name, never a closure.

The flat-array rule#

A shape describes what exists and which command to call. It never carries executable code or a class reference.

php
$shapes->declare('filter-source', 'product', [
    'entity'   => 'product',
    'label'    => 'Products',
    'commands' => [
        'sources' => 'ecommerce.product.filter.sources',
        'options' => 'ecommerce.product.filter.options',
        'query'   => 'ecommerce.product.query',
    ],
]);

The consumer reads names and calls them through Commander:

php
foreach (app(ShapeRegistry::class)->of('filter-source') as $provider) {
    $sources = app(Commander::class)->run($provider['commands']['sources'], [], 'filter-bar');
}

If shapes could carry class references, the consumer would need the owner's classes on the autoload path, and dependencies() would have to list the owner. Keeping shapes flat is what lets a consumer declare no dependencies at all and still work with any provider that shows up.

It also means shapes can be serialised — cached, sent to the admin UI, or rendered in a control panel — without executing anything.

Ownership#

PluginManager sets the current plugin key before calling each boot(), so every shape and command is attributed automatically. The plugin detail screen lists what a plugin publishes: entities, filter sources, and registered commands.

Declaring in boot()#

php
public function boot(PluginManager $manager): void
{
    parent::boot($manager);

    $shapes = app(ShapeRegistry::class);
    $commander = app(Commander::class);

    $shapes->declare('entity', 'product', [
        'label' => 'Product',
        'route' => '/products',
    ]);

    $commander->register('ecommerce.product.filter.options',
        fn (array $a) => ProductFilterSources::options((string) ($a['source'] ?? '')));
}

Do not query the database in boot(). It runs during migrate:fresh too, when the tables may not exist. Declare static metadata here; resolve dynamic lists through a command at request time.

Missing owners#

A consumer iterating of('filter-source') over an empty registry simply does nothing. A consumer calling a command from a shape that is no longer registered gets CommandNotFound from run(), or the default from tryRun().

Neither is an error condition — it is what a plugin being uninstalled looks like.