[{"data":1,"prerenderedAt":39},["ShallowReactive",2],{"blog-post-shipping-a-feature-into-a-five-year-old-plugin":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},"shipping-a-feature-into-a-five-year-old-plugin","Shipping a feature into a five-year-old plugin without a war room","Real tactics for shipping into an old WordPress plugin: feature flags, backward-compatible DB migrations, and staged rollouts across a live install base.","2026-06-03T00:00:00.000Z",null,[10,11,12,13,14],"wordpress","plugins","feature-flags","rollouts","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>Shipping into an old plugin should be a config change, not an event. Separate three decisions: ship the code, enable the behavior, and widen who sees it, using a database feature flag, an additive migration, and a staged percentage rollout.\u003C/p>\u003C/div>\n\u003Cp>Shipping a feature into a five-year-old plugin should be uneventful. If launching a feature needs a war room, a rollback bridge, and everyone on call, the process is broken, not the feature. On the affiliate and licensing plugins I maintain, a new feature reaches the install base in stages, behind a flag, on top of a migration that only adds. It is the same \u003Ca href=\"/blog/you-dont-rewrite-a-plugin-with-a-big-install-base\">operate-on-it-awake discipline I use instead of rewriting\u003C/a>. By the time it is default-on, it has already been running quietly for weeks. The launch is a config change, not an event.\u003C/p>\n\u003Cp>Here are the three tactics that make that true: feature flags that live in the database, migrations that never destroy, and rollouts that move in percentages instead of all at once.\u003C/p>\n\u003Ch2 id=\"feature-flags-belong-in-the-options-table%2C-not-in-a-constant\" tabindex=\"-1\">Feature flags belong in the options table, not in a constant\u003C/h2>\n\u003Cp>The mistake I see is gating a feature on a PHP constant or the plugin version. That gives you one lever with two positions, on for everyone or off for everyone, and no way to turn it off for one angry customer at 2am without shipping a patch release. A flag needs to be per-site state you can read and write at runtime.\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_feature_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $feature, $default \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> false\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $flags \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\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_features'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">array\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\"> ( \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">!\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> is_array\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $flags ) \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">||\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> !\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> array_key_exists\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $feature, $flags ) ) {\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\">) $default;\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:#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\">) $flags[ $feature ];\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>Then the new code sits behind it:\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\">if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ( \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">affapp_feature_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'tiered_commissions'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ) ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">    add_filter\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_calculate_commission'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'affapp_tiered_commission'\u003C/span>\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\">3\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>Because the flag is an option, I can flip it with WP-CLI on a single site, expose it as a hidden setting for beta customers, or force it off from a kill switch if support reports something ugly. The feature ships in the release turned off. Turning it on is a separate, reversible decision I make when I am ready, not something bundled into the moment a user clicks update.\u003C/p>\n\u003Cp>The kill switch matters more than the enable switch. When something breaks, I want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\u003C/p>\n\u003Ch2 id=\"the-migration-only-adds%2C-and-it-runs-once\" tabindex=\"-1\">The migration only adds, and it runs once\u003C/h2>\n\u003Cp>The feature needs a new column? Add it. Do not alter the meaning of an existing one, do not drop anything, and do not rename in a step a user cannot undo. \u003Ca href=\"/blog/backward-compatible-migrations-on-an-install-base\">Additive-only migrations\u003C/a> are the reason a rollout can be paused or reversed without leaving data in a shape the old code cannot read.\u003C/p>\n\u003Cp>I gate every schema change on a stored version number, separate from the plugin version, because users downgrade and restore backups and I cannot assume the plugin version tracks the actual schema state.\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\"> 12\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'\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\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\">    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\"> 12\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_migration_12_add_tier_column\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>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> affapp_migration_12_add_tier_column\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_referrals'\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:#6A737D;--shiki-dark:#6A737D\">    // Guard the ALTER so a re-run does not error on an existing column.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $exists \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_var\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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\">prepare\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">            \"\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> COUNT\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">(\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\">FROM\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> information_schema\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">COLUMNS\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">             WHERE\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> TABLE_SCHEMA \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> %s \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">AND\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> TABLE_NAME \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> %s \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">AND\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> COLUMN_NAME \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> %s\"\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">            DB_NAME\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">            $table,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">            'commission_tier'\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>\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:#D73A49;--shiki-dark:#F97583\">!\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $exists ) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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\">query\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">            \"\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">ALTER\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:#D73A49;--shiki-dark:#F97583\">             ADD\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> COLUMN commission_tier \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\">20\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\"> 'standard'\"\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>\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>Two details carry the safety here. The new column has a default, so every existing row is instantly valid without a backfill and the old code, which never selects this column, is unaffected. And the \u003Ccode>information_schema\u003C/code> check means a half-finished migration that timed out and reloaded does not throw a duplicate-column error. Idempotent steps are the difference between a migration that is safe to retry and one that corrupts state when a shared host kills it at thirty seconds.\u003C/p>\n\u003Ch2 id=\"roll-out-in-percentages%2C-watch%2C-then-widen\" tabindex=\"-1\">Roll out in percentages, watch, then widen\u003C/h2>\n\u003Cp>Default-on for the whole install base in one release is a bet that your feature is correct against every data shape, host, and \u003Ca href=\"/blog/supporting-a-plugin-across-wordpress-versions\">PHP version out there\u003C/a>. I do not take that bet. I widen the flag in stages and watch between each.\u003C/p>\n\u003Cp>A cheap way to do staged enablement without a backend service is to derive a stable bucket from something already unique per site.\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_in_rollout\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $feature, $percent ) {\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_feature_enabled\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $feature ) ) {\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\"> true\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">; \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// Explicit opt-in always wins.\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:#24292E;--shiki-dark:#E1E4E8\">    $seed   \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\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_install_id'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ); \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// set once at activation\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $bucket \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> hexdec\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">substr\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">md5\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">( $seed \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\"> .\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $feature ), \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">4\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ) ) \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">%\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 100\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\">    return\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $bucket \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">&#x3C;\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\">) $percent;\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 bucket is stable, so a site that is in the 10 percent cohort stays in it as I widen to 25, then 50, then 100. Nobody flickers in and out of the feature between page loads. I move the percentage up over a week or two, reading support tickets and error logs between steps.\u003C/p>\n\u003Cp>The thing I watch hardest in the early cohort is the environment, not the feature itself. An unusual host or a PHP version I thought nobody still ran is what turns a clean feature into a fatal on someone’s site, and that is exactly why the rollout is a dial I turn on evidence, not a switch I throw on a schedule.\u003C/p>\n\u003Cp>If the early cohort is quiet, I widen. If it is not, I stop widening, and the kill switch takes the feature off the sites that already have it. Nobody who is not yet in the rollout is affected either way. That is the entire value of the staged approach: a problem is capped at the cohort size, not the install base.\u003C/p>\n\u003Ch2 id=\"why-this-beats-the-war-room\" tabindex=\"-1\">Why this beats the war room\u003C/h2>\n\u003Cp>A war room exists because the whole change lands at once and there is no way to take back one piece of it. Every tactic here is about removing that condition. The flag decouples shipping code from enabling behavior. The additive migration means the old code path is always valid, so pausing costs nothing. The staged rollout caps blast radius to a slice you chose.\u003C/p>\n\u003Cp>Put together, the scary launch becomes a series of small, reversible moves. The code shipped weeks ago, turned off. It ran for the beta cohort, then ten percent, then half, each step boring. When it finally goes default-on, it is the least interesting thing that happened that day, and that is exactly what you want from a plugin that a lot of people are quietly depending on right now.\u003C/p>\n\u003Cdiv class=\"post-takeaway\">\u003Cdiv class=\"post-takeaway-label\">The takeaway\u003C/div>\u003Cp>Shipping into an old plugin stops being scary the moment you stop collapsing three decisions into one. Shipping the code, enabling the behavior, and widening who sees it become separate, reversible levers: a database flag, an additive migration, and a percentage rollout. Pull them one at a time and the launch is a calm afternoon’s config change, capped to a cohort you chose, instead of an all-at-once bet that needs a war room.\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\">Where should a plugin feature flag live?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>In the options table as per-site state, not in a PHP constant or the plugin version. That way you can flip it with WP-CLI on a single site, expose it to beta customers, or force it off with a kill switch without shipping a patch release.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Why does the kill switch matter more than the enable switch?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Because when something breaks you want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">How do I stage a rollout without a backend service?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Derive a stable bucket from something already unique per site, like an install ID hashed with the feature name, then compare it against the target percentage. The bucket is stable, so a site in the ten percent cohort stays in it as you widen to twenty-five, fifty, then one hundred.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">How do I make a migration safe to pause or reverse?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Only add, give new columns a default so existing rows are instantly valid, and guard the change so a re-run does not throw a duplicate-column error. Additive-only means the old code path is always valid, so pausing costs nothing.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">What percentage should I start a rollout at?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>Start with an explicit beta cohort, then widen through roughly ten, twenty-five, fifty, and one hundred percent over a week or two, reading support tickets and error logs between each step and stopping the moment the early cohort gets noisy.\u003C/p>\u003C/div>\u003C/div>\u003C/div>","\n## TL;DR\n\nShipping into an old plugin should be a config change, not an event. Separate three decisions: ship the code, enable the behavior, and widen who sees it, using a database feature flag, an additive migration, and a staged percentage rollout.\n\nShipping a feature into a five-year-old plugin should be uneventful. If launching a feature needs a war room, a rollback bridge, and everyone on call, the process is broken, not the feature. On the affiliate and licensing plugins I maintain, a new feature reaches the install base in stages, behind a flag, on top of a migration that only adds. It is the same [operate-on-it-awake discipline I use instead of rewriting](/blog/you-dont-rewrite-a-plugin-with-a-big-install-base). By the time it is default-on, it has already been running quietly for weeks. The launch is a config change, not an event.\n\nHere are the three tactics that make that true: feature flags that live in the database, migrations that never destroy, and rollouts that move in percentages instead of all at once.\n\n## Feature flags belong in the options table, not in a constant\n\nThe mistake I see is gating a feature on a PHP constant or the plugin version. That gives you one lever with two positions, on for everyone or off for everyone, and no way to turn it off for one angry customer at 2am without shipping a patch release. A flag needs to be per-site state you can read and write at runtime.\n\n```php\nfunction affapp_feature_enabled( $feature, $default = false ) {\n    $flags = get_option( 'affapp_features', array() );\n    if ( ! is_array( $flags ) || ! array_key_exists( $feature, $flags ) ) {\n        return (bool) $default;\n    }\n    return (bool) $flags[ $feature ];\n}\n```\n\nThen the new code sits behind it:\n\n```php\nif ( affapp_feature_enabled( 'tiered_commissions' ) ) {\n    add_filter( 'affapp_calculate_commission', 'affapp_tiered_commission', 10, 3 );\n}\n```\n\nBecause the flag is an option, I can flip it with WP-CLI on a single site, expose it as a hidden setting for beta customers, or force it off from a kill switch if support reports something ugly. The feature ships in the release turned off. Turning it on is a separate, reversible decision I make when I am ready, not something bundled into the moment a user clicks update.\n\nThe kill switch matters more than the enable switch. When something breaks, I want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\n\n## The migration only adds, and it runs once\n\nThe feature needs a new column? Add it. Do not alter the meaning of an existing one, do not drop anything, and do not rename in a step a user cannot undo. [Additive-only migrations](/blog/backward-compatible-migrations-on-an-install-base) are the reason a rollout can be paused or reversed without leaving data in a shape the old code cannot read.\n\nI gate every schema change on a stored version number, separate from the plugin version, because users downgrade and restore backups and I cannot assume the plugin version tracks the actual schema state.\n\n```php\nconst AFFAPP_DB_VERSION = 12;\n\nadd_action( 'plugins_loaded', 'affapp_maybe_upgrade' );\n\nfunction affapp_maybe_upgrade() {\n    $installed = (int) get_option( 'affapp_db_version', 0 );\n    if ( $installed >= AFFAPP_DB_VERSION ) {\n        return;\n    }\n\n    if ( $installed \u003C 12 ) {\n        affapp_migration_12_add_tier_column();\n    }\n\n    update_option( 'affapp_db_version', AFFAPP_DB_VERSION );\n}\n\nfunction affapp_migration_12_add_tier_column() {\n    global $wpdb;\n    $table = $wpdb->prefix . 'affapp_referrals';\n\n    // Guard the ALTER so a re-run does not error on an existing column.\n    $exists = $wpdb->get_var(\n        $wpdb->prepare(\n            \"SELECT COUNT(*) FROM information_schema.COLUMNS\n             WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s\",\n            DB_NAME,\n            $table,\n            'commission_tier'\n        )\n    );\n\n    if ( ! $exists ) {\n        $wpdb->query(\n            \"ALTER TABLE {$table}\n             ADD COLUMN commission_tier VARCHAR(20) NOT NULL DEFAULT 'standard'\"\n        );\n    }\n}\n```\n\nTwo details carry the safety here. The new column has a default, so every existing row is instantly valid without a backfill and the old code, which never selects this column, is unaffected. And the `information_schema` check means a half-finished migration that timed out and reloaded does not throw a duplicate-column error. Idempotent steps are the difference between a migration that is safe to retry and one that corrupts state when a shared host kills it at thirty seconds.\n\n## Roll out in percentages, watch, then widen\n\nDefault-on for the whole install base in one release is a bet that your feature is correct against every data shape, host, and [PHP version out there](/blog/supporting-a-plugin-across-wordpress-versions). I do not take that bet. I widen the flag in stages and watch between each.\n\nA cheap way to do staged enablement without a backend service is to derive a stable bucket from something already unique per site.\n\n```php\nfunction affapp_in_rollout( $feature, $percent ) {\n    if ( affapp_feature_enabled( $feature ) ) {\n        return true; // Explicit opt-in always wins.\n    }\n\n    $seed   = get_option( 'affapp_install_id' ); // set once at activation\n    $bucket = hexdec( substr( md5( $seed . '|' . $feature ), 0, 4 ) ) % 100;\n\n    return $bucket \u003C (int) $percent;\n}\n```\n\nThe bucket is stable, so a site that is in the 10 percent cohort stays in it as I widen to 25, then 50, then 100. Nobody flickers in and out of the feature between page loads. I move the percentage up over a week or two, reading support tickets and error logs between steps.\n\nThe thing I watch hardest in the early cohort is the environment, not the feature itself. An unusual host or a PHP version I thought nobody still ran is what turns a clean feature into a fatal on someone's site, and that is exactly why the rollout is a dial I turn on evidence, not a switch I throw on a schedule.\n\nIf the early cohort is quiet, I widen. If it is not, I stop widening, and the kill switch takes the feature off the sites that already have it. Nobody who is not yet in the rollout is affected either way. That is the entire value of the staged approach: a problem is capped at the cohort size, not the install base.\n\n## Why this beats the war room\n\nA war room exists because the whole change lands at once and there is no way to take back one piece of it. Every tactic here is about removing that condition. The flag decouples shipping code from enabling behavior. The additive migration means the old code path is always valid, so pausing costs nothing. The staged rollout caps blast radius to a slice you chose.\n\nPut together, the scary launch becomes a series of small, reversible moves. The code shipped weeks ago, turned off. It ran for the beta cohort, then ten percent, then half, each step boring. When it finally goes default-on, it is the least interesting thing that happened that day, and that is exactly what you want from a plugin that a lot of people are quietly depending on right now.\n\n## The takeaway\n\nShipping into an old plugin stops being scary the moment you stop collapsing three decisions into one. Shipping the code, enabling the behavior, and widening who sees it become separate, reversible levers: a database flag, an additive migration, and a percentage rollout. Pull them one at a time and the launch is a calm afternoon's config change, capped to a cohort you chose, instead of an all-at-once bet that needs a war room.\n\n## FAQ\n\n**Where should a plugin feature flag live?**\nIn the options table as per-site state, not in a PHP constant or the plugin version. That way you can flip it with WP-CLI on a single site, expose it to beta customers, or force it off with a kill switch without shipping a patch release.\n\n**Why does the kill switch matter more than the enable switch?**\nBecause when something breaks you want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.\n\n**How do I stage a rollout without a backend service?**\nDerive a stable bucket from something already unique per site, like an install ID hashed with the feature name, then compare it against the target percentage. The bucket is stable, so a site in the ten percent cohort stays in it as you widen to twenty-five, fifty, then one hundred.\n\n**How do I make a migration safe to pause or reverse?**\nOnly add, give new columns a default so existing rows are instantly valid, and guard the change so a re-run does not throw a duplicate-column error. Additive-only means the old code path is always valid, so pausing costs nothing.\n\n**What percentage should I start a rollout at?**\nStart with an explicit beta cohort, then widen through roughly ten, twenty-five, fifty, and one hundred percent over a week or two, reading support tickets and error logs between each step and stopping the moment the early cohort gets noisy.\n",[22,25,28,31,34],{"q":23,"a":24},"Where should a plugin feature flag live?","In the options table as per-site state, not in a PHP constant or the plugin version. That way you can flip it with WP-CLI on a single site, expose it to beta customers, or force it off with a kill switch without shipping a patch release.",{"q":26,"a":27},"Why does the kill switch matter more than the enable switch?","Because when something breaks you want to disable one feature on one site in seconds, not roll the whole plugin back and take every other fix down with it.",{"q":29,"a":30},"How do I stage a rollout without a backend service?","Derive a stable bucket from something already unique per site, like an install ID hashed with the feature name, then compare it against the target percentage. The bucket is stable, so a site in the ten percent cohort stays in it as you widen to twenty-five, fifty, then one hundred.",{"q":32,"a":33},"How do I make a migration safe to pause or reverse?","Only add, give new columns a default so existing rows are instantly valid, and guard the change so a re-run does not throw a duplicate-column error. Additive-only means the old code path is always valid, so pausing costs nothing.",{"q":35,"a":36},"What percentage should I start a rollout at?","Start with an explicit beta cohort, then widen through roughly ten, twenty-five, fifty, and one hundred percent over a week or two, reading support tickets and error logs between each step and stopping the moment the early cohort gets noisy.","8 min read",8,1785070532920]