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.

$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 your serverFields()). The core picks a server with free capacity and sets $service->server.
  • In ProvisionResult::ok($message, $data, $secrets), data is shown to staff and the client and secrets are 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() and runAction() add buttons on the service page. ProvisionResult::redirect($url) sends the browser to another address.
  • suspend, unsuspend, terminate and changePackage are 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.

Was this article helpful?
Last updated 10 Nov 2026
Powered by Tidemark Billing