Xgenious/ docs

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.json is scanned. A deeper manifest is ignored.
  • plugins/framework is always skipped.
  • The directory name and the manifest slug can differ, but they should match. Errors are keyed by directory when the manifest cannot be parsed, and by slug once it can.
  • plugins/statuses.json is not a plugin because it is a file, not a directory.

Manifest parsing

Plugin::fromJsonFile() decodes JSON and calls Plugin::fromArray(). It validates:

  • slug exists, is a string, and matches ^[a-z0-9][a-z0-9-]*$.
  • name exists and is non-empty.
  • version matches ^\d+(\.\d+){0,3}$.
  • entry.file and entry.class are non-empty.
  • requires is an object if present.
  • autoload.psr-4 contains string keys and values.
  • xilancerMetaData is an object if present.
  • legacyName is 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:

  1. Visit each plugin.
  2. If it is already ordered, stop.
  3. If it is currently being visited, record circular plugin dependency.
  4. If a dependency is missing, record missing dependency: <slug>.
  5. Visit the dependency first.
  6. If the dependency has an error, propagate the skip.
  7. Check the dependency's version against the constraint.
  8. 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

ErrorCause
missing dependency: referralsThe dependency is not installed
unmet dependency: referrals >=2.0 (have 1.4.0)Installed, wrong version
circular plugin dependencyTwo or more plugins require each other
required by another plugin but not installedA 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.framework against PluginFramework::VERSION.
  • requires.php against PHP_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 PluginContext instance 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 to booted().

Asset wiring

For each slug in booted(), PluginBridgeServiceProvider::registerPluginAsset() wires:

PathAction
routes/web.phpRoute::middleware('web')->group($file)
routes/api.phpRoute::middleware('api')->group($file)
views/loadViewsFrom($path, $slug)
views/ with legacyNameAlso loadViewsFrom($path, strtolower($legacyName))
database/migrations/loadMigrationsFrom($path)
lang/loadTranslationsFrom($path, $slug)
lang/ with legacyNameAlso legacy namespace
config/<slug>.phpmergeConfigFrom($file, 'plugins.' . $slug)
config/<legacy>.phpMerged 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:

  1. Runs PluginValidator::validate($slug, runBootTest: true).
  2. Aborts on errors.
  3. Runs artisan migrate --path=<plugin>/database/migrations --realpath --force.
  4. Aborts on migration failure.
  5. Writes statuses.json.
  6. Clears the route cache.
  7. Reminds you to restart queue workers.

Admin uploader

Admin Plugin Manage → Upload Plugin does the same sequence:

  1. Accept the zip.
  2. Extract safely, rejecting path traversal.
  3. Find plugin.json at the root or one folder deep.
  4. Move the plugin into plugins/<slug>.
  5. Validate.
  6. Run migrations.
  7. Activate.
  8. 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

FailureEffectWhere it is reported
Invalid JSONPlugin skippedplugins list, admin list
Missing entry filePlugin skippedValidator, plugins list
PHP syntax errorPlugin skippedValidator
Wrong PHP versionPlugin skippedplugins list
Missing dependencyPlugin skippedplugins list
Circular dependencyBoth plugins skippedplugins list
Throwing boot()Plugin not bootedPluginManager::errors()
Throwing actionCaught by plugin_do_action()report() / log
Throwing filterCaught; input returnedreport() / log
Throwing slotRenderer skippedSlotRenderer::lastErrors()
Bad route fileOther assets still loadreport() / log
Migration failureActivation abortedCommand 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

  1. Run php core/artisan plugins list. Read the problem column.
  2. Run php core/artisan plugins validate <slug>. Read every error and warning.
  3. Check core/storage/logs/laravel.log for report() entries.
  4. Confirm the slug in statuses.json is true.
  5. Confirm the plugin is not in PluginManager::errors().
  6. Confirm booted() contains the slug if you rely on routes or views.
  7. Clear caches and restart the worker.

Common causes

SymptomLikely cause
Menu missingRoute name undefined, or permission missing
Route 404Plugin not in booted(), or route file has a syntax error
View not foundWrong namespace, or views/ missing
Config nullFile name does not match the slug
Migration not foundWrong directory or filename
Hook not firingCore seam not present in this version, or plugin not active
Hook fires twicePlugin 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

Still stuck?
Our support team is ready to help you get set up.
Get support