Cogeze
Core

Writing a plugin

The PluginContract reference — every method, its default, and when to override it.

A plugin is a directory under plugins/ with one class extending AbstractPlugin. AbstractPlugin implements every method of PluginContract with a working default, so a minimal plugin declares two:

php
namespace Plugins\Article;

use App\Plugins\AbstractPlugin;

class ArticlePlugin extends AbstractPlugin
{
    public function key(): string
    {
        return 'article';
    }

    public function name(): string
    {
        return 'Articles';
    }
}

Discovery is filesystem-based: the directory name maps to Plugins\{Dir}\{Dir}Plugin. Nothing to register.

Identity#

Method Returns Default Notes
key() string Required. Stable identifier. Used in plugins.key, permission names, asset paths. Never change it after release.
name() string Required. Human-readable, shown in admin.
version() string '1.0.0' Semver. Other plugins constrain against it.
description() string '' Shown on the plugin detail screen.
requiresSdk() string '1.0.0' Minimum AdminSDK version the front-end bundle needs.
category() string 'utility' content · system · media · storefront · utility. Other plugins filter by this — the translation plugin only manages content plugins.

Lifecycle#

Method Returns Default Notes
mandatory() bool false true boots the plugin regardless of database state. For infrastructure whose tables are foreign-key targets for core tables.
installPriority() int 50 if mandatory, else 0 Tie-breaker for automatic install order. Higher installs first. Dependencies always win over priority.
boot() void Loads routes, views, pages Called on every request while the plugin is enabled. Never query the database hereboot() also runs during migrate:fresh, when tables may not exist.
install() void no-op Runs after migrations and permission seeding.
uninstall() void no-op Runs before migrations roll back.

boot()#

Always call the parent — it wires up route files, view namespaces and declared pages:

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

    ArticleGateway::register(app(Commander::class));
}

Dependencies#

Method Returns Default
dependencies() array<string, string> []
optionalDependencies() array<string, string> []

Both map plugin key to a version constraint ('*', '1.0.0', '^1.2').

php
public function dependencies(): array
{
    return ['faq' => '*'];          // programs.faq_id is a foreign key into faqs
}

public function optionalDependencies(): array
{
    return ['media-finder' => '*']; // borrows a picker component; falls back to a URL field
}

The difference is what happens when the dependency is missing:

dependencies() optionalDependencies()
Missing on disk Install is blocked Install proceeds, warning shown
Not installed Installed automatically Suggested to the admin only
Uninstalled later Blocked while a dependent exists Warning only
Install order Always before this plugin Before this plugin, if installed

Use dependencies() when the plugin breaks without it — typically a foreign key. Use optionalDependencies() when it degrades — typically a borrowed UI component behind a null check.

Schema and data#

Method Returns Default
migrationsPath() ?string {plugin}/database/migrations if the directory exists
seeders() array<class-string> []
permissions() ?array null
php
public function seeders(): array
{
    return [ArticleCategorySeeder::class, ArticleSeeder::class];
}

public function permissions(): ?array
{
    return [
        'key' => 'articles',
        'label' => 'Articles',
        'permissions' => [
            'articles.view'   => 'View articles',
            'articles.create' => 'Create articles',
            'articles.update' => 'Edit articles',
            'articles.delete' => 'Delete articles',
        ],
    ];
}

Permissions are seeded into RBAC on install and are idempotent.

Admin surface#

Method Returns Default
nav() array []
adminRoutes() array<int, string> Every url in nav()
bundle() ?string /plugins/{key}/plugin.js if the file exists
settings() array []
php
public function nav(): array
{
    return [[
        'group'      => 'Content',
        'title'      => 'Articles',
        'url'        => '/posts',
        'icon'       => 'Newspaper',       // Lucide icon name
        'permission' => 'articles.view',
    ]];
}

adminRoutes() is reported into route_map with surface='admin'. It exists purely for conflict detection: two plugins both claiming /posts is otherwise silent — whichever registers last wins in the client, and nobody finds out until they open the screen and see the wrong plugin.

Override it when the plugin has routes that are not on the menu:

php
public function adminRoutes(): array
{
    return ['/posts', '/posts/:id/edit', '/article-categories'];
}

settings()#

php
public function settings(): array
{
    return [
        'per_page' => [
            'label'   => 'Articles per page',
            'type'    => 'number',
            'default' => 12,
        ],
    ];
}

Types: string · password · number · boolean · text · select (with options). Read them back with $this->setting('per_page', 12).

Public pages#

Method Returns Default
pages() array<string, array> []

Leave this empty in a data plugin. It belongs to storefront plugins. See Pages for the full schema and Storefront for why the split exists.

Full example#

php
namespace Plugins\Article;

use App\Plugins\AbstractPlugin;
use App\Plugins\Commander;
use App\Plugins\PluginManager;
use Plugins\Article\Seeders\ArticleSeeder;
use Plugins\Article\Support\ArticleGateway;

class ArticlePlugin extends AbstractPlugin
{
    public function key(): string
    {
        return 'article';
    }

    public function name(): string
    {
        return 'Articles';
    }

    public function description(): string
    {
        return 'Articles, categories and tags, with a public read gateway.';
    }

    public function category(): string
    {
        return 'content';
    }

    public function seeders(): array
    {
        return [ArticleSeeder::class];
    }

    public function permissions(): ?array
    {
        return [
            'key' => 'articles',
            'label' => 'Articles',
            'permissions' => ['articles.view' => 'View articles'],
        ];
    }

    public function nav(): array
    {
        return [[
            'group' => 'Content', 'title' => 'Articles',
            'url' => '/posts', 'icon' => 'Newspaper', 'permission' => 'articles.view',
        ]];
    }

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

        ArticleGateway::register(app(Commander::class));
    }
}