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:
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 here — boot() 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:
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').
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 |
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 |
[] |
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:
public function adminRoutes(): array
{
return ['/posts', '/posts/:id/edit', '/article-categories'];
}
settings()#
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#
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));
}
}