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:
- Declare a slot in
signals.yamlslots[]. - Register a core Blade component name, Livewire component name, or trusted HTML producer via
PluginRegistrar::slot(). - Optionally add navigation with
navItem(). - 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
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}(routeplugins.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 inNotificationRegistryand are seeded intonotification_typeson install/update so they appear in the user preference UI. - Delivery uses the generic queued
PluginNotificationon thenotificationsqueue. Channels are resolved per user viaNotificationChannelResolver(type defaults ∩ tenant settings ∩ preferences) — muted preferences skip send. - Guards: plugin enabled; key declared + registered;
titlerequired;urlmust 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.