Self-hosted email marketing with full source code. Pay once, own forever. Get AcelleMail — $74 →

Four states. Four model methods. One JSON master file.

Every plugin in the host application moves through four discrete states: register (files on disk + autoload + DB row), activate (migrations + status flip), disable (status flip only — routes and hooks survive), delete (rollback + DB row removal + master-file cleanup). Each state is implemented by a single method in app/Model/Plugin.php; each transition writes both to the plugins database table and to storage/app/plugins/index.json. This page walks every state in order with the exact host-side steps.

The four states at a glance

Every plugin row carries a status column with one of two values — active or inactive. Two more states are implicit: not yet registered (no DB row, no master-file entry) and deleted (file directory gone, row gone, master-file entry gone). Four transitions move the plugin between them:

TransitionMethodStatus beforeStatus afterWhat changes on disk / DB
RegisterPlugin::register($name)(no row)inactiveDB row inserted; master-file entry written; service provider booted in current request
Activate$plugin->activate()inactiveactiveMigrations run via the activate hook; DB status flipped; master-file error cleared
Disable$plugin->disable()activeinactiveDB status flipped; master-file error cleared. Nothing else.
Delete$plugin->deleteAndCleanup($keepData = false)any(no row)Delete hook fires (typically migrate:rollback); plugin folder removed; DB row removed; master-file entry removed

The mental model worth holding: register and delete change the world (files on disk, DB schema). Activate and disable only flip a flag — the routes, views, hooks, and service-provider state from register stay in place. The next sections cover each transition in order.

State 1 — Register / install

Plugin::register($name) at app/Model/Plugin.php:559 is the entry point. It is called automatically at the end of php artisan plugin:init and on every successful upload through the admin Plugins page. The method does five distinct things in order:

  1. Reads composer.json from storage/app/plugins/{vendor}/{name}/ and copies title, description, version into the model. Throws if the composer name field does not exactly match the directory.
  2. Inserts (or updates) the row in the plugins DB table with status = inactive. firstOrNew(['name' => $name]) is the lookup, so re-registering an existing plugin updates rather than duplicates.
  3. Writes the master file: storage/app/plugins/index.json gets a { "name": { "status": "inactive" } } entry. This is the boot-time registry the host reads on every request without going to the DB.
  4. Loads the service provider immediately: $plugin->load($withServiceProvider = true) registers the PSR-4 prefix with a fresh Composer\Autoload\ClassLoader and calls App::register() on the plugin's service provider class. By the time the method returns, the plugin's routes, views, and hooks are wired into the running process.
  5. Materialises translations and publishes assets: Language::dump() creates per-locale runtime files under storage/app/data/plugins/{vendor}/{name}/lang/, then artisan vendor:publish --force --tag=plugin copies any bundled assets into public/plugins/{vendor}/{name}/.

After register, the plugin is installed and loaded. It is not yet active — that just means whatever the plugin chose to wire up to its activate event has not run. The plugin's routes, views, and hook listeners are already live.

State 2 — Activate

$plugin->activate() at Plugin.php:484 is what the admin "Activate" button calls. Four ordered steps:

  1. Fire the activate hook: Hook::fire('activate_plugin_'.$this->name). Every listener registered against this name runs — typically the plugin's own Hook::on('activate_plugin_*', ...) listener that calls artisan migrate against the plugin's migrations folder. Other plugins can register additional listeners on the same event.
  2. Re-validate composer.json: self::validateMetaData($config) verifies the plugin's required keys (name, version, app_version) are present and well-formed. Missing keys throw before the status flip lands.
  3. Set DB status to active and save the row.
  4. Update the master file: { "status": "active", "error": null } — the error reset clears any previous boot failure so future autoload sweeps treat the plugin as healthy.

Activation is idempotent in practice. Re-running activate() on an already-active plugin re-fires the hook (so listeners that ran migrate would run it again — Laravel's migrations table de-dupes already-run files, so the second invocation is a no-op), re-validates, and writes the same status. No special "already active" branch.

State 3 — Disable

$plugin->disable() at Plugin.php:136 is the simplest of the four methods. It does only this:

  1. Set DB status to inactive.
  2. Update the master file with the new status and clear any error field.

That is the entire method. It does not unload anything.

Routes registered during the plugin's boot() stay registered. Views remain mountable. Hook listeners still fire when the host fires their hook. The plugin's service provider is still loaded into the application's container and will be loaded again on the next request because autoloadWithoutDbQuery() reads every entry from the master file regardless of status. Disable is a status flip, not an unload — Laravel itself does not support unregistering a service provider after it boots.

This is why the acelle/console plugin is the canonical "plugin features should disappear when disabled" pattern: routes always load, but a route middleware named console.active aborts with 404 when Plugin::getByName('acelle/console')->isActive() returns false. The check happens on every request, against the current DB status, so disabling the plugin makes its routes return 404 starting with the next request.

The visible-disable pattern in three steps. (1) Define a route middleware that checks Plugin::enabled('myvendor/myplugin') and aborts 404 when false. (2) Register it as a middleware alias in your service provider's boot(). (3) Apply it to your route group in routes.php. Every plugin that ships user-visible features should follow this pattern — without it, "deactivated" looks identical to "activated" from the user's perspective.

State 4 — Delete

$plugin->deleteAndCleanup($keepData = false) at Plugin.php:670 is the full teardown. Four ordered steps:

  1. Fire the delete hook: Hook::fire('delete_plugin_'.$name, [$keepData]). The skeleton's listener calls artisan migrate:rollback against the plugin's migrations folder. The $keepData flag is forwarded so the listener can opt out of rolling back tables that hold customer data — see the database-models page for the worked pattern.
  2. Delete the plugin directory: $this->deletePluginDirectory() recursively removes storage/app/plugins/{vendor}/{name}/. After this step, the plugin's PHP source is gone from disk.
  3. Delete the DB row. The plugins table no longer references this plugin.
  4. Remove the master-file entry: updatePluginMasterFile($name, null) — the null is the conventional signal to drop the entry rather than merge new fields.

Until the next request boots a fresh process, the plugin's routes, views, and hooks are still loaded in memory — the in-process Laravel container has no concept of "unregister this plugin's service provider". The next request reads the (now-shrunk) master file, does not load the plugin, and the in-memory state is discarded with the previous request's lifecycle.

The master file at every transition

storage/app/plugins/index.json is the single source of truth at boot time. Every transition above writes to it. A useful way to see the lifecycle is to watch what one plugin's entry looks like at each step:

// Before register: no entry.
{}

// After register:
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After activate:
{
  "acmecorp/loyalty": { "status": "active" }
}

// After a boot failure (sticky until cleared by activate):
{
  "acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}

// After disable (error cleared, status flipped):
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After delete: no entry.
{}

Three host-side methods own the file: updatePluginMasterFile($name, $params) for merge-writes (pass null as the second arg to remove the entry), resetPluginMasterFile() to rebuild the file from Plugin::all() when it gets out of sync with the DB, and getErroredPluginNames() to read every entry and return the names with non-empty error.

Recovery from broken state

Three failure modes show up in production:

1. The plugin row in the master file is stale or wrong

Common after manual edits, partial deploys, or restoring a database snapshot. Fix: run php artisan tinker and call Plugin::resetPluginMasterFile(). The method iterates Plugin::all() from the DB and rewrites the JSON file from scratch, preserving status and clearing every error field.

2. A plugin's error field is set and the admin Plugins page shows the red pill

The error is sticky — set when autoloadWithoutDbQuery() wraps a loadPluginByName() call in try/catch and the call throws. The error remains until either a successful activate() (which sets error => null) or a disable() (same). Fix: resolve the underlying problem (missing autoload.psr-4, namespace mismatch, missing service provider class), then click Activate; the next boot will succeed and the error will clear.

3. The plugin folder is missing but the master-file entry remains

Happens after a manual rm -rf. Boot still tries to load the plugin via the master-file entry, throws, and records the error. Fix: remove the master-file entry directly with Plugin::updatePluginMasterFile($name, null), or — if the plugin should still exist — re-upload the source archive and run Plugin::register($name) again to repopulate everything.

The plugin:* console commands

One artisan command ships in the host: plugin:init. There are no plugin:activate, plugin:disable, or plugin:delete commands — those are admin-UI actions. Programmatic access goes through the model methods directly:

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();   // → active, runs migration via activate hook
>>> $p->disable();    // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true);  // → preserve customer-facing tables

This is the same surface the admin Plugins page uses internally. CI scripts, seeders, and integration tests all reach for these methods directly. The Testing deep-dive covers the test-suite-level pattern.

State transitions in one diagram

          ┌─────────────────────┐
          │   not registered    │  (no row, no master-file entry)
          └──────────┬──────────┘
                     │ Plugin::register($name)
                     │  ├─ writes DB row (status=inactive)
                     │  ├─ writes master file
                     │  ├─ loads service provider in-process
                     │  └─ Language::dump() + vendor:publish
                     ▼
          ┌─────────────────────┐
   ┌────▶ │      inactive       │ ◀───┐
   │      └──────────┬──────────┘     │
   │                 │                │
   │       activate()│                │ disable()
   │                 │                │   ├─ status=inactive
   │                 │                │   └─ master file updated
   │                 ▼                │
   │      ┌─────────────────────┐     │
   │      │       active        │ ────┘
   │      └──────────┬──────────┘
   │                 │
   │     deleteAndCleanup($keepData)
   │                 │  ├─ fires delete hook (rollback unless $keepData)
   │                 │  ├─ removes plugin folder
   │                 │  ├─ deletes DB row
   │                 │  └─ removes master-file entry
   │                 ▼
   │      ┌─────────────────────┐
   └──────│   not registered    │
          └─────────────────────┘
          (cycle: register again to re-install)

Five anti-patterns

1. Treating disable as if it unloads the plugin

Routes still register, hooks still fire, views still mount. Fix: guard user-visible features with a Plugin::enabled(...) middleware or inline check, exactly like acelle/console.

2. Manually editing the master file in production

Easy to corrupt the JSON. Fix: call Plugin::updatePluginMasterFile() or Plugin::resetPluginMasterFile() through tinker — both validate.

3. rm -rf storage/app/plugins/{vendor}/{name} without removing the master entry

Boot keeps trying to load the missing plugin and records the error. Fix: always pair a folder removal with Plugin::updatePluginMasterFile($name, null), or use deleteAndCleanup() which does both.

4. Calling activate() from inside a service provider's boot()

The boot phase runs once per process; calling activate() there fires the activate hook on every request. The migration runs every time (idempotent — but expensive), and the side-effect listeners fire too. Fix: activation is an admin-UI action, never a boot-time side effect.

5. Forgetting that register happens before activate

Some plugins try to seed default data via activate hook listener and reference Eloquent models that depend on the plugin's own migrations — but the migrations have not run yet on the first activate. Fix: the migration listener runs during activate, before any other Hook::on('activate_plugin_*') listener that might reference the new tables. Order your registrations so the migration goes first (it does in the skeleton — keep it that way).

Where to go next

Lifecycle covers the when; Testing covers the verify. The next page walks the phpunit.xml testsuite registration, the PluginTestCase base-class pattern, hooks-under-test assertions, and the activate-test-delete CI cycle.