Writing a plugin
The plugin folder layout, plugin.json, the plugin class, routes, events, hooks, provisioning modules and gateways, with the bundled plugins as examples.
This article is for developers who write PHP. A plugin is a folder in plugins/. There is no Composer step and no build step, so a plugin works on shared hosting. For installing and managing plugins as an administrator, see Plugins.
A plugin runs with full access to your data. Treat your own plugin like core code, and read anyone else's before you install it.
Folder layout#
plugins/acme-widgets/
├── plugin.json
├── src/AcmePlugin.php namespace Plugins\Acme\ (autoloaded from src/)
├── resources/views/ views are addressed as acme-widgets::name
├── database/migrations/ run when the plugin is enabled
└── routes/{admin,portal,api,public,webhook}.php
plugin.json#
{
"slug": "acme-widgets",
"name": "Acme Widgets",
"version": "1.0.0",
"description": "Sells widgets.",
"author": "Acme",
"namespace": "Plugins\\Acme\\",
"provider": "Plugins\\Acme\\AcmePlugin",
"requires": { "billing": ">=0.1" }
}
slug, name, version, namespace and provider are required for uploads. The slug is lowercase letters, numbers and dashes, up to 64 characters. Classes under namespace are autoloaded from src/. If you need third-party packages, ship a vendor/autoload.php inside the plugin folder and it is loaded for you. requires.billing is a version constraint checked on upload.
The plugin class#
The class named in provider extends App\Plugins\Plugin.
namespace Plugins\Acme;
use App\Plugins\Plugin;
class AcmePlugin extends Plugin
{
public function register(): void // early: gateways, modules, bindings
{
$this->registerModule(WidgetModule::class);
}
public function boot(): void // everything is ready: routes, views, menus, listeners
{
$this->loadViews();
$this->loadMigrations();
$this->loadRoutes('routes/admin.php', 'admin');
$this->adminMenu('Widgets', 'admin.acme-widgets.index', 'package');
}
public function fields(): array // optional form under Plugins → Settings
{
return [['key' => 'api_key', 'label' => 'API key', 'type' => 'password', 'required' => true]];
}
}
Read a saved value with $this->setting('api_key'). Field types are text, password, textarea, select (with options), checkbox, number, url, email and color. Password fields are encrypted at rest.
install() runs once, the first time the plugin is enabled, after its migrations. uninstall() runs when an uploaded plugin is deleted. Only enabled plugins are loaded.
Routes#
loadRoutes($file, $area) mounts a routes file in one of five areas:
| Area | URL prefix | Route names | Protection |
|---|---|---|---|
admin |
/admin/x/{slug} |
admin.{slug}.* |
Signed-in staff |
portal |
/portal/x/{slug} |
portal.{slug}.* |
Signed-in customer |
api |
/api/v1/x/{slug} |
api.{slug}.* |
None; add auth.api yourself |
public |
/x/{slug} |
{slug}.* |
None |
webhook |
/webhooks/{slug} |
webhook.{slug}.* |
None, no CSRF |
Extension points#
Domain events#
Every business event is a class in App\Events that carries the model. Listen with $this->listen(). The same events feed outbound webhooks, and the event names there are the ones the classes return.
$this->listen(\App\Events\InvoicePaid::class, fn ($e) => Slack::notify("Paid: {$e->model->number}"));
Filters#
A filter changes a value.
use App\Plugins\Hooks;
Hooks::filter('my.filter', fn (string $value, $context) => strtoupper($value));
$value = Hooks::apply('my.filter', $value, $context); // in your own code
The core applies the pdf.data filter to the data of invoice and quote PDFs before they render. The callback receives ($data, $type, $document), where $type is invoice or quote, and must return the array.
Menus and page slots#
$this->adminMenu($label, $route, $icon, $group) and portalMenu() add sidebar links. $this->extend($slot, ['view' => 'slug::partial', 'when' => fn ($ctx) => ...]) adds a panel to a core page. Available slots:
| Slot | Where | Data passed |
|---|---|---|
admin.dashboard, portal.dashboard |
Dashboards | none |
admin.service, portal.service |
Service page | service |
admin.client |
Client page | client |
admin.invoice, portal.invoice |
Invoice page | invoice |
admin.product |
Product form | product |
Scheduled work#
$this->schedule(fn (Schedule $s) => $s->call(fn () => Widgets::sync())->hourly());
This runs through the normal scheduler, so it needs the cron job.
Provisioning modules#
A module turns a paid service into something real. Extend App\Plugins\AbstractModule and implement key(), label() and create(). Every other lifecycle method has a harmless default.
class WidgetModule extends AbstractModule
{
public function key(): string { return 'widgets'; }
public function label(): string { return 'Widget factory'; }
public function productFields(): array
{
return [['key' => 'size', 'label' => 'Size', 'type' => 'select', 'options' => ['s' => 'Small', 'l' => 'Large']]];
}
public function create(Service $service): ProvisionResult
{
$id = Widgets::make($service->product->moduleOption('size'));
return ProvisionResult::ok('Created', ['widget_id' => $id], ['admin_password' => $this->generatePassword()]);
}
}
- The module appears in the product form's Module dropdown.
productFields()render there and are stored with the product; read one with$product->moduleOption('size'). usesServers()returning true makes the module use Servers records (hostname, port, username, secret, plus yourserverFields()). The core picks a server with free capacity and sets$service->server.- In
ProvisionResult::ok($message, $data, $secrets),datais shown to staff and the client andsecretsare encrypted and shown only on request. - Return
ProvisionResult::fail($message)rather than throwing. The service stays pending, staff see the error with a Retry button, and a provisioning-failed event fires. adminActions(),clientActions()andrunAction()add buttons on the service page.ProvisionResult::redirect($url)sends the browser to another address.suspend,unsuspend,terminateandchangePackageare called when invoices go unpaid, are paid, the service ends or the plan changes.
A module can also implement App\Contracts\ReportsUsage and its pullUsage($service, $from, $to) returns meter key to quantity. billing:usage:pull (also part of billing:run) calls it daily for services whose product has meters. Each window is recorded once, so a re-run is safe.
Payment gateways#
Extend App\Plugins\AbstractGateway and implement key(), label(), fields(), checkout() and webhook(). complete() handles the customer coming back from the provider and refund() handles refunds; both have defaults.
- Record money with
app(PaymentService::class)->record($invoice, 'acmepay', $amountMinor, $providerReference). It is idempotent on gateway plus reference, so calling it from both the browser return and the webhook is safe. - Administrators enable the gateway and fill
fields()under Settings → Payments. The provider's webhook URL is/webhooks/gateways/{key}. - Amounts are integers in the currency's minor unit.
A gateway can also implement App\Contracts\SupportsSavedMethods to offer saved payment methods and automatic payment. The core stores only the provider's token with brand, last four digits and expiry.
Migrations#
Migrations in database/migrations/ run when the plugin is enabled or upgraded. Write a complete down() in each one. If a migration fails, the migrations that completed in that operation are rolled back by running their down() methods. On SQLite and PostgreSQL the whole run is also one transaction. MySQL and MariaDB cannot roll back schema changes, so there down() is all you have and the failing migration itself may be partly applied. Keep each migration small.
Examples to read#
The bundled plugins/license-server shows a module, a public API, admin and client pages, migrations, settings, an event and a filter. plugins/stripe is a gateway. plugins/cpanel is a server-based module.