Lifecycle and upgrades
Activation
- Requirements are checked. If any fail, the plugin is deactivated again and WordPress shows the list of problems with a "Go back" link.
- The uninstall hook is registered.
- If the database holds an older version (the files were updated while the plugin was inactive), the upgrade runs first.
- Every task's
activate()runs, in registration order. - On first install,
{prefix}_versionand{prefix}_installed_atare stored. {prefix}_activatedfires with( Plugin $plugin, bool $firstTime ).
Activation runs again on every reactivation, so tasks are idempotent: options are added only if missing, tables are synced by dbDelta, and cron events are scheduled only if absent.
Runtime and upgrades
On plugins_loaded (priority 5):
- Requirements are checked. If any fail, an admin notice is shown and nothing else happens.
- If the stored version is older than the code's version, the upgrade runs:
- Migrations newer than the stored version, up to the current version, run in version order.
After each version's migrations finish,
{prefix}_versionadvances to that version. - Every task's
upgrade()runs. For built-in tasks this re-runs activation: new tables or columns, new options, caps and cron events. {prefix}_versionis set to the current version and{prefix}_upgradedfires.
- Migrations newer than the stored version, up to the current version, run in version order.
After each version's migrations finish,
onReady()callbacks run, then{prefix}_readyfires.
Upgrades run under a lock ({prefix}_upgrade_lock), so concurrent requests don't run migrations
twice. A lock older than 10 minutes is treated as stale.
If a migration throws, the upgrade stops and the stored version stays at the last version that
completed. The error is saved in {prefix}_upgrade_error and shown to administrators, and the
upgrade retries on the next page load. onReady() callbacks still run, so make your code
tolerate the previous schema, or check get_option( $plugin->optionName( 'upgrade_error' ) ).
Running the upgrade at runtime, not only on activation, matters because WordPress doesn't fire the activation hook for updates, including automatic background updates.
If the stored version is newer than the code (a downgrade), nothing runs.
If the plugin is active but was never activated through WordPress (for example, enabled directly in the database or deployed as a must-use plugin), the first request installs it.
Adopting the framework in a plugin that has already shipped
The framework decides between "fresh install" and "upgrade" by looking for {prefix}_version.
A plugin that shipped before it used the framework has no such option, so every existing site
would look like a fresh install, and the migrations that sites need would be skipped.
Tell the framework how to recognise those installs with legacyVersion():
Plugin::create( __FILE__ )
// The name of an option that holds the old version...
->legacyVersion( 'acme_db_version' )
// ...or a callback that returns it, or null when the plugin was never installed.
->legacyVersion( fn () => get_option( 'acme_settings' ) ? '0.1.0' : null )
->migration( '0.2.0', [ Migrations::class, 'moveSettings' ] );
It's consulted only when no {prefix}_version is stored. If it finds a version older than the
code's, that site is upgraded from it: migrations newer than that version run, then tasks
sync tables, options and so on. {prefix}_activated reports $firstTime as false. If it finds
the current version, the framework just starts tracking it. If it returns null, the site is
treated as a fresh install.
A string is always treated as an option name. To call a named function, wrap it in a closure.
Rewrite rules
Flushing rewrite rules immediately often comes too early: the activation hook, or a settings
page saving a new URL prefix, runs before the post types are registered with their new
settings. Request a flush instead, and it runs at the end of the next init:
add_action( 'update_option_acme_settings', function ( $old, $new ) use ( $plugin ) {
if ( ( $old['url_base'] ?? '' ) !== ( $new['url_base'] ?? '' ) ) {
$plugin->requestRewriteFlush();
}
}, 10, 2 );
->flushRewriteRules() requests one after every activation and upgrade. requestRewriteFlush()
works whether or not you use it.
Deactivation
Tasks' deactivate() methods run in reverse order: cron events are unscheduled and rewrite rules
are dropped. Roles and capabilities are kept unless you passed removeOnDeactivate: true. Data is
kept. {prefix}_deactivated fires.
Uninstall
WordPress calls the registered Plugin::handleUninstall() after loading your main file, so your
Plugin definition is available.
Nothing is deleted unless you opt in:
->deleteDataOnUninstall() // Always.
->deleteDataOnUninstall( fn () => get_option( 'acme_remove_data' ) ) // Let the site owner decide.
The {prefix}_delete_data_on_uninstall filter has the final say.
When data deletion is enabled, every task's uninstall() runs in reverse order on every site.
That drops tables, deletes options, removes roles and capabilities, and unschedules cron events.
The framework's own options are deleted too, then {prefix}_uninstalled fires.
Using your own uninstall.php
If you need one, load the main file and hand off to the framework:
<?php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
require_once __DIR__ . '/acme-bookings.php';
Bignall\WPFoundation\Plugin::instance( 'acme-bookings/acme-bookings.php' )?->lifecycle()->uninstall();