SIGNALS Documentation
API Reference

Plugin folder structure

Canonical Signals plugin package tree — composer.json, signals.yaml, plugin_ migrations, src entry class, and how it mirrors plugins/signals/xero-sync.

Canonical tree

Every plugin is a Composer package with a package-root signals.yaml. Local packages live under plugins/; the reference implementation is vendor-nested:

plugins/signals/xero-sync/
├── composer.json          # PSR-4 + extra.signals.plugin
├── signals.yaml           # validated manifest
├── database/migrations/   # plugin_*-prefixed tables only
└── src/
    ├── XeroSyncPlugin.php # extends PluginBase
    ├── XeroClient.php
    ├── XeroContactPusher.php
    ├── XeroInvoicePusher.php
    └── Models/XeroSyncLog.php

Recommended layout for a new package acme/widget:

plugins/acme/widget/
├── composer.json
├── signals.yaml
├── database/
│   └── migrations/
│       └── 2026_07_17_120000_create_plugin_acme_widget_log_table.php
├── src/
│   ├── Acme/
│   │   └── WidgetPlugin.php   # or flat WidgetPlugin.php — match PSR-4
│   ├── Handlers/              # optional hook handlers
│   └── Clients/               # optional HTTP clients
└── tests/                     # optional package tests (or cover from app tests/)

Discovery paths

PluginDiscovery is constructed with:

  • Composer inventory: base_path('vendor/composer/installed.json')
  • Local root: base_path('plugins')

Local scan (discoverLocal):

  1. Scan immediate children of plugins/.
  2. For each child directory, try to load a package (signals.yaml + composer.json with extra.signals.plugin).
  3. If the child is not itself a package, scan one level deeper (plugins/{vendor}/{name}/).

So both of these work:

  • plugins/hello-world/ (flat)
  • plugins/acme/hello/ (vendor-nested — preferred; matches Composer acme/hello)

Composer-installed plugins are discovered from installed.json when extra.signals.plugin is set and signals.yaml exists at the install path. Local packages with the same Composer name as an already-discovered Composer package are skipped.

File roles

Path Role
composer.json Package identity (name), PSR-4 autoload, extra.signals.plugin → entry FQCN
signals.yaml Static declaration validated by ManifestValidator before plugin code runs
database/migrations/ Optional. Run only through PluginMigrationGuard on install/update
src/*Plugin.php Subclass of App\Sdk\PluginBase; implements register(PluginRegistrar $registrar)
Handlers / clients Plain PHP classes invoked from hooks or helpers — no Blade views
Eloquent models Allowed for plugin_ tables only (e.g. XeroSyncLog → plugin_xero_sync_log)

Migrations — plugin_ tables only

Declared in the manifest:

tables:
  - plugin_xero_sync_log

PluginMigrationGuard::run($path):

  1. Snapshot table names.
  2. Run pending migrations in the plugin’s database/migrations/.
  3. Diff: newly created tables must match ^plugin_[a-z][a-z0-9_]*$; core tables must not disappear.
  4. On violation → roll back the batch and throw PluginLifecycleException.

Never migrate core tables from a plugin. Extend core entities with custom fields and the guarded data facade — see Data access and Data models.

No plugin-shipped views

Plugins never ship Blade views under the package. UI is config-driven: declare a slot in the manifest, then register a core Blade/Livewire component name (or trusted HTML) via PluginRegistrar::slot(). See Plugin UI.

Autoloading

Composer-installed plugins use the application autoloader. Local packages are registered by PluginClassLoader when PluginDiscovery::instantiate() runs — it reads the package autoload.psr-4 map. Keep the extra.signals.plugin class under that namespace prefix.

Reference and fixture

Package Path
Production reference plugins/signals/xero-sync/
Minimal test fixture tests/Fixtures/plugin-packages/acme-example/

Annotated tour: Plugin examples.