Lifecycle & Discovery
This page explains exactly what happens between a plugin directory existing on disk and its hooks running.
The short version
glob plugins/*/plugin.json
-> parse and validate each manifest
-> topological sort by requires.plugins
-> read statuses.json
-> register PSR-4 for active plugins
-> instantiate and boot active plugins in order
-> wire routes/views/migrations/lang/config for booted plugins
Each step records errors per plugin. A failure isolates that plugin; it does not stop the others.
Discovery
PluginManager::discover() runs:
foreach (glob($pluginsDir . '/*/plugin.json') as $file) {
$dir = basename(dirname($file));
if ($dir === 'framework') {
continue;
}
// parse manifest, record errors per directory
}
Notes:
- Only
plugins/<dir>/plugin.jsonis scanned. A deeper manifest is ignored. plugins/frameworkis always skipped.- The directory name and the manifest
slugcan differ, but they should match. Errors are keyed by directory when the manifest cannot be parsed, and by slug once it can. plugins/statuses.jsonis not a plugin because it is a file, not a directory.
Manifest parsing
Plugin::fromJsonFile() decodes JSON and calls Plugin::fromArray(). It validates:
slugexists, is a string, and matches^[a-z0-9][a-z0-9-]*$.nameexists and is non-empty.versionmatches^\d+(\.\d+){0,3}$.entry.fileandentry.classare non-empty.requiresis an object if present.autoload.psr-4contains string keys and values.xilancerMetaDatais an object if present.legacyNameis a string if present.
A parse failure records the message in errors()[<directory>] and the plugin is skipped.
Dependency ordering
The manager performs a depth-first topological sort over requires.plugins:
- Visit each plugin.
- If it is already ordered, stop.
- If it is currently being visited, record
circular plugin dependency. - If a dependency is missing, record
missing dependency: <slug>. - Visit the dependency first.
- If the dependency has an error, propagate the skip.
- Check the dependency's version against the constraint.
- Add the plugin to the ordered list.
The result is a boot order where dependencies come before dependents. Unrelated plugins keep discovery order.
Dependency errors
| Error | Cause |
|---|---|
missing dependency: referrals | The dependency is not installed |
unmet dependency: referrals >=2.0 (have 1.4.0) | Installed, wrong version |
circular plugin dependency | Two or more plugins require each other |
required by another plugin but not installed | A dependency slot references a missing plugin |
A plugin with an error is not booted, even if it is marked active. Its error appears in plugins list
and in the admin plugin screen.
Environment checks
Before boot, each plugin is checked against:
requires.frameworkagainstPluginFramework::VERSION.requires.phpagainstPHP_VERSION.
A mismatch records an error such as:
requires framework ^2.0 (have 1.0.0)
requires php >=8.4 (have 8.3.12)
The plugin stays inactive for that request.
Activation state
plugins/statuses.json is a flat map:
{
"coupons": true,
"referrals": true,
"nda": true
}
Rules:
- Missing file means no plugins are active.
- A slug is active only when its value is exactly
true. - Deactivation removes the key.
- The file lives outside
core/, so a core update does not overwrite it. - Back it up before an upgrade.
plugin_statuses() caches the decoded file per request. plugin_is_active($slug) is the fast check
used by core and theme code.
Autoload registration
For each active plugin, the bridge calls:
$loader->addPsr4($namespace, $pluginPath . '/' . $relative);
This happens before boot(), so sibling classes resolve normally. The manager itself stays
dependency-free by accepting a registrar callback.
If Composer's loader is unavailable, autoload registration is skipped and boot may fail with a class not found error. The validator warns about this case.
Boot
PluginManager::boot():
$context = new PluginContext($hooks, $slots, $registry);
foreach ($ordered as $slug => $plugin) {
if (!$this->isActive($slug) || isset($this->errors[$slug])) {
continue;
}
if (!$this->checkEnvironment($plugin)) {
continue;
}
require_once $entryFile;
$instance = new $entryClass($context);
$instance->boot();
$this->booted[] = $slug;
}
Key points:
- One
PluginContextinstance is shared by all plugins. Hooks, slots, and registry are global. boot()runs on every request, every CLI command, and every queue worker.boot()must be fast and side-effect free. Register callbacks; do not query or call out.booted()returns the slugs that succeeded. Asset wiring uses this list.- A throwing
boot()records the error and the plugin is not added tobooted().
Asset wiring
For each slug in booted(), PluginBridgeServiceProvider::registerPluginAsset() wires:
| Path | Action |
|---|---|
routes/web.php | Route::middleware('web')->group($file) |
routes/api.php | Route::middleware('api')->group($file) |
views/ | loadViewsFrom($path, $slug) |
views/ with legacyName | Also loadViewsFrom($path, strtolower($legacyName)) |
database/migrations/ | loadMigrationsFrom($path) |
lang/ | loadTranslationsFrom($path, $slug) |
lang/ with legacyName | Also legacy namespace |
config/<slug>.php | mergeConfigFrom($file, 'plugins.' . $slug) |
config/<legacy>.php | Merged under strtolower($legacyName) |
Asset wiring is per booted plugin, inside a try/catch. One plugin's bad route file does not stop the others.
Activation
CLI
php core/artisan plugins activate my-plugin
The command:
- Runs
PluginValidator::validate($slug, runBootTest: true). - Aborts on errors.
- Runs
artisan migrate --path=<plugin>/database/migrations --realpath --force. - Aborts on migration failure.
- Writes
statuses.json. - Clears the route cache.
- Reminds you to restart queue workers.
Admin uploader
Admin Plugin Manage → Upload Plugin does the same sequence:
- Accept the zip.
- Extract safely, rejecting path traversal.
- Find
plugin.jsonat the root or one folder deep. - Move the plugin into
plugins/<slug>. - Validate.
- Run migrations.
- Activate.
- Clear route, view, and config caches.
Re-uploading replaces the plugin directory. Back up any plugin-owned storage first.
Deactivation
php core/artisan plugins deactivate my-plugin
This removes the slug from statuses.json and clears the route cache. It does not drop tables or
delete data. The plugin's routes stop loading on the next request.
Failure isolation matrix
| Failure | Effect | Where it is reported |
|---|---|---|
| Invalid JSON | Plugin skipped | plugins list, admin list |
| Missing entry file | Plugin skipped | Validator, plugins list |
| PHP syntax error | Plugin skipped | Validator |
| Wrong PHP version | Plugin skipped | plugins list |
| Missing dependency | Plugin skipped | plugins list |
| Circular dependency | Both plugins skipped | plugins list |
Throwing boot() | Plugin not booted | PluginManager::errors() |
| Throwing action | Caught by plugin_do_action() | report() / log |
| Throwing filter | Caught; input returned | report() / log |
| Throwing slot | Renderer skipped | SlotRenderer::lastErrors() |
| Bad route file | Other assets still load | report() / log |
| Migration failure | Activation aborted | Command output |
Long-running runtimes
The bridge boots plugins once per process. That is correct for php-fpm, where each request is a fresh process state. It is not correct for Octane, RoadRunner, or a long-running queue worker that starts before activation.
Rules:
- Restart workers after activate or deactivate.
- If you use Octane, boot plugins per request or restart the worker pool on deploy.
- Do not store per-request state in static properties. Use the request lifecycle.
- Coupons' quote attribution assumes per-request instances; do not move it to a singleton.
Debugging a plugin that did not boot
- Run
php core/artisan plugins list. Read theproblemcolumn. - Run
php core/artisan plugins validate <slug>. Read every error and warning. - Check
core/storage/logs/laravel.logforreport()entries. - Confirm the slug in
statuses.jsonistrue. - Confirm the plugin is not in
PluginManager::errors(). - Confirm
booted()contains the slug if you rely on routes or views. - Clear caches and restart the worker.
Common causes
| Symptom | Likely cause |
|---|---|
| Menu missing | Route name undefined, or permission missing |
| Route 404 | Plugin not in booted(), or route file has a syntax error |
| View not found | Wrong namespace, or views/ missing |
| Config null | File name does not match the slug |
| Migration not found | Wrong directory or filename |
| Hook not firing | Core seam not present in this version, or plugin not active |
| Hook fires twice | Plugin booted twice, or two callbacks registered |
Performance notes
- Discovery parses every manifest on every request. Keep manifests small.
boot()runs on every request. Register only; do no I/O.plugin_has_filter()lets core skip expensive payload construction when no plugin listens. Use it for new seams.- Slots render on demand. A slot with no renderers returns an empty string.
- The registry is in-memory. It is rebuilt each request from
boot().
Last updated: October 21, 2026

