Skip to content
Plugin modernization cover
writing

You don't rewrite a plugin with a big install base, you operate on it awake

How I evolve a WordPress plugin with a large install base without breaking users mid-update: a strangler-fig approach with real PHP and version gates.

Hasan MisbahHasan Misbah7 min read
wordpress
plugins
refactoring
migrations
php
TL;DR

Do not rewrite a plugin with a large install base. Evolve it live with the strangler fig pattern: freeze public names, gate new internals behind per-site options, dual-write before you dual-read, and delete legacy code only once it is provably dead.

The big rewrite is the most expensive decision you can make on a plugin that people already run in production. I have maintained affiliate, licensing, and link-management plugins with install bases large enough that any given hour, someone somewhere is mid-checkout when my update lands. So I don’t rewrite. I operate on the patient while they are awake, one organ at a time, and I never cut anything that is still load-bearing.

The pattern has a name: strangler fig. You wrap the old code, route a slice of behavior through new code, prove it, then let the new growth quietly replace the old. Nothing goes dark. Here is how that actually looks in WordPress, where you don’t control the host, the PHP version, or when the user clicks update.

Keep the public surface frozen

The first rule is that the function and hook names other people depend on do not move. Site owners have snippets in functions.php. Other plugins hook your filters. If you rename affapp_get_referral() because the new architecture prefers a repository, you just broke a thousand child themes you have never seen.

So the public function stays. Its guts get a switch.

function affapp_get_referral( $referral_id ) {
    if ( affapp_referrals_v2_enabled() ) {
        return \AffApp\Referrals\Repository::find( (int) $referral_id );
    }

    return affapp_legacy_get_referral( $referral_id );
}

function affapp_referrals_v2_enabled() {
    // Off by default. Flipped per-site, never in the plugin file.
    return (bool) get_option( 'affapp_referrals_v2', 0 );
}

The legacy function does not get deleted. It gets renamed to affapp_legacy_get_referral() and kept as the fallback. The facade is the only thing the outside world sees, and it looks identical to what it saw last release. That is the whole trick: change everything behind a surface that changed nothing.

Route a slice, not the whole thing

You do not flip the new path on for the world in one release. You flip it for one path, on one site, and watch. The option gate above is per-site, the same lever I lean on to ship a feature in staged percentages, which means I can turn on the v2 referrals repository on my own staging install, then on a friendly customer’s site, before it is anywhere near the default.

The staging is important because production data is never as clean as your schema thinks. The legacy referrals table on a five-year-old site has rows with NULL in a column that your new code assumes is always an integer, or a status string that predates an enum you introduced three years ago. New code that has only ever seen fresh installs will trip on that. Routing a narrow slice through real, old data is how you find the rows your migration forgot.

Dual-write before you dual-read

When the new code owns a different shape of data, do not migrate everything and swap. Write to both, read from one, and let the two converge. For the licensing plugin, moving activation records into a normalized table meant every activation had to land in the old table and the new one during the transition window.

add_action( 'affapp_license_activated', function ( $license_id, $site_url ) {
    // Legacy path stays authoritative until v2 is proven.
    affapp_legacy_record_activation( $license_id, $site_url );

    if ( affapp_activations_v2_enabled() ) {
        \AffApp\Activations\Repository::record( $license_id, $site_url );
    }
}, 10, 2 );

Reads still come from the legacy table. The new table is being populated in parallel so that when I do flip reads, it already holds live data, not a stale one-time export. When the two tables agree for long enough, and I have a script that diffs them, reads move over. The old table stays for one more release as a rollback net, then gets retired.

The version gate is the seatbelt

Every schema touch runs once, guarded by a stored version. I do not trust the plugin version string for this, because a user can downgrade, re-upgrade, or restore a backup. I keep a separate schema version in the options table and compare against it on plugins_loaded, following the same backward-compatible migration rules I hold everywhere.

const AFFAPP_DB_VERSION = 7;

add_action( 'plugins_loaded', 'affapp_maybe_upgrade_schema' );

function affapp_maybe_upgrade_schema() {
    $installed = (int) get_option( 'affapp_db_version', 0 );
    if ( $installed >= AFFAPP_DB_VERSION ) {
        return;
    }

    require_once __DIR__ . '/includes/schema.php';

    if ( $installed < 7 ) {
        affapp_schema_7_add_activation_table();
    }

    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );
}

Each step is additive and idempotent. affapp_schema_7_add_activation_table() uses dbDelta(), which will create the table if it is missing and leave it alone if it already matches. That means a user who half-upgraded, hit a timeout, and reloaded does not get a broken half-migration. The step either completes or is safe to run again.

function affapp_schema_7_add_activation_table() {
    global $wpdb;
    $table   = $wpdb->prefix . 'affapp_activations';
    $charset = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        license_id BIGINT UNSIGNED NOT NULL,
        site_url VARCHAR(255) NOT NULL DEFAULT '',
        activated_at DATETIME NOT NULL,
        PRIMARY KEY  (id),
        KEY license_id (license_id)
    ) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}

Note that I only ever add. No DROP, no ALTER ... DROP COLUMN, no destructive rename in an upgrade path a user cannot roll back from. If the new code is wrong, I flip the option off and the old table is still there, untouched, still authoritative.

Let the old growth die on its own schedule

The strangler fig does not kill the host tree in one cut. It grows around it until the host is no longer holding anything up, and then the host is just deadwood you can remove at leisure. Same with the legacy path. Once v2 reads have been default-on across the install base for a couple of releases with no rollbacks, the legacy function has no callers left. That is when it goes, in a release whose notes say “internal cleanup” because from the outside nothing changed.

The takeaway

The discipline is boring on purpose. Freeze the surface, gate the internals, dual-write, version the schema, keep every step additive, and delete only what is provably dead. You will move slower than a rewrite promises to. You will also never wake up to a support queue full of people whose store stopped taking orders because your clean new architecture met their five-year-old data at the wrong moment.

A rewrite bets the install base on your ability to predict every edge case in one shot. Operating awake bets on nothing. That is the point.

FAQ

Should I ever rewrite a plugin from scratch?

Almost never once it has a real install base. A rewrite bets everything on predicting every edge case in one shot, while operating awake with a strangler fig lets you prove each change on live data before it becomes the default.

What is the strangler fig pattern in a WordPress plugin?

You wrap the old code, route a narrow slice of behavior through new code behind a gate, prove it against real data, then let the new path replace the old once it has no callers left. Nothing goes dark during the transition.

Why keep the legacy function instead of deleting it after adding the new path?

Because it is your fallback and your rollback net. The old function gets renamed to something like affapp_legacy_get_referral() and stays behind the frozen public facade, so if the new path is wrong you flip one option off and the old behavior is still there.

How do I turn on the new path safely?

Use a per-site option that is off by default and never flipped in the plugin file. Enable it on your own staging install first, then a friendly customer, watching how the new code meets old, messy production data before it goes anywhere near the default.

When is it safe to delete the old code?

Once v2 reads have been default-on across the install base for a couple of releases with no rollbacks, the legacy path has no callers left. Only then does it go, in a release whose notes read “internal cleanup” because from the outside nothing changed.

let's talk

Building something where this kind of work matters?

I'm in GMT+6 and work async-first, so your timezone is never a reason not to reach out.

Get in touch
Copyright © Hasan Misbah. All rights reserved.
Hasan Misbah