SIGNALS Documentation
API Reference

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

  1. Asymmetric fields — read contact/invoice attributes; write only custom_fields.xero_*_id.
  2. Hook shape — __invoke(PluginContext $context, mixed ...$payload).
  3. Settings — encrypted client_secret, enum sync_direction.
  4. Slot — core signals.stat-card with permission signals.xero.view.
  5. Network — api.xero.com, identity.xero.com only.

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

  1. Manifest gate — channel_drivers[].key matches $registrar->channelDriver($key, …).
  2. Logical channel — channel: slack may appear in notifications[].channels.
  3. Tenant config — secrets on ChannelProvider, schema via configSchema().
  4. Network allowlist — declare hooks.slack.com even when the driver uses the HTTP client from core delivery paths.
  5. Tests — tests/Feature/Plugins/SlackWebhookPluginTest.php fakes 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 behaviour
  • tests/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.