Contents
Introduction — why you must handle database schema migrations in plugins
When a WordPress plugin evolves, its database schema often must evolve with it: new tables, added columns, dropped columns, renamed columns, changed indexes, or mass data transformations. If you ship schema changes carelessly you risk data loss, fatal PHP/SQL errors, performance regressions and unhappy users.
This article provides a complete, production-ready approach for migrating database schemas on plugin updates using PHP and WordPress APIs. It covers best practices, versioning strategies, concrete code examples (creation, alteration, renames, data migrations), multisite considerations, transactional safety, error handling, rollback strategies, testing, and cleanup on uninstall.
Principles and high-level workflow
- Store a single schema version — save a numeric/string version in the options table (or site options for multisite). Compare the installed version with your plugins schema version on load and run only the needed migrations.
- Use incremental, idempotent migrations — each migration should be safe to run multiple times (detect and skip if already applied).
- Separate DDL from data migrations — schema changes and data transformations should be independent steps so failures in one don’t corrupt the other.
- Use dbDelta for table creation — dbDelta understands CREATE TABLE SQL and will create/alter tables for many cases. For complex ALTER operations, use explicit ALTER TABLE queries.
- Validate and back up before changes — instruct administrators to back up production DB before updates and offer programmatic safeguards/logs.
- Handle multisite/network activation — update site options across network when appropriate.
- Log and fail gracefully — capture SQL errors, store migration status and messages for admin review, avoid fatal errors.
- Offer uninstall cleanup — optionally remove tables and options on uninstall via register_uninstall_hook or uninstall.php.
Which hooks and events to run migrations from
- register_activation_hook — good for first activation and initial setup but does not run on updates.
- plugin_loaded or init — safe to trigger migrations early check capability and admin area before heavy ops.
- admin_init — you can run migrations here but avoid doing heavy tasks on every admin page load run only once per upgrade.
- upgrader_process_complete — runs when update completes useful to run migrations immediately after core/plugin/theme updates (check the action args).
- Upgrader API filters — detect plugin upgrades programmatically and run migrations only when the plugin was updated.
Schema version storage — options vs site options
Store a schema version string (for example, 1.2.0 or an incremental integer) in the options table using add_option/update_option. For multisite plugins or network-activated plugins, use add_site_option/update_site_option so you can manage the network schema centrally.
Common key names: your_plugin_db_version, your_plugin_schema_version. Keep the version atomic and easy-to-compare (use version_compare for semantic versions).
Basic migration manager — concept and example implementation
Below is a compact, robust MigrationManager class implementing common patterns: register migrations, run migrations in order, store schema version, handle errors, and support multisite.
lt?php // Example: Minimal migration manager for plugin updates class MyPlugin_DB_Migrator { protected option_name = myplugin_db_version protected migrations = array() // array of version => callable protected current_version // plugin-coded version, set by plugin protected is_network = false public function __construct( current_version, is_network = false ) { this->current_version = current_version this->is_network = is_network } public function register_migration( version, callable cb ) { this->migrations[ version ] = cb // keep migrations sorted by version (semantic) uksort( this->migrations, version_compare ) } protected function get_stored_version() { if ( is_multisite() this->is_network ) { return get_site_option( this->option_name, 0 ) } return get_option( this->option_name, 0 ) } protected function update_stored_version( version ) { if ( is_multisite() this->is_network ) { update_site_option( this->option_name, version ) } else { update_option( this->option_name, version ) } } public function maybe_migrate() { installed = this->get_stored_version() // No-op if already up to date if ( version_compare( installed, this->current_version, >= ) ) { return } foreach ( this->migrations as version => callable ) { if ( version_compare( installed, version, < ) version_compare( version, this->current_version, <= ) ) { try { // Each migration should be idempotent call_user_func( callable ) this->update_stored_version( version ) } catch ( Exception e ) { error_log( MyPlugin migration to . version . failed: . e->getMessage() ) // Stop further migrations on failure throw e } } } // Final bump to current version if migrations jumped versions this->update_stored_version( this->current_version ) } } ?gt
How to register and trigger this migrator
In your main plugin file instantiate the migrator with your plugins schema version, register migrations, and call maybe_migrate() on init (or on an update hook). Keep migrations small and fast where possible.
lt?php // plugin main file (simplified) define( MYPLUGIN_VERSION, 1.3.0 ) migrator = new MyPlugin_DB_Migrator( MYPLUGIN_VERSION, true ) // true if network-level // Register migrations in order: migrator->register_migration( 1.0.0, function() { // create initial table (see create table example below) } ) migrator->register_migration( 1.1.0, function() { // add a column } ) migrator->register_migration( 1.2.0, function() { // transform data or normalize } ) // Run on admin_init to ensure WordPress is loaded optionally limit to administrators add_action( admin_init, function() use ( migrator ) { if ( ! current_user_can( manage_options ) ) { return } migrator->maybe_migrate() } ) ?gt
Creating tables: use dbDelta
dbDelta is the standard WordPress helper for creating or updating table columns/keys from a CREATE TABLE SQL string. Always include the charset and collate string and use the exact table name (including wpdb-gtprefix). dbDelta is conservative — complex column type changes sometimes require direct ALTER statements.
lt?php global wpdb charset_collate = wpdb-gtget_charset_collate() table_name = wpdb-gtprefix . myplugin_items sql = CREATE TABLE {table_name} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL, status varchar(50) NOT NULL DEFAULT new, meta longtext NULL, created datetime NOT NULL, updated datetime NULL, PRIMARY KEY (id), KEY user_id (user_id), KEY status (status) ) charset_collate // dbDelta is in wp-admin/includes/upgrade.php require_once( ABSPATH . wp-admin/includes/upgrade.php ) dbDelta( sql ) ?gt
dbDelta tips and gotchas
- Column definitions must match exactly for dbDelta to recognize changes (types, defaults, lengths).
- Always end the SQL with a semicolon.
- dbDelta can add columns and indexes but may not alter complex column type changes (e.g., change TEXT to JSON) — use ALTER TABLE then.
- Use explicit KEY definitions for indexes dbDelta parses keys to create missing ones.
Altering tables: use prepared ALTER statements
For column renames, type changes, or other DDL operations not reliably handled by dbDelta, send explicit ALTER TABLE queries with wpdb-gtquery() and wpdb-gtprepare() when you include variables.
lt?php global wpdb table = wpdb-gtprefix . myplugin_items // Add a column if it doesnt exist column = wpdb-gtget_results( wpdb-gtprepare( SHOW COLUMNS FROM %s LIKE %s, table, new_col ) ) if ( empty( column ) ) { sql = ALTER TABLE {table} ADD COLUMN new_col VARCHAR(100) NULL wpdb-gtquery( sql ) } // Rename column (MySQL) has_old = wpdb-gtget_results( wpdb-gtprepare( SHOW COLUMNS FROM %s LIKE %s, table, old_col ) ) if ( ! empty( has_old ) ) { // CHANGE old_col new_col NEWTYPE ... (CHANGE requires full definition) wpdb-gtquery( ALTER TABLE {table} CHANGE old_col new_col VARCHAR(255) NULL ) } ?gt
Detecting existing columns and indexes
Use SHOW COLUMNS FROM or information_schema queries:
-- Example query (via wpdb) SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = wp_myplugin_items AND COLUMN_NAME = mycol
Data migrations — transformation patterns
When altering schema you often need to migrate existing data: copy values into new columns, normalize serialized arrays into JSON, split rows, etc. Perform data migrations in small chunks to avoid timeouts and to allow resumability.
- Chunked processing — use LIMIT/OFFSET or primary key ranges to process N rows per HTTP request and resume with an option/transient. Cron or WP-CLI is excellent for long migrations.
- Transactional guarantees — if using InnoDB, run START TRANSACTION / COMMIT so a failing batch rolls back.
- Idempotency — mark rows as migrated or check destination column state to skip already-migrated rows.
- Logging and progress — store counts and error messages in an option for admin control and progress display.
lt?php global wpdb table = wpdb-gtprefix . myplugin_items // Process rows in batches to convert meta (serialized) into JSON in meta_json column batch_size = 200 offset = 0 do { rows = wpdb-gtget_results( wpdb->prepare( SELECT id, meta FROM {table} WHERE meta IS NOT NULL LIMIT %d OFFSET %d, batch_size, offset ) ) if ( empty( rows ) ) { break } wpdb-gtquery( START TRANSACTION ) try { foreach ( rows as r ) { meta = maybe_unserialize( r->meta ) // normalize to array if ( ! is_array( meta ) ) { meta = array( value => meta ) } meta_json = wp_json_encode( meta ) wpdb-gtupdate( table, array( meta_json =gt meta_json ), array( id =gt r-gtid ), array( %s ), array( %d ) ) } wpdb-gtquery( COMMIT ) } catch ( Exception e ) { wpdb-gtquery( ROLLBACK ) throw e } offset = batch_size } while ( count( rows ) === batch_size ) ?gt
Transactions and rollback strategies
MySQL transactions can help maintain consistency for data migrations when using InnoDB tables. Use START TRANSACTION / COMMIT / ROLLBACK via wpdb-gtquery(). Keep transactions small (per batch) to avoid long locks.
For DDL (ALTER TABLE) note that many MySQL versions do non-transactional DDL if an ALTER fails, you may need to instruct the admin to restore from backup. Build migrations that can be resumed or that detect partially applied changes and either finish or rollback manually.
Multisite and network-activated plugin considerations
- If your plugin is network-activated, perform network-wide migrations (iterate sites and upgrade each sites schema or store a network-level schema flag).
- When iterating many subsites, use WP-CLI or asynchronous background processing (Action Scheduler, wp-cron jobs) to avoid timeouts.
- Use get_sites() with fields limited to id to iterate efficiently and switch_to_blog / restore_current_blog while running site-level migrations.
lt?php // Multisite migration example: iterate site IDs and run site-specific setup if ( is_multisite() ) { sites = get_sites( array( fields =gt ids ) ) foreach ( sites as blog_id ) { switch_to_blog( blog_id ) // Run site-level migration code that uses options or tables with wp_ prefix migrator-gtmaybe_migrate() restore_current_blog() } } ?gt
Safe checks and idempotency recommendations
- Check existence of table/column/index before creating/altering.
- Make migrations skip if a post-condition is already met (e.g., column exists or data flag set).
- Use version_compare for semantic versions and order migrations accordingly.
- Test migrations against a copy of production data if possible.
Running migrations during plugin updates (hooking into upgrader)
You can detect plugin updates using the upgrader_process_complete action and run migrations specifically when your plugin was updated. This helps avoid running migrations on every admin page load.
lt?php add_action( upgrader_process_complete, function( upgrader_object, options ) { // options[action] is update or install options[type] is plugin if ( options[action] !== update options[type] !== plugin ) { return } if ( empty( options[plugins] ) ) { return } // Look for our plugin in the list of updated plugins foreach ( options[plugins] as plugin_file ) { if ( plugin_file === plugin_basename( __FILE__ ) ) { // run migration migrator = new MyPlugin_DB_Migrator( MYPLUGIN_VERSION ) migrator->maybe_migrate() break } } }, 10, 2 ) ?gt
Logging, admin notices and progress reporting
When migrations run automatically, surface minimal progress or errors to administrators via admin notices stored transient/option, or a dedicated log option. Be careful with noisy notices — only show a concise message and link to plugin status page for details.
lt?php // Simple error logging to debug.log try { migrator-gtmaybe_migrate() } catch ( Exception e ) { error_log( [MyPlugin] Migration failed: . e->getMessage() ) // Set an admin notice to show in the dashboard update_option( myplugin_migration_error, e->getMessage() ) } // On admin_notices, display small message if migration_error exists add_action( admin_notices, function() { if ( msg = get_option( myplugin_migration_error ) ) { echo ltdiv class=notice notice-errorgtltpgtMyPlugin migration error: . esc_html( msg ) . lt/pgtlt/divgt } } ) ?gt
Unit and integration testing for migrations
- Write unit tests that set up a pre-migration schema and data, run the migrator, and assert final schema and data states.
- Use WP-CLI and a dockerized MySQL instance to run reproducible tests with realistic data.
- Test incremental upgrades: e.g., 1.0.0 → 1.1.0 → 1.2.0 and 1.0.0 → 1.2.0 directly (to ensure migrations chain correctly).
Backup and recovery guidance
Before releasing migrations that change schemas in production, instruct site owners to back up the database. Provide simple commands and WP-CLI examples:
# MySQL dump (site admins) mysqldump -u DBUSER -p DBNAME gt backup-before-update.sql # WP-CLI export (posts/media, not DB-level) wp db export backup-before-update.sql
Uninstall and cleanup
If your plugin needs to remove tables/options on uninstall, implement an uninstall.php or register_uninstall_hook that deletes your tables and options. Warn users because uninstall removes user data permanently.
lt?php // uninstall.php (this file is loaded by WP when plugin is uninstalled) if ( ! defined( WP_UNINSTALL_PLUGIN ) ) { exit } global wpdb table = wpdb-gtprefix . myplugin_items wpdb-gtquery( DROP TABLE IF EXISTS {table} ) // remove options delete_option( myplugin_db_version ) delete_site_option( myplugin_db_version ) // if multisite ?gt
Advanced topics
Schema migrations as classes and automatic discovery
For large projects you may implement migrations as PHP classes/files named by version (e.g., migrations/1.1.0_AddColumn.php). Discover and run them dynamically support up and down methods for reversible migrations. Keep them small and single-responsibility.
lt?php // Example migration class pattern class Migration_1_1_0_AddStatusColumn { public static function up() { global wpdb table = wpdb-gtprefix . myplugin_items exists = wpdb-gtget_results( wpdb-gtprepare( SHOW COLUMNS FROM %s LIKE %s, table, status ) ) if ( empty( exists ) ) { wpdb-gtquery( ALTER TABLE {table} ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT new ) } } public static function down() { global wpdb table = wpdb-gtprefix . myplugin_items wpdb-gtquery( ALTER TABLE {table} DROP COLUMN IF EXISTS status ) } } ?gt
Using WP-CLI for heavy migrations
WP-CLI is ideal for heavy schema migrations. Build an wp myplugin migrate command to run migrations outside web requests, allow dry-run, and provide verbose logging. This avoids web server timeouts and gives site owners control.
Common migration patterns and sample steps
- Create table if not exists (dbDelta)
- Add column if missing (SHOW COLUMNS ALTER TABLE ADD)
- Populate new column from existing data (batch processing with transactions)
- Change column type where necessary (ALTER TABLE CHANGE with full definition)
- Rename column (ALTER TABLE CHANGE old_name new_name FULLTYPE)
- Drop column when safe (ensure data copied/migrated)
- Rebuild or partition large tables if performance issues arise
Security and sanitization
- Always use prepared statements or sanitize table/column names when constructing raw SQL.
- Never trust user input in migration SQL (migrations run by admin only but still be careful with dynamic values).
- Sanitize serialized data carefully use maybe_unserialize and wp_json_encode to standardize formats.
Summary checklist before releasing a migration
- Are migrations idempotent? (safe to run multiple times)
- Is schema versioning stored and compared correctly?
- Do migrations have adequate checks (column/table existence, indexes)?
- Are heavy data migrations chunked or runnable via WP-CLI / background jobs?
- Are multisite and network-activated cases accounted for?
- Is there logging, admin feedback, and rollback guidance?
- Is uninstall cleanup (if any) implemented and documented?
- Has the migration been tested against production-like data?
Further reading
Concluding notes
Database migrations are one of the riskiest parts of plugin development, but with careful versioning, idempotent migrations, chunked data processing, testing, and operator-friendly tooling (WP-CLI, progress/status reporting) you can upgrade schemas reliably and maintain user trust. Use the patterns and code samples above as a blueprint and adapt for your plugins needs.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |