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:
PluginManager::discover()scans theplugins/directory- 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 apluginsrecord already exists for this slug d. If not, inserts a new record withis_active = 0 - The VettingService is called if configured — checks the plugin against a remote vetting API
- 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):
PluginManager::activate($slug)is called- The plugin's namespace is registered:
spl_autoload_registermapsPluginsMyPlugin→plugins/MyPlugin/ - 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
- All migration files in
Installer::up()is called (ifInstaller.phpexists)- Seeds default data, creates directories, writes default settings
- If
Installer::up()throws an exception, activation is aborted
- The
pluginstable record is updated:is_active = 1
New in 2.3.6— Step 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:
- Query the
pluginstable:WHERE is_active = 1 - For each active plugin:
a. Register namespace:
spl_autoload_registerforPluginsPluginNameb. Instantiate Plugin class:new PluginsPluginNamePlugin()c. Callregister(): event listeners, SDK initialisation, service overrides d. Inject CSRF exemptions: fromgetCsrfExemptions()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:
PluginManager::deactivate($slug)setsis_active = 0in thepluginstable- 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:
Installer::down()is called- The
pluginstable record is deleted - 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