SIGNALS Documentation
API Reference

Plugin UI

Config-driven plugin UI — slot types, isolation, permission gating, live slot names, modal slots, palette providers, recents API, notifications, navItem(), generated settings form, and the no-plugin-views rule.

Philosophy — no plugin-shipped views

Plugins never ship Blade views. UI extension is config-driven:

  1. Declare a slot in signals.yaml slots[].
  2. Register a core Blade component name, Livewire component name, or trusted HTML producer via PluginRegistrar::slot().
  3. Optionally add navigation with navItem().
  4. Settings UI is generated from the manifest by core (PluginSettingsForm).

Slot component types

App\Sdk\Slots\SlotComponentType:

Case Value Meaning
View view Core Blade component name + data callable → props array
Html html Callable returning a trusted HTML string
Livewire livewire Core Livewire component name + params callable
public function slot(
    string $slot,
    SlotComponentType $type,
    string $component,
    ?callable $data = null,
    int $priority = 50,
    ?string $permission = null,
): self

Manifest gate: slot + component pair must match an entry in slots[].

Live slot names

Documented in the SlotRegistry docblock and wired into core Blade hosts:

Slot Host view
rental.detail.header_actions resources/views/livewire/rentals/partials/rental-header.blade.php
rental.detail.tabs …/rentals/partials/rental-tabs.blade.php
account.detail.header_actions resources/views/livewire/accounts/partials/account-header.blade.php
invoice.detail.header_actions resources/views/livewire/invoices/show.blade.php
activity.detail.header_actions resources/views/livewire/activities/show.blade.php
catalogue_item.detail.header_actions …/catalogue_items/partials/product-header.blade.php
catalogue_item.detail.tabs …/catalogue_items/partials/product-tabs.blade.php
asset.detail.header_actions resources/views/livewire/assets/show.blade.php
virtual_stock.detail.header_actions resources/views/livewire/virtual-stock/show.blade.php
asset_retirement.detail.header_actions resources/views/livewire/asset-retirements/show.blade.php
warehouse_transfer.detail.header_actions resources/views/livewire/warehouse-transfers/show.blade.php
fleet_vehicle.detail.header_actions …/fleet/partials/vehicle-header.blade.php
fleet_vehicle.detail.tabs …/fleet/partials/vehicle-tabs.blade.php
flightcase.detail.header_actions resources/views/livewire/flightcases/show.blade.php
stock_check.detail.header_actions resources/views/livewire/stock-checks/show.blade.php
equipment_test.detail.header_actions resources/views/livewire/equipment_tests/show.blade.php
equipment_test_result.detail.header_actions resources/views/livewire/equipment-test-results/show.blade.php
repair.detail.header_actions resources/views/livewire/repairs/show.blade.php
purchase_order.detail.header_actions resources/views/livewire/purchase-orders/show.blade.php
catalogue_item_groups.index.actions …/catalogue-item-groups/partials/toolbar.blade.php
credit_notes.index.actions …/credit-notes/partials/toolbar.blade.php
payments.index.actions …/payments/partials/toolbar.blade.php
dispatch.board.actions resources/views/livewire/dispatch/board.blade.php
returns.board.actions resources/views/livewire/returns/board.blade.php
scanning.console.actions resources/views/livewire/scanning/console.blade.php
notifications.panel.actions resources/views/livewire/notifications/pane.blade.php
dashboard.widgets resources/views/livewire/dashboard/dashboard-board.blade.php
modals.global resources/views/components/layouts/app/header.blade.php (once, beside the confirm modal)

There is no DB seed of slot names — the registry is in-memory at boot. Additional hosts may be added in core over time; declare only slots that exist in a Blade host.

Blade host

{{-- Free-form surfaces (widgets, boards, modals, index toolbars) --}}
<x-signals.plugin-slot name="dashboard.widgets" :context="[]" />

{{-- Detail tabs: native strip links + panel host at /{entity}/{id}/tabs/{key} --}}
<x-signals.module-tabs
    :tabs="$coreTabs"
    :active="$activeTab"
    plugin-slot="rental.detail.tabs"
    :plugin-context="['rental' => $rental, 'activeTab' => $activeTab]"
    plugin-tab-route-name="rentals.plugin-tab"
    :plugin-tab-route-params="[$rental]"
/>

{{-- Detail header actions: merge into the page Actions menu --}}
<x-signals.actions-menu
    size="sm"
    plugin-slot="rental.detail.header_actions"
    :plugin-context="['rental' => $rental]"
>
    {{-- core actions --}}
</x-signals.actions-menu>

Detail tab Html should return either a panel HTML string or ['key' => '…', 'label' => '…', 'content' => '…']. The host builds the subnav-link and renders content in the entity tab panel via <x-signals.plugin-tab-panel>. Detail header-action Html should emit s-dropdown-item markup so those look native inside the Actions menu.

Isolation

SlotRenderer::render() catches per-component Throwable, logs, and continues with sibling components — one failing plugin does not blank the page.

Priorities: ascending (lower first), default 50 — same ordering idea as hooks.

Permission gating

If permission is set on the registration (or declared on the manifest slot), SlotRenderer skips the component unless Auth::user()?->can($permission).

Xero example:

$registrar->slot(
    'dashboard.widgets',
    SlotComponentType::View,
    'signals.stat-card',
    static function (array $context) use ($package): array { /* props */ },
    70,
    'signals.xero.view',
);

Global modal slots

Plugins may register modals on the reserved slot modals.global. Core renders that slot once in the app shell (beside the confirm modal). Declare the slot in the existing slots[] section — no new manifest section:

slots:
  - slot: modals.global
    component: signals.plugin-modal   # or a custom Html component name
// Config-driven Flux modal (core component)
$registrar->slot(
    'modals.global',
    SlotComponentType::View,
    'signals.plugin-modal',
    static fn (): array => [
        'name' => 'acme-jobs-modal',   // unique Flux modal name
        'title' => 'Field jobs',
        'sections' => [
            ['type' => 'stat-grid', 'data' => [['label' => 'Open', 'value' => '4', 'color' => 'green']]],
            ['type' => 'table', 'data' => ['columns' => ['Job'], 'rows' => [['Install']]]],
            ['type' => 'html', 'data' => '<p>Trusted HTML</p>'],
        ],
    ],
    50,
    'acme.example.view',
);

// Or fully custom markup (same Html trust model as other slots)
$registrar->slot(
    'modals.global',
    SlotComponentType::Html,
    'AcmeCustomModal',
    static fn (): string => '<div data-flux-modal>…</div>',
);

Open a modal from any plugin surface (header action Html slot, page section, palette entry) with a plain Flux dispatch:

<button type="button" x-on:click="$dispatch('modal-show', { name: 'acme-jobs-modal' })">
  Open jobs
</button>

Or from JavaScript: window.Flux.modal('acme-jobs-modal').show().

Navigation is not a slot:

public function navItem(array|NavigationItem $item): self

Not manifest-gated. Use for sidebar / admin nav entries that point at core routes or external URLs as supported by NavigationItem.

Set show_in_palette => true to include the item in the command palette (via NavigationService::paletteCommandsFor()). Plugin enable/disable busts the navigation:* cache so sidebar lists stay fresh on the next boot cycle.

Command palette providers

For dynamic palette entries (not static navItems), register a provider:

$registrar->paletteCommands(static fn (): array => [
    [
        'key' => 'acme.open-jobs',
        'label' => 'Open Acme Jobs',
        'url' => '/plugins/acme/example/jobs',
        'icon' => 'briefcase',          // optional
        'group' => 'Acme',              // optional — defaults to plugin name
        'keywords' => 'jobs field',     // optional
        'permission' => 'acme.example.view', // null or a declared permissions[] key
    ],
]);

There is no new manifest section for palette providers — they are gated only by the plugin being enabled (disabled packages never boot). When the registrar is scoped, each command's permission must be null or one of the plugin's declared permissions[]; undeclared permissions are skipped (logged) at query time. Providers are failure-isolated like slots.

Entries merge into NavigationService::paletteCommandsFor() at query time (not cached). That resolution happens only when the palette is actually opened — the UI fetches GET /palette/commands on first open — so a provider is never invoked during an ordinary page render.

Recents API

Push into the same recently-viewed store core uses (recently_viewed_items → morph PluginRecentEntry):

Signals::recent('acme/example', [
    'label' => 'Job #12',
    'url' => '/plugins/acme/example/jobs', // must start with /
    'icon' => 'briefcase',                 // optional
    'type' => 'Acme Example',              // optional — defaults to plugin name
]);

// From a hook handler:
$context->recent(['label' => 'Job #12', 'url' => '/plugins/acme/example/jobs']);

Guards: plugin must be enabled; URL must be an internal path starting with / (external / protocol-relative URLs rejected). Entries appear in the global Recent menu (GlobalRecent), capped at the same read limit (25).

Top-level nav groups & plugin pages

A plugin may declare one top-level nav group (nav) plus config-driven UI pages (pages[]) in signals.yaml — see the manifest reference. Runtime wiring is manifest-gated:

// Group must match nav.group / nav.icon; every item must match a declared nav.items[] entry.
$registrar->navGroup('Field Ops', 'map', [
    ['key' => 'jobs', 'label' => 'Jobs', 'page' => 'jobs', 'permission' => 'acme.fieldops.view'],
]);

// Key must match a declared pages[] entry; the provider supplies section data
// keyed by section index (or section type as a fallback).
$registrar->page('jobs', fn (array $context): array => [
    // sections[0] type stat-grid → list of ['label', 'value', 'color' (blue|green|amber|violet)]
    [['label' => 'Open Jobs', 'value' => '12', 'color' => 'green']],
    // sections[1] type table → ['columns' => list<string>, 'rows' => list<list<scalar>>] (values escaped)
    ['columns' => ['Job', 'Status'], 'rows' => [['Install rig', 'Scheduled']]],
    // sections[2] type html → trusted HTML string (same trust model as Html slots)
    '<p>Summary</p>',
]);

page() requires a plugin-scoped registrar — page metadata (title, layout, sections) comes exclusively from the manifest.

datatable sections

A fourth section type, datatable, renders a registered listing table instead of provider data:

pages:
  - key: jobs
    title: Jobs
    sections:
      - type: stat-grid
      - type: datatable
        datatable: acme.fieldops.jobs   # a declared datatables[] key

The definition is the content, so the page's data provider supplies nothing for that index. Core mounts the shared components.data-table Livewire component with the definition's params, skips the section entirely for a viewer without the definition's permission, and logs-and-skips an unknown or unnamed key rather than breaking the page. Register the table with PluginRegistrar::datatable(), or render the same definition in a UI slot with datatableSlot() — see DataTable SDK.

Runtime behaviour:

  • Pages render at /plugins/{vendor}/{name}/{page} (route plugins.page) inside the console shell, with a group sidebar showing the same items (same order, same permissions) as the header dropdown.
  • The header group appears after Finance; each item is permission-gated and a group with no visible items is hidden.
  • Disabled plugin → 404; missing item permission → 403; a throwing data provider is logged and renders empty sections (failure isolation, like slots).

Notifications

Plugins declare sendable types in signals.yaml notifications[] (vendor-prefixed keys; channels ⊂ database / mail / broadcast). Rules: notifications.namespace, notifications.malformed.

notifications:
  - key: acme.sync.failed
    label: Sync Failed
    group: Acme
    channels: [database, mail]
    description: Fired when a sync fails.
$registrar->notification('acme.sync.failed', [
    'label' => 'Sync Failed',
    'group' => 'Acme',
    'channels' => ['database', 'mail'],
    'description' => 'Fired when a sync fails.',
]);

Signals::notify('acme/example', 'acme.sync.failed', $user, [
    'title' => 'Sync failed',
    'body' => 'Could not reach API.',
    'url' => '/plugins/acme/example/jobs', // internal path only
]);

// From a hook handler:
$context->notify('acme.sync.failed', $user, ['title' => 'Sync failed']);
  • Registrar wiring ⊆ notifications[]. Types land in NotificationRegistry and are seeded into notification_types on install/update so they appear in the user preference UI.
  • Delivery uses the generic queued PluginNotification on the notifications queue. Channels are resolved per user via NotificationChannelResolver (type defaults ∩ tenant settings ∩ preferences) — muted preferences skip send.
  • Guards: plugin enabled; key declared + registered; title required; url must start with /; inactive users skipped.

Generated settings form

Manifest settings[] drive Livewire PluginSettingsForm on the Plugins admin page. See Plugin settings.

Admin page presence

Route: /admin/settings/plugins (admin.settings.plugins) — Livewire PluginManager.

Tabs include installed package overview, permissions, data access, hooks, slots, settings, and tables, plus a read-only manifest viewer. Requires manage on Plugin.

Contract: Slot Registry.

Worked example

plugins/signals/slots-demo/ wires every surface on this page — one slot from each family, the global modal + open snippet, a nav group with two pages, a palette provider, a recents push, and a sendable notification. Annotated tour: Plugin examples.