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):
- Scan immediate children of
plugins/. - For each child directory, try to load a package (
signals.yaml+composer.jsonwithextra.signals.plugin). - 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 Composeracme/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):
- Snapshot table names.
- Run pending migrations in the plugin’s
database/migrations/. - Diff: newly created tables must match
^plugin_[a-z][a-z0-9_]*$; core tables must not disappear. - 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.