Plugin Lifecycle

A plugin passes through four distinct lifecycle phases: discovery, activation, boot (every request), and deactivation. Understanding each phase is essential for building plugins that behave correctly.

Phase 1: Discovery

Trigger: Administrator clicks "Discover Plugins" in the admin panel, or an auto-discovery cron runs.

What happens:

  1. PluginManager::discover() scans the plugins/ directory
  2. For each subdirectory found: a. Reads plugin_info.json — if missing or invalid, the directory is skipped b. Validates required fields (name, slug, version, min_pubvana_version, etc.) c. Checks if a plugins record already exists for this slug d. If not, inserts a new record with is_active = 0
  3. The VettingService is called if configured — checks the plugin against a remote vetting API
  4. The admin panel shows the newly discovered plugin with an "Activate" button

DB state after discovery: plugins row exists, is_active = 0

SELECT * FROM plugins WHERE slug = 'myplugin';
-- id | name       | slug      | version | is_active | ...
-- 1  | My Plugin  | myplugin  | 1.0.0   | 0         | ...

No PHP from the plugin runs during discovery. The namespace is not registered. No routes are loaded.

Phase 2: Activation

Trigger: Administrator clicks "Activate" on the plugin.

What happens (in order):

  1. PluginManager::activate($slug) is called
  2. The plugin's namespace is registered: spl_autoload_register maps PluginsMyPluginplugins/MyPlugin/
  3. Migrations run: $migrate->setNamespace('Plugins\MyPlugin')->latest()
    • All migration files in plugins/MyPlugin/Database/Migrations/ are executed in timestamp order
    • If any migration fails, activation is aborted and the error is displayed
  4. Installer::up() is called (if Installer.php exists)
    • Seeds default data, creates directories, writes default settings
    • If Installer::up() throws an exception, activation is aborted
  5. The plugins table record is updated: is_active = 1

New in 2.3.6Step 6: If the plugin is the first active plugin declaring "core" email capability, the admin is shown a prompt to choose whether this plugin should handle core system email delivery (contact forms, password resets, etc.). The selection can be changed at any time in Admin → Settings → Email. See Email Provider.

DB state after activation: is_active = 1, all plugin tables created, default data seeded

// PluginManager::activate() — simplified
public function activate(string $slug): bool
{
    $plugin = $this->findPlugin($slug);

    // Register namespace
    $this->registerNamespace($plugin);

    // Run migrations
    $migrate = ConfigServices::migrations();
    $migrate->setNamespace('Plugins\' . $plugin->directory)->latest();

    // Run installer
    $installerClass = 'Plugins\' . $plugin->directory . '\Installer';
    if (class_exists($installerClass)) {
        $installerClass::up();
    }

    // Mark active
    $this->pluginModel->update($plugin->id, ['is_active' => 1]);

    return true;
}

Phase 3: Boot (Every Request)

Trigger: Every HTTP request to the Pubvana application.

What happens:

The pre_system event fires early in the CI4 bootstrap. PluginManager::boot() is called:

  1. Query the plugins table: WHERE is_active = 1
  2. For each active plugin: a. Register namespace: spl_autoload_register for PluginsPluginName b. Instantiate Plugin class: new PluginsPluginNamePlugin() c. Call register(): event listeners, SDK initialisation, service overrides d. Inject CSRF exemptions: from getCsrfExemptions() into the CSRF filter e. Load routes: require plugins/PluginName/Config/Routes.php (if it exists)

This entire sequence runs before routing, so plugin routes are available to the router as if they were defined in app/Config/Routes.php.

// PluginManager::boot() — simplified
public function boot(): void
{
    $plugins = $this->pluginModel->where('is_active', 1)->findAll();

    foreach ($plugins as $plugin) {
        // a. Register namespace
        spl_autoload_register(function ($class) use ($plugin) {
            $prefix = 'Plugins\' . $plugin->directory . '\' ;
            if (strpos($class, $prefix) === 0) {
                $file = PLUGINS_PATH . $plugin->directory . '/'
                      . str_replace('\', '/', substr($class, strlen($prefix))) . '.php';
                if (file_exists($file)) {
                    require_once $file;
                }
            }
        });

        // b-c. Instantiate and register
        $className    = 'Plugins\' . $plugin->directory . '\Plugin';
        $pluginInst   = new $className();
        $pluginInst->register();

        // d. CSRF exemptions
        $exemptions = $pluginInst->getCsrfExemptions();
        // ... inject into CSRF filter config

        // e. Routes
        $routesFile = PLUGINS_PATH . $plugin->directory . '/Config/Routes.php';
        if (file_exists($routesFile)) {
            require_once $routesFile;
        }
    }
}

Phase 4: Deactivation

Trigger: Administrator clicks "Deactivate" on the plugin.

What happens:

  1. PluginManager::deactivate($slug) sets is_active = 0 in the plugins table
  2. Nothing else — no migrations are rolled back, no Installer::down() is called, no files are deleted, no settings are removed

The plugin's tables, data, and settings remain intact. The plugin simply stops booting on requests.

Reactivation after deactivation: migrations do not re-run (they are already up-to-date), but Installer::up() is called again. This is why up() must be idempotent (guard inserts with existence checks).

Phase 5: Uninstall (Optional)

Trigger: Administrator explicitly uninstalls the plugin (separate from deactivation).

What happens:

  1. Installer::down() is called
  2. The plugins table record is deleted
  3. No automatic cleanup of DB tables, files, or settings — down() must handle this explicitly

Lifecycle Summary

Filesystem placement
        │
        ▼
   Discovery (scan disk → DB insert, is_active=0)
        │
        ▼ [admin clicks Activate]
   Activation
        ├── Register namespace
        ├── Run migrations (setNamespace → latest())
        ├── Installer::up()
        ├── is_active = 1
        └── [Email provider prompt, if first "core"-capable plugin] (2.3.6+)
        │
        ▼ [every request]
   Boot (pre_system)
        ├── Register namespace
        ├── Plugin::register()
        ├── Inject CSRF exemptions
        └── Load Config/Routes.php
        │
        ▼ [admin clicks Deactivate]
   Deactivation → is_active = 0 (no cleanup)
        │
        ▼ [admin clicks Uninstall]
   Uninstall → Installer::down() → DB record deleted