Plugin manifest reference
Complete signals.yaml reference — every section validated by ManifestValidator, exact failure messages, and worked example manifests including Xero Sync.
Overview
Parsed into App\Sdk\Manifest\PluginManifest and validated by App\Sdk\Manifest\ManifestValidator. Rule keys below are the keys on ManifestValidationException::$errors.
Normalised sections: package, name, version, signals_version, requires, conflicts, permissions, data_access, tables, hooks, slots, settings, network, operations, nav, pages, datatables, notifications, channel_drivers, events, tools.
Cross-link: field allowlists → Data models; runtime enforcement → Data access.
Validate interactively: the Manifest validator & builder runs any pasted signals.yaml through this exact validator (and builds new manifests section by section), reporting failures under the rule keys below.
File / parse errors (before validator)
| Key | Message |
|---|---|
manifest.file |
Manifest file not found: {$path} |
manifest.file |
Manifest file could not be read: {$path} |
manifest.parse |
Manifest YAML could not be parsed: + exception message |
manifest.parse |
Manifest YAML must parse to a mapping. |
Identity
| Field | Required | Rule key |
|---|---|---|
package |
yes | identity.required / identity.package.format |
name |
yes | identity.required |
version |
yes | identity.required / identity.version.semver |
signals_version |
yes | identity.required / identity.signals_version.constraint |
icon |
no | identity.icon.format |
| Key | Exact message |
|---|---|
identity.required |
Manifest identity is missing required field(s): {comma-separated}. |
identity.package.format |
Package [{$manifest->package}] must be a Composer name in vendor/name format. |
identity.version.semver |
Version [{$manifest->version}] must be a valid semantic version (e.g. 1.2.0). |
identity.signals_version.constraint |
signals_version [{$manifest->signals_version}] is not a well-formed version constraint. |
identity.icon.format |
icon [{$manifest->icon}] must be a kebab-case flux icon name (e.g. bolt, puzzle-piece). |
signals_version is constrained against config('plugins.sdk_version') (currently 1.0.0) at lifecycle time.
icon is the plugin's identity icon — a kebab-case flux/heroicon name (e.g. bolt, puzzle-piece). It is shown in the Plugin Manager and anywhere the plugin is referenced (dropdowns, titles), and serves as the fallback when nav.icon is omitted.
requires / conflicts
Maps of package => constraint. Same validation; rule keys differ.
| Key | Exact message |
|---|---|
requires.constraint / conflicts.constraint |
Dependency package [{$package}] must be a Composer name in vendor/name format. |
| same | Dependency constraint [{$constraint}] for [{$package}] is not well-formed. |
requires:
acme/helper: "^1.0"
conflicts:
rival/xero-legacy: "*"
permissions
List of resource.action strings. Every key must be prefixed with {vendor}. where vendor is the first Composer segment of package (e.g. signals/xero-sync → signals.).
Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
| Key | Exact message |
|---|---|
permissions.namespace |
Permission entries must be non-empty resource.action strings. |
permissions.namespace |
Permission [{$permission}] must be namespaced with vendor prefix [{$prefix}]. |
permissions.namespace |
Permission [{$permission}] must use resource.action format under the vendor prefix. |
Wiring via $registrar->permission($key, …) is gated: key must appear here.
data_access
Per-entity allowlist for Signals::read() / Signals::update(). Allowed operations: read, write, delete.
| Key | Exact message |
|---|---|
data_access.malformed |
data_access[{$index}] is missing a non-empty entity. |
data_access.malformed |
data_access[{$index}] must declare at least one operation. |
data_access.malformed |
data_access[{$index}] operation [{$operation}] is invalid; allowed: read, write, delete. |
data_access.malformed |
{$path} contains an empty field name. (when validating fields[]) |
data_access.read_fields |
data_access[{$index}].read_fields must be a non-empty list when present. |
data_access.read_fields |
{path} contains an empty field name. |
data_access.write_fields |
data_access[{$index}].write_fields must be a non-empty list when present. |
data_access.write_fields |
{path} contains an empty field name. |
data_access.fields |
data_access[{$index}] has no field list for [{$operation}]; declare fields[] or {$operation}_fields. |
Field list resolution (ManifestDataAccess::fieldsFor):
- When
read_fieldsis present (non-null) it fully defines the read allowlist. - When
write_fieldsis present it fully defines the write allowlist. - Otherwise
fields[]applies for that operation.
Custom-field declaration matching (enforced at runtime by PluginDataAccess, not the YAML field-name grammar):
- Nested custom fields are written under attribute key
custom_fields(flat map). - Nested key
xis allowed when the effective list declarescustom_fields(wholesale) orcustom_fields.x. - Any undeclared top-level or nested key rejects the whole update.
Known entities: accounts, rentals, invoices, catalogue_items — see Data models.
tables
| Key | Exact message |
|---|---|
tables.prefix |
Table [{$table}] must start with plugin_ and use snake_case (e.g. plugin_acme_log). |
Pattern: /^plugin_[a-z][a-z0-9_]*$/
hooks
| Key | Exact message |
|---|---|
hooks.malformed |
hooks[{$index}] requires non-empty name and type. |
Fields: name, type (e.g. event, filter), optional priority (runtime default 50). The validator does not enum-check type against HookType.
slots
| Key | Exact message |
|---|---|
slots.malformed |
slots[{$index}] requires non-empty slot and component. |
Optional: priority, permission.
settings
Allowed types: string, integer, boolean, enum, json.
| Key | Exact message |
|---|---|
settings.malformed |
settings[{$index}] requires non-empty key, type, and label. |
settings.malformed |
settings[{$index}] type [{$setting->type}] is invalid; allowed: string, integer, boolean, enum, json. |
settings.malformed |
settings[{$index}] of type enum requires a non-empty options list. |
settings.malformed |
settings[{$index}] rules must be a list of non-empty strings. |
Accepted on the DTO but not validated by ManifestValidator: encrypted, default (runtime in PluginSettingsManager).
Storage group: plugin_ + package with / and - → _ (e.g. signals/xero-sync → plugin_signals_xero_sync). See Plugin settings.
network
Bare hostnames only — no wildcards, schemes, paths, or ports.
| Key | Exact message |
|---|---|
network.wildcard |
Network allowlist entries must be concrete hostnames. |
network.wildcard |
Network allowlist entry [{$host}] must not contain wildcards; declare a concrete host. |
network.wildcard |
Network allowlist entry [{$host}] must be a bare hostname (no scheme, path, or port). |
network.wildcard |
Network allowlist entry [{$host}] is not a valid hostname. |
operations
Names for Signals::operation(). Format resource.action.
| Key | Exact message |
|---|---|
operations.malformed |
operations[{$index}] must be a non-empty operation name string. |
operations.malformed |
operations[{$index}] [{$operation}] must use resource.action format (e.g. activities.complete). |
Must also be mapped in PluginOperationRegistry. Seeded today: activities.create, activities.complete, favourites.toggle. See Plugin Operation Registry.
nav
Top-level nav group (one per plugin) shown in the app header after Finance, with a matching group sidebar on the plugin's UI pages. Items reference declared pages[] keys and are wired at runtime via PluginRegistrar::navGroup().
nav:
group: Field Ops # header group label
icon: map # kebab-case flux icon name
items:
- key: jobs # slug, unique within the plugin
label: Jobs
page: jobs # references pages[].key
permission: acme.fieldops.view # optional; gates header item, sidebar item, and page
icon: briefcase # optional; falls back to the group icon
| Key | Exact message |
|---|---|
nav.malformed |
nav requires a non-empty group label. |
nav.malformed |
nav icon is required unless a top-level plugin icon is declared as the fallback. |
nav.malformed |
nav icon [{$icon}] must be a kebab-case flux icon name (e.g. map, puzzle-piece). |
nav.malformed |
nav must declare at least one item. |
nav.items |
nav.items[{$index}] key [{$key}] must be a lowercase slug. |
nav.items |
nav.items[{$index}] requires a non-empty label. |
nav.items |
nav.items[{$index}] key [{$key}] is declared more than once; item keys must be unique. |
nav.items |
nav.items[{$index}] icon [{$icon}] must be a kebab-case flux icon name. |
nav.page_ref |
nav.items[{$index}] page [{$page}] does not reference a declared pages[] key. |
pages
Config-driven UI pages rendered by core components only — no plugin views. Each page renders at /plugins/{vendor}/{name}/{key} (the Composer package split into two path segments). Section data is supplied at runtime by PluginRegistrar::page($key, $dataProvider).
pages:
- key: jobs
title: Jobs
description: Field jobs overview
layout: console # only 'console' in v1
sections:
- type: stat-grid # rows of core stat cards
- type: table # columns + rows, values escaped
- type: html # trusted HTML string (same trust model as Html slots)
- type: datatable # a declared datatables[] key, rendered by the shared DataTable
datatable: acme.fieldops.jobs
| Key | Exact message |
|---|---|
pages.malformed |
pages[{$index}] key [{$key}] must be a lowercase slug (e.g. jobs, sync-status). |
pages.malformed |
pages[{$index}] requires a non-empty title. |
pages.malformed |
pages[{$index}] layout [{$layout}] is invalid; allowed: console. |
pages.key_unique |
pages[{$index}] key [{$key}] is declared more than once; page keys must be unique. |
pages.sections |
pages[{$index}] must declare at least one section. |
pages.sections |
pages[{$index}].sections[{$sectionIndex}] type [{$type}] is invalid; allowed: stat-grid, table, html, datatable. |
pages.sections |
pages[{$index}].sections[{$sectionIndex}] of type [datatable] must name a declared datatables[] key. |
pages.sections |
pages[{$index}].sections[{$sectionIndex}] references undeclared datatable [{$key}]. |
A datatable section takes no data from the page's provider — the registered definition is its content. It is skipped for a viewer without the definition's permission, so sibling sections keep rendering.
See Plugin UI for the runtime wiring and section data shapes.
datatables
Listing tables the plugin may register with PluginRegistrar::datatable(). Keys are global — page sections and slots reference a table by key alone — so they must be dotted and vendor-prefixed exactly like permissions. See DataTable SDK and Plugin Data Table Registry.
datatables:
- key: acme.fieldops.jobs
permission: acme.fieldops.view # optional here, mandatory on the definition
description: Open field jobs.
| Key | Exact message |
|---|---|
datatables.malformed |
datatables[{$index}] key [{$key}] must use dotted resource.name format (e.g. acme.jobs.table). |
datatables.malformed |
datatables[{$index}] key [{$key}] must be namespaced with vendor prefix [{$prefix}]. |
datatables.malformed |
datatables[{$index}] permission must be a non-empty permission key when declared. |
datatables.key_unique |
datatables[{$index}] key [{$key}] is declared more than once; datatable keys must be unique. |
The declaration is the gate: datatable() refuses a key that is not listed here, and when permission is declared the registered definition must gate on exactly that permission. A permission is mandatory on the definition in every case.
notifications
Types the plugin may send via Signals::notify(). Keys must be vendor-prefixed like permissions. Channels (when present) are a non-empty subset of core channels (database, mail, broadcast) or logical channels declared by the same plugin in channel_drivers.
notifications:
- key: acme.sync.failed
label: Sync Failed
group: Acme
channels: [database, mail, slack]
description: Fired when a sync fails.
| Key | Exact message |
|---|---|
notifications.malformed |
notifications[{$index}] requires non-empty key and label. |
notifications.malformed |
notifications[{$index}] channels must be a non-empty subset of core channels (database, mail, broadcast) or channels declared in channel_drivers[] when present. |
notifications.malformed |
notifications[{$index}] channel [{$channel}] is invalid; allowed: {allowed list}. |
notifications.namespace |
Notification [{$key}] must be namespaced with vendor prefix [{$prefix}]. |
notifications.namespace |
Notification [{$key}] must use resource.action format under the vendor prefix. |
Wiring via $registrar->notification($key, …) is gated: key must appear here. See Plugin UI.
channel_drivers
Notification channel drivers the plugin may register via $registrar->channelDriver($key, $class). Keys must be vendor-prefixed like permissions. Guide: Plugin channel providers.
channel_drivers:
- key: acme.slack_webhook
channel: slack
label: Slack Incoming Webhook
config_fields:
- webhook_url
- default_channel
| Key | Exact message |
|---|---|
channel_drivers.malformed |
channel_drivers[{$index}] requires non-empty key, channel, and label. |
channel_drivers.malformed |
channel_drivers[{$index}] channel [{$channel}] must be a lowercase slug (e.g. slack, teams). |
channel_drivers.malformed |
channel_drivers[{$index}] channel [{$channel}] collides with a core notification channel. |
channel_drivers.malformed |
channel_drivers[{$index}] config_fields must be a non-empty list when present. |
channel_drivers.malformed |
channel_drivers[{$index}] config_fields must be a list of non-empty strings. |
channel_drivers.key_unique |
channel_drivers[{$index}] key [{$key}] is declared more than once; driver keys must be unique. |
channel_drivers.namespace |
Channel driver [{$key}] must be namespaced with vendor prefix [{$prefix}]. |
channel_drivers.namespace |
Channel driver [{$key}] must use resource.action format under the vendor prefix. |
Wiring via $registrar->channelDriver($key, …) is gated: key must appear here.
tools
Optional MCP / CLI tools. Names must be vendor-prefixed like permissions. Wired at runtime via PluginRegistrar::tool(). See Plugin MCP / CLI tools and Plugin Tool Registry.
| Rule key | When it fires |
|---|---|
tools.malformed |
tools[{$index}] missing name, title, action, or permission |
tools.namespace |
Tool name missing vendor prefix / invalid format |
tools.name_unique |
Duplicate tool name |
tools.action |
Action is not a fully-qualified class name |
tools.permission |
Permission is neither declared on the plugin nor a registered core permission |
tools.surfaces |
Empty or unknown surface (allowed: mcp, cli) |
tools.args |
Arg missing name/type or unknown type (string, integer, number, boolean) |
events
Plugin-owned EventRegistry names for event() / webhookEvent() / Signals::emit(). Names must be vendor-prefixed (e.g. acme.sync.completed).
events:
- name: acme.sync.completed
label: Sync Completed
webhook: true
| Key | Exact message |
|---|---|
events.malformed |
events[{$index}] requires a non-empty name. |
events.namespace |
Event [{$name}] must be namespaced with vendor prefix [{$prefix}]. |
events.namespace |
Event [{$name}] must use dotted resource.action format under the vendor prefix (e.g. acme.sync.completed). |
Wiring via $registrar->event($name, …) / webhookEvent($name) is gated: name must appear here; webhook visibility also requires webhook: true. See Plugin events.
Example A — minimal valid
package: acme/hello
name: Acme Hello
version: 0.1.0
signals_version: "^1.0"
permissions:
- acme.hello.view
slots:
- slot: dashboard.widgets
component: signals.stat-card
priority: 90
permission: acme.hello.view
settings:
- key: greeting
type: string
label: Greeting
default: Hello
network: []
Example B — Xero Sync (annotated)
Source: plugins/signals/xero-sync/signals.yaml.
package: signals/xero-sync # identity.package — Composer name
name: Xero Sync
version: 0.1.0 # SemVer
signals_version: "^1.0" # constraint vs config plugins.sdk_version
permissions: # vendor prefix signals.
- signals.xero.sync
- signals.xero.view
data_access:
- entity: accounts
read_fields: # asymmetric read allowlist
- name
- email # virtual field (primary email sync)
- custom_fields.xero_contact_id
write_fields: # write only the Xero id CF
- custom_fields.xero_contact_id
operations:
- read
- write
- entity: invoices
read_fields:
- number
- total
- currency_code
- account_id
- custom_fields.xero_invoice_id
write_fields:
- custom_fields.xero_invoice_id
operations:
- read
- write
operations: [] # no Signals::operation() names
tables:
- plugin_xero_sync_log # must match migration-created table
hooks:
- name: invoice.issued # EventRegistry + AuditableEvent
type: event
priority: 50
- name: account.updated
type: event
priority: 50
slots:
- slot: dashboard.widgets
component: signals.stat-card # core Blade component
priority: 70
permission: signals.xero.view
settings:
- key: client_id
type: string
label: Xero Client ID
rules: [nullable, string, max:255]
- key: client_secret
type: string
label: Xero Client Secret
encrypted: true # leave-blank-to-keep on save
- key: tenant_id
type: string
label: Xero Tenant ID
rules: [nullable, string, max:255]
- key: sync_direction
type: enum
label: Sync Direction
options: [push, pull, both]
default: push
network: # exact hosts for PluginHttpClient
- api.xero.com
- identity.xero.com
Example C — recipes
UI-only plugin
package: acme/dashboard-tip
name: Dashboard Tip
version: 1.0.0
signals_version: "^1.0"
permissions:
- acme.dashboard_tip.view
slots:
- slot: dashboard.widgets
component: signals.stat-card
permission: acme.dashboard_tip.view
settings:
- key: tip_text
type: string
label: Tip text
default: Remember to confirm returns
network: []
Data-sync plugin
package: acme/crm-push
name: CRM Push
version: 1.0.0
signals_version: "^1.0"
permissions:
- acme.crm_push.sync
data_access:
- entity: accounts
read_fields: [name, email, custom_fields.external_id]
write_fields: [custom_fields.external_id]
operations: [read, write]
hooks:
- name: account.updated
type: event
priority: 50
network:
- api.partner.example
settings:
- key: api_token
type: string
label: API token
encrypted: true
Import/export extension plugin
Import/export registrar seams (importableModel, exportableModel, importTransform, planTemplate) are not on this branch. Declare data_access + hooks as above for sync-style work today; see Import/export plugins for the merge note and Coming soon.
Machine-readable specification (JSON Schema)
The manifest format is published as a versioned JSON Schema — the public
standard for signals.yaml — at:
/schemas/signals-plugin-manifest.v1.json
On a running Signals instance the schema is served statically (no
authentication) at https://{your-host}/schemas/signals-plugin-manifest.v1.json,
and it lives in the repository at public/schemas/signals-plugin-manifest.v1.json.
Use it to validate manifests in editors (# yaml-language-server: $schema=...),
CI pipelines, or LLM toolchains. Two rules cannot be expressed in JSON Schema
and are enforced only by the framework validator: the vendor-prefix requirement
on permission keys (permissions.namespace) and the wiring ⊆ declaration rule.
php artisan signals:plugin check {package} runs the authoritative validator
and reports failures using the same rule keys the schema documents.
For the authoritative validator over HTTP — no checkout required — POST /docs/tools/manifest-validate with {"yaml": "..."} (public, rate-limited;
response shape documented in the LLM guide),
or use the interactive Manifest validator & builder.