At a Glance
Running migrations during the deployment of a theme via the CLI can make changes more predictable and remove manual tasks. We can apply this process in a simplified way from Laravel’s migrations and follow a state-of-the-art method within WordPress. Upon running the migration process, we can run database migrations, call other CLI commands, or perform any necessary stateless actions to adjust our data or options for the new circumstances.
I’m creating custom themes for clients, maintaining and improving them. With adjustments come also changes in the database. Until now, I had a to-do list of things I had to apply after the deployment. It works well if you’re the only one accountable for the development and the release. But recently, I came across a case where a coworker made a fresh sync of the database to the integration environment, and my prepared setup wasn’t working anymore. That was an edge case, but it showed me the importance of having a proper and automatic solution in place, where adjustments to the database get triggered automatically based on the current database state.
The rabbit hole
I thought it couldn’t be that hard during a flight and started coding a solution. I got a lot of inspiration from the migration of Laravel and Symfony. But I went deep into a rabbit hole. I compared specific migration steps I already did manually on the latest releases and figured out I had used hooks for them. So, unlike Laravel, we have different cases. I wanted to run hooked functions, CLI commands and usual databank migrations. Laravel, for example, supports database updates and running Artisan commands inside a migration. Fast forward, that’s the way it makes sense and reduces unexpected behaviour.
WordPress core runs the database update procedure by scheduling a task and doing it once a user accesses the backend or when a WP-CLI command (wp core update-db
) runs. That solution makes sense since many non-tech-savvy users use WordPress. As we know from PHP frameworks, migrations are for developers and are usually called during deployment via a CLI command.
I wanted to follow WordPress’s approach but quickly realised there were better choices. Scheduling multiple migrations can become complicated, and it is uncertain whether the migrations will execute without adding much overhead to the code. I wanted to create a simple way of adjusting certain things programmatically without manual interactions during deployment.
Creating migration files
I got inspired by how Laravel is doing it and applied it to WordPress. The file structure of the migration is the same. We create a migrations folder within the theme root, adding every step in a specific format (YEAR_MONTH_DAY_HOUR-MINUTE-SECOND_DESCRIPTION). In this format, we can order the migrations ideally according to the time and apply them after each other, including everything down to the second it was created. Ultimately, we have a random description of the migration to make it easy to find and understand what it does.
migrations/
2022_01_16_062323_initial_migration.php
2024_11_29_101344_another_example_migration.php
We make our adjustments to the database within the migration file itself. We can call $wpdb, use CLI commands (described below), or trigger migration flags (defined below). For now, I only focus on the up functionality of the migrations, meaning no rollbacks are possible by now since I don’t see the use case for theme developers. But if needed at some point, this could be extended without much trouble.
That’s the blueprint of our migration file. In the best way, we create a CLI command to create this migration blueprint, including generating the filename with the current timestamp.
<?php
return new class() {
/**
* Run the migrations
*/
public function up(): void
{
// Run migration code here
// $wpdb, CLI commands or trigger migrations flags
}
// Potentially add a "down" method to include rollback
};
Now that the migration files are in place, we must migrate the logic by running the correct migrations.
public function getPendingMigrations(): array
{
$allMigrations = $this->getAllMigrations();
$latestMigration = $this->getLatestMigration();
if ($latestMigration && in_array($latestMigration, $allMigrations)) {
$latestIndex = array_search($latestMigration, $allMigrations);
return array_slice($allMigrations, $latestIndex + 1);
}
return $allMigrations;
}
public function applyMigrations(): void
{
$pendingMigrations = $this->getPendingMigrations();
foreach ($pendingMigrations as $migrationName) {
$migrationDone = $this->applySingleMigration($migrationName);
if ($migrationDone) {
ErrorHelper::log('All migrations applied');
}
}
}
private function getLatestMigration(): string|null
{
// Options are cached, but we always need fresh data
wp_cache_delete('theme_latest_migration', 'options');
return get_option('theme_latest_migration', null);
}
private function getAllMigrations(): array
{
$migrationFiles = glob(THEME_MIGRATIONS_DIR . '/*.php');
$migrationNames = array_map(fn ($file) => pathinfo($file, PATHINFO_FILENAME), $migrationFiles);
natsort($migrationNames);
return $migrationNames;
}
With this script, all our migrations are run; once they’re run, they will never run again. If new migrations are added, only this will run since we store the latest migration in the database and only pick the newer once.
Calling migrations via CLI
The last step is to run the migration from the CLI. That’s pretty easy. We must register a custom WP CLI command and run our `applyMigrations` method there.
We could also extend the command by allowing an option “status” to list all the migrations with their status to see which are upcoming. You can take some inspiration from the Laravel Migrations documentation to extend your commands.
$migrator = new Migrator();
$pending = $migrator->getPendingMigrations();
if (empty($pending)) {
\WP_CLI::success('No pending migrations');
return;
}
foreach ($pending as $migration) {
$done = $migrator->applySingleMigration($migration);
if($done) {
\WP_CLI::success("Migration \"{$migration}\" applied");
} else {
\WP_CLI::error("Migration \"{$migration}\" failed");
}
}
\WP_CLI::success('All migrations applied');
Last, remember to call the command in your deployment script. I have no idea about your setup, but make sure to call it there, where you probably flush rewrite rules and delete the cache after the deployment. (wp CUSTOM_COMAND migrate
)
Learnings
Don’t schedule events, don’t call hooks
I wanted to schedule a task that runs only once. This event is triggered by a CLI command or based on a different deployment hash or theme version. After that, the migrations are called as soon as the website is visited, as WordPress does with its database migration routine.
This code would look something like that.
public function scheduleMigration(): void
{
if (!wp_next_scheduled('schedule_theme_migration')) {
wp_schedule_single_event(time() + 1, 'schedule_theme_migration'); // Run ASAP
}
}
add_action('schedule_theme_migration', function () {
(new Migrator())->applyMigrations();
});
A better way would be to run the migration directly from the CLI when it’s called. In this way, the current state can be outputted directly in the console, and it’s clear that the steps have been run. I wanted to stick to the scheduled version from above because I had some hooks that were not working within the WordPress CLI.
add_action('admin_init', function() {
// DON'T DO THIS: unsafe call during deployment
deactivate_plugins('PLUGIN_HOME');
}, 10);
After thinking about this, calling hooks during a migration process is very unsafe since hooks can placed at specific places within the application, and most of them won’t get called during a usual visit to the page. So, the migration will run only partially. We have to call our migrations stateless, meaning hooks are not what we want to use. We get data from the database, adjust them and store them back into the database.
There are two better solutions in this specific case. Depending on the actual case, one or the other may be preferable.
Call WordPress functions, which are usually only available inside hooks
If you want to immediately deactivate the plugin during a CLI migration (when admin_init isn’t triggered), there’s a workaround to make deactivate_plugins
available. The WordPress function is usually only accessible within the admin_init
hook.
public function up() {
if (!function_exists('deactivate_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
deactivate_plugins('PLUGIN_HOME');
}
That’s the way if you want to use standard WordPress logic, but the WP CLI can also be called for deactivating a plugin. Since we’re triggering our migration via a CLI command, we can access other WP CLI commands as well.
public function up() {
WP_CLI::runcommand('plugin deactivate PLUGIN_SLUG');
}
The check to see if WP_CLI is available (if (defined('WP_CLI') && WP_CLI)
) can be omitted to keep the migration clean.
Using Flags within Hooks
Sometimes, things are particular to an interaction with a hook, and we want to migrate when it’s called the next time. We can include a flag migrating right where it is supposed to happen. Within the migration file, we just set a flag with our options. In this way, we can quickly see which migration flag is set and easily remove the death code later when going through old migrations.
public function up() {
update_option('deactivate_plugin_flag', true);
}
Coming back to the case of deactivating the plugin, we could do the following to make sure this plugin gets deactivated the next time someone calls the admin panel after our migration.
add_action('admin_init', function () {
$flag = get_option('deactivate_plugin_flag', false);
if ($flag) {
// Deactivate the plugin
deactivate_plugins('PLUGIN_HOME');
// Remove the flag to ensure it runs only once
delete_option('deactivate_plugin_flag');
}
});
Generating migration file via CLI
To make our lives easier and reduce mistakes, we can generate the migration file automatically via the CLI. To do this, register another custom command for the WP-CLI. The content of that command is pretty simple. The user defines a migrationName that has to be passed as an argument; everything else gets generated automatically.
$timestamp = date('Y_m_d_His');
$filename = "{$timestamp}_{$migrationName}.php";
$filePath = THEME_MIGRATIONS_DIR . '/' . $filename;
$template = <<<PHP
<?php
return new class {
/**
* Run the migrations.
*/
public function up(): void
{
// Add your migration logic here
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Add your rollback logic here
}
};
PHP;
file_put_contents($filePath, $template);
Conclusion
We can apply a migration process very similar to the one already known by the big PHP frameworks. We call those during the deployment of our theme via the CLI. Using our latest database version, we created a migration class that handles the needed steps.
To run a reliable migration, we must remember to avoid calling hooks, which include state. In case we need to adjust data inside hooks, we can use migration flags, which can be set via the migration and will run later when the actual hook is called.