Plugin examples
Annotated tours of plugins/signals/xero-sync, signals/slots-demo, signals/slack-webhook, and the acme-example test fixture — repo-relative paths only.
Primary reference — signals/xero-sync
Path: plugins/signals/xero-sync/
Full production-shaped reference that exercises discovery, manifest validation, guarded migrations, permissions, asymmetric data_access, Event hooks, HTTP allowlist, encrypted settings, a dashboard slot, and a plugin_* log table.
File tour
| Path | Role |
|---|---|
plugins/signals/xero-sync/composer.json |
Package signals/xero-sync, PSR-4 Signals\XeroSync\, extra.signals.plugin → Signals\XeroSync\XeroSyncPlugin |
plugins/signals/xero-sync/signals.yaml |
Full manifest — see annotated copy on Plugin manifest |
plugins/signals/xero-sync/src/XeroSyncPlugin.php |
register(): permissions, hooks, dashboard slot |
plugins/signals/xero-sync/src/XeroContactPusher.php |
Event handler for account.updated — PluginContext first arg, read/update accounts, HTTP push |
plugins/signals/xero-sync/src/XeroInvoicePusher.php |
Event handler for invoice.issued |
plugins/signals/xero-sync/src/XeroClient.php |
Token + Contacts/Invoices HTTP against allowlisted hosts |
plugins/signals/xero-sync/src/Models/XeroSyncLog.php |
Eloquent model for plugin_xero_sync_log |
plugins/signals/xero-sync/database/migrations/2026_07_16_120000_create_plugin_xero_sync_log_table.php |
Guarded plugin_ migration |
What to copy from Xero
- Asymmetric fields — read contact/invoice attributes; write only
custom_fields.xero_*_id. - Hook shape —
__invoke(PluginContext $context, mixed ...$payload). - Settings — encrypted
client_secret, enumsync_direction. - Slot — core
signals.stat-cardwith permissionsignals.xero.view. - Network —
api.xero.com,identity.xero.comonly.
Install locally
php artisan signals:plugin check signals/xero-sync
php artisan signals:plugin install signals/xero-sync
php artisan signals:plugin enable signals/xero-sync
# restart PHP process, then open /admin/settings/plugins and the dashboard
Kitchen-sink reference — signals/slots-demo
Path: plugins/signals/slots-demo/
The canonical "every surface" example — one plugin that demonstrates every place a plugin can touch: one slot from every slot family, a global modal, a top-level nav group with two config-driven pages, a registered datatable, a palette provider, a recents push, a sendable notification, a provided (self-subscribed) event, a catalogued-event hook, a vendor-prefixed permission and three settings. Copy this when you want to see how each surface is wired in one place; copy Xero when you need HTTP + custom fields.
File tour
| File | Purpose |
|---|---|
plugins/signals/slots-demo/signals.yaml |
Manifest declaring eleven slots (one per family + modals.global + a datatable widget), two hooks (account.updated + the plugin's own event), events[], notifications[], nav + pages[], datatables[], one permission, three settings (string + enum + boolean), one plugin_ table, empty network |
plugins/signals/slots-demo/src/SlotsDemoPlugin.php |
register(): permission → provided event → notification → hooks → navItem → navGroup + pages → datatable + datatableSlot → paletteCommands → every slot. Numbered comments (1)–(12) walk each surface |
plugins/signals/slots-demo/src/SlotsDemoListener.php |
account.updated handler — PluginContext first arg, reads the account, writes an audit row, then $context->notify() to the acting user and $context->emit() the provided event |
plugins/signals/slots-demo/src/SlotsDemoEchoListener.php |
Second hook, subscribed to the plugin's own signals.slotsdemo.logged event — proves plugin-declared events are hookable, including by the declaring plugin |
plugins/signals/slots-demo/src/Models/SlotsDemoLog.php |
Eloquent model for plugin_slots_demo_log |
plugins/signals/slots-demo/database/migrations/2026_07_17_120000_create_plugin_slots_demo_log_table.php |
Guarded plugin_ migration |
Surfaces demonstrated
| Surface | Where in the plugin | What it shows |
|---|---|---|
dashboard.widgets slot |
View → core signals.stat-card |
Stat card counting logged account updates; label/colour from settings |
Detail tabs (rental.detail.tabs, catalogue_item.detail.tabs) |
Html true tabs |
Key/label/content payload → native strip link + panel in the entity model area (/{entity}/{id}/tabs/{key}) |
Detail header actions (rental / invoice / account / catalogue_item .detail.header_actions) |
Html menu items |
s-dropdown-item merged into <x-signals.actions-menu> via plugin-slot |
Board slot (dispatch.board.actions) |
Html button |
Board surfaces carry no single entity id |
Index toolbar (payments.index.actions) |
Html button |
Toolbar family for list pages without a detail view |
Global modal (modals.global) |
View → core signals.plugin-modal |
Config-driven Flux modal (slots-demo-info) with stat-grid + html sections |
| Nav group + pages | navGroup('Slots Demo', …) + page('overview') / page('playground') |
Config-driven pages at /plugins/signals/slots-demo/{overview,playground}: stat-grid + table + html sections fed by live plugin_slots_demo_log data; Playground lists the plugin's own SlotRegistry entries |
| DataTable | datatable('signals.slotsdemo.countries', …) + a datatable page section |
A declared datatables[] table over App\Models\Country (searchable, active-only scope, primary / status-dot renderers) rendered on the Playground page through the shared components.data-table component — see DataTable SDK |
| DataTable slot | datatableSlot('dashboard.widgets', 'signals.slotsdemo.countries', 80) |
The same definition rendered as a dashboard widget; the definition's own permission is re-checked at render time |
| Palette | paletteCommands() + navItem(show_in_palette) |
Two dynamic commands (Overview page, Plugins admin — palette entries are URL-only, so a modal cannot open directly from the palette) plus the static nav item |
| Recents | Overview page data provider | Signals::recent() push on page visit, guarded + best-effort |
| Notification | notification('signals.slotsdemo.account_logged') + $context->notify() in the listener |
Declared type sent to the acting user; channels resolve per user preferences |
| Provided event | event('signals.slotsdemo.logged', ['plugin' => true, 'webhook' => true]) + $context->emit() |
Plugin-owned catalogue name, webhook-subscribable, self-subscribed by SlotsDemoEchoListener |
| Hook | hook('account.updated', HookType::Event, …) |
Catalogued core event → plugin handler |
| Permission / settings / table | permission(), manifest settings[], tables[] |
Gates every surface; generated settings form drives the sample UI |
Every surface is gated on signals.slotsdemo.view, and every registrar call matches a manifest declaration (wiring ⊆ declaration). Registration order matters in one place: event() must run before the hook() that subscribes to the same name, because Event-type hook names must already exist in EventRegistry.
Install locally
php artisan signals:plugin check signals/slots-demo
php artisan signals:plugin install signals/slots-demo
php artisan signals:plugin enable signals/slots-demo
# restart PHP process, then open /plugins/signals/slots-demo/overview
Channel-provider reference — signals/slack-webhook
Path: plugins/signals/slack-webhook/
Minimal Slack incoming webhook channel plugin — the canonical example for Plugin channel providers. Config is webhook URL + optional default channel name, stored on the tenant ChannelProvider row (not plugin settings).
File tour
| Path | Role |
|---|---|
plugins/signals/slack-webhook/composer.json |
Package signals/slack-webhook, PSR-4 Signals\SlackWebhook\, extra.signals.plugin |
plugins/signals/slack-webhook/signals.yaml |
channel_drivers[] (signals.slack_webhook → logical slack), sample notifications[], network: [hooks.slack.com] |
plugins/signals/slack-webhook/src/SlackWebhookPlugin.php |
register(): permission → channelDriver() → sample notification() |
plugins/signals/slack-webhook/src/SlackWebhookChannelDriver.php |
ChannelDriver — configSchema, HTTP POST send/test |
What to copy
- Manifest gate —
channel_drivers[].keymatches$registrar->channelDriver($key, …). - Logical channel —
channel: slackmay appear innotifications[].channels. - Tenant config — secrets on
ChannelProvider, schema viaconfigSchema(). - Network allowlist — declare
hooks.slack.comeven when the driver uses the HTTP client from core delivery paths. - Tests —
tests/Feature/Plugins/SlackWebhookPluginTest.phpfakes Slack HTTP.
Install locally
php artisan signals:plugin check signals/slack-webhook
php artisan signals:plugin install signals/slack-webhook
php artisan signals:plugin enable signals/slack-webhook
# restart PHP process, then open Admin → Settings → Notifications → Channel Providers
Minimal fixture — acme-example
Path: tests/Fixtures/plugin-packages/acme-example/
Used by InstallsFixturePlugins and discovery tests. Not a production package, but a complete minimal shape:
| Path | Role |
|---|---|
tests/Fixtures/plugin-packages/acme-example/composer.json |
acme/example → Tests\Fixtures\PluginPackages\AcmeExample\AcmeExamplePlugin |
tests/Fixtures/plugin-packages/acme-example/signals.yaml |
permissions, data_access (fields[]), operations, tables, hooks (custom event + filter), slots, settings, network |
tests/Fixtures/plugin-packages/acme-example/AcmeExamplePlugin.php |
Fixture entry class |
tests/Fixtures/plugin-packages/acme-example/database/migrations/2026_07_16_100000_create_plugin_acme_example_table.php |
Valid plugin_acme_example table |
Related fixtures for negative tests:
tests/Fixtures/plugin-packages/acme-failing/— failing hook behaviourtests/Fixtures/plugin-packages/acme-badtable/— migration that creates a non-plugin_table (guard rejection)
Test helpers
| Helper | Path |
|---|---|
MakesPluginManifests |
tests/Support/Plugins/MakesPluginManifests.php |
FakesPluginHooks |
tests/Support/Plugins/FakesPluginHooks.php |
InstallsFixturePlugins |
tests/Support/Plugins/InstallsFixturePlugins.php |
$plugin = InstallsFixturePlugins::installAndEnable('acme/example');
// …
InstallsFixturePlugins::teardown($plugin);
Quickstart package
For a greenfield local plugin under plugins/{vendor}/{name}/, follow Getting started. Prefer slots-demo when you want to see every UI / notification / event surface wired in one place; prefer Xero when you need HTTP + hooks + custom fields; prefer slack-webhook when you are building a notification channel provider; prefer acme-example when you need the smallest valid fixture for automated tests.