[{"data":1,"prerenderedAt":39},["ShallowReactive",2],{"blog-post-you-dont-rewrite-a-plugin-with-a-big-install-base":3},{"slug":4,"title":5,"description":6,"date":7,"updated":8,"tags":9,"pillar":15,"draft":16,"image":17,"imageAlt":18,"html":19,"markdown":20,"faq":21,"readingTimeText":37,"readingTimeMinutes":38},"you-dont-rewrite-a-plugin-with-a-big-install-base","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.","2026-07-01T00:00:00.000Z",null,[10,11,12,13,14],"wordpress","plugins","refactoring","migrations","php","plugin-modernization",false,"/blog-covers/plugin-modernization.png","Plugin modernization cover","\u003Cdiv class=\"post-tldr\">\u003Cdiv class=\"post-tldr-label\">TL;DR\u003C/div>\u003Cp>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.\u003C/p>\u003C/div>\n\u003Cp>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.\u003C/p>\n\u003Cp>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, \u003Ca href=\"/blog/supporting-a-plugin-across-wordpress-versions\">the PHP version\u003C/a>, or when the user clicks update.\u003C/p>\n\u003Ch2 id=\"keep-the-public-surface-frozen\" tabindex=\"-1\">Keep the public surface frozen\u003C/h2>\n\u003Cp>The first rule is that the function and hook names other people depend on do not move. Site owners have snippets in \u003Ccode>functions.php\u003C/code>. Other plugins hook your filters. If you rename \u003Ccode>affapp_get_referral()\u003C/code> because the new architecture prefers a repository, you just broke a thousand child themes you have never seen.\u003C/p>\n\u003Cp>So the public function stays. Its guts get a switch.\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_get_referral\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $referral_id ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">affapp_referrals_v2_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        return\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> \\AffApp\\Referrals\\Repository\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">find\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( (\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">int\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) $referral_id );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_legacy_get_referral\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $referral_id );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_referrals_v2_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    // Off by default. Flipped per-site, never in the plugin file.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">bool\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">get_option\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_referrals_v2'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>The legacy function does not get deleted. It gets renamed to \u003Ccode>affapp_legacy_get_referral()\u003C/code> 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.\u003C/p>\n\u003Ch2 id=\"route-a-slice%2C-not-the-whole-thing\" tabindex=\"-1\">Route a slice, not the whole thing\u003C/h2>\n\u003Cp>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 \u003Ca href=\"/blog/shipping-a-feature-into-a-five-year-old-plugin\">ship a feature in staged percentages\u003C/a>, 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.\u003C/p>\n\u003Cp>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 \u003Ccode>NULL\u003C/code> in a column that your new code assumes is always an integer, or a \u003Ccode>status\u003C/code> 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.\u003C/p>\n\u003Ch2 id=\"dual-write-before-you-dual-read\" tabindex=\"-1\">Dual-write before you dual-read\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">add_action\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_license_activated'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( $license_id, $site_url ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    // Legacy path stays authoritative until v2 is proven.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">    affapp_legacy_record_activation\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $license_id, $site_url );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">affapp_activations_v2_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">        \\AffApp\\Activations\\Repository\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">record\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $license_id, $site_url );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">10\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">2\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Ch2 id=\"the-version-gate-is-the-seatbelt\" tabindex=\"-1\">The version gate is the seatbelt\u003C/h2>\n\u003Cp>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 \u003Ccode>plugins_loaded\u003C/code>, following the same \u003Ca href=\"/blog/backward-compatible-migrations-on-an-install-base\">backward-compatible migration rules\u003C/a> I hold everywhere.\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">const\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> AFFAPP_DB_VERSION\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 7\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">add_action\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'plugins_loaded'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_maybe_upgrade_schema'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_maybe_upgrade_schema\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $installed \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">int\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">get_option\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_db_version'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( $installed \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">>=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> AFFAPP_DB_VERSION\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    require_once\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> __DIR__\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> .\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '/includes/schema.php'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( $installed \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">&#x3C;\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 7\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">        affapp_schema_7_add_activation_table\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">    update_option\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_db_version'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">AFFAPP_DB_VERSION\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Each step is additive and idempotent. \u003Ccode>affapp_schema_7_add_activation_table()\u003C/code> uses \u003Ccode>dbDelta()\u003C/code>, 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.\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_schema_7_add_activation_table\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">() {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    global\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $wpdb;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $table   \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $wpdb\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">prefix \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">.\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'affapp_activations'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $charset \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $wpdb\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">get_charset_collate\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $sql \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> TABLE\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> {\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$table\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">} (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        id \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">BIGINT\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> UNSIGNED \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">NOT NULL\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> AUTO_INCREMENT,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        license_id \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">BIGINT\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> UNSIGNED \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">NOT NULL\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        site_url \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">VARCHAR\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">255\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">) \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">NOT NULL\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> DEFAULT\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> '',\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">        activated_at \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">DATETIME\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> NOT NULL\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        PRIMARY KEY\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">  (id),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">        KEY\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> license_id (license_id)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">    ) {\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$charset\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">};\"\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    require_once\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> ABSPATH\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> .\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'wp-admin/includes/upgrade.php'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">    dbDelta\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $sql );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Note that I only ever add. No \u003Ccode>DROP\u003C/code>, no \u003Ccode>ALTER ... DROP COLUMN\u003C/code>, 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.\u003C/p>\n\u003Ch2 id=\"let-the-old-growth-die-on-its-own-schedule\" tabindex=\"-1\">Let the old growth die on its own schedule\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cdiv class=\"post-takeaway\">\u003Cdiv class=\"post-takeaway-label\">The takeaway\u003C/div>\u003Cp>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.\u003C/p>\n\u003Cp>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.\u003C/p>\u003C/div>\u003Ch2 id=\"faq\" tabindex=\"-1\">FAQ\u003C/h2>\n\u003Cdiv class=\"post-faq\">\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Should I ever rewrite a plugin from scratch?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">What is the strangler fig pattern in a WordPress plugin?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Why keep the legacy function instead of deleting it after adding the new path?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">How do I turn on the new path safely?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">When is it safe to delete the old code?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003C/div>","\n## TL;DR\n\nDo 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.\n\nThe 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.\n\nThe 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](/blog/supporting-a-plugin-across-wordpress-versions), or when the user clicks update.\n\n## Keep the public surface frozen\n\nThe 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.\n\nSo the public function stays. Its guts get a switch.\n\n```php\nfunction affapp_get_referral( $referral_id ) {\n    if ( affapp_referrals_v2_enabled() ) {\n        return \\AffApp\\Referrals\\Repository::find( (int) $referral_id );\n    }\n\n    return affapp_legacy_get_referral( $referral_id );\n}\n\nfunction affapp_referrals_v2_enabled() {\n    // Off by default. Flipped per-site, never in the plugin file.\n    return (bool) get_option( 'affapp_referrals_v2', 0 );\n}\n```\n\nThe 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.\n\n## Route a slice, not the whole thing\n\nYou 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](/blog/shipping-a-feature-into-a-five-year-old-plugin), 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.\n\nThe 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.\n\n## Dual-write before you dual-read\n\nWhen 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.\n\n```php\nadd_action( 'affapp_license_activated', function ( $license_id, $site_url ) {\n    // Legacy path stays authoritative until v2 is proven.\n    affapp_legacy_record_activation( $license_id, $site_url );\n\n    if ( affapp_activations_v2_enabled() ) {\n        \\AffApp\\Activations\\Repository::record( $license_id, $site_url );\n    }\n}, 10, 2 );\n```\n\nReads 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.\n\n## The version gate is the seatbelt\n\nEvery 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](/blog/backward-compatible-migrations-on-an-install-base) I hold everywhere.\n\n```php\nconst AFFAPP_DB_VERSION = 7;\n\nadd_action( 'plugins_loaded', 'affapp_maybe_upgrade_schema' );\n\nfunction affapp_maybe_upgrade_schema() {\n    $installed = (int) get_option( 'affapp_db_version', 0 );\n    if ( $installed >= AFFAPP_DB_VERSION ) {\n        return;\n    }\n\n    require_once __DIR__ . '/includes/schema.php';\n\n    if ( $installed \u003C 7 ) {\n        affapp_schema_7_add_activation_table();\n    }\n\n    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );\n}\n```\n\nEach 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.\n\n```php\nfunction affapp_schema_7_add_activation_table() {\n    global $wpdb;\n    $table   = $wpdb->prefix . 'affapp_activations';\n    $charset = $wpdb->get_charset_collate();\n\n    $sql = \"CREATE TABLE {$table} (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        license_id BIGINT UNSIGNED NOT NULL,\n        site_url VARCHAR(255) NOT NULL DEFAULT '',\n        activated_at DATETIME NOT NULL,\n        PRIMARY KEY  (id),\n        KEY license_id (license_id)\n    ) {$charset};\";\n\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n    dbDelta( $sql );\n}\n```\n\nNote 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.\n\n## Let the old growth die on its own schedule\n\nThe 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.\n\n## The takeaway\n\nThe 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.\n\nA 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.\n\n## FAQ\n\n**Should I ever rewrite a plugin from scratch?**\nAlmost 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.\n\n**What is the strangler fig pattern in a WordPress plugin?**\nYou 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.\n\n**Why keep the legacy function instead of deleting it after adding the new path?**\nBecause 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.\n\n**How do I turn on the new path safely?**\nUse 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.\n\n**When is it safe to delete the old code?**\nOnce 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.\n",[22,25,28,31,34],{"q":23,"a":24},"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.",{"q":26,"a":27},"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.",{"q":29,"a":30},"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.",{"q":32,"a":33},"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.",{"q":35,"a":36},"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.","7 min read",7,1785070532919]