
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.
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.
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 operate-on-it-awake discipline I use instead of rewriting. By the time it is default-on, it has already been running quietly for weeks. The launch is a config change, not an event.
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.
Feature flags belong in the options table, not in a constant
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.
function affapp_feature_enabled( $feature, $default = false ) {
$flags = get_option( 'affapp_features', array() );
if ( ! is_array( $flags ) || ! array_key_exists( $feature, $flags ) ) {
return (bool) $default;
}
return (bool) $flags[ $feature ];
}
Then the new code sits behind it:
if ( affapp_feature_enabled( 'tiered_commissions' ) ) {
add_filter( 'affapp_calculate_commission', 'affapp_tiered_commission', 10, 3 );
}
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.
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.
The migration only adds, and it runs once
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. Additive-only migrations are the reason a rollout can be paused or reversed without leaving data in a shape the old code cannot read.
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.
const AFFAPP_DB_VERSION = 12;
add_action( 'plugins_loaded', 'affapp_maybe_upgrade' );
function affapp_maybe_upgrade() {
$installed = (int) get_option( 'affapp_db_version', 0 );
if ( $installed >= AFFAPP_DB_VERSION ) {
return;
}
if ( $installed < 12 ) {
affapp_migration_12_add_tier_column();
}
update_option( 'affapp_db_version', AFFAPP_DB_VERSION );
}
function affapp_migration_12_add_tier_column() {
global $wpdb;
$table = $wpdb->prefix . 'affapp_referrals';
// Guard the ALTER so a re-run does not error on an existing column.
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
DB_NAME,
$table,
'commission_tier'
)
);
if ( ! $exists ) {
$wpdb->query(
"ALTER TABLE {$table}
ADD COLUMN commission_tier VARCHAR(20) NOT NULL DEFAULT 'standard'"
);
}
}
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 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.
Roll out in percentages, watch, then widen
Default-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. I do not take that bet. I widen the flag in stages and watch between each.
A cheap way to do staged enablement without a backend service is to derive a stable bucket from something already unique per site.
function affapp_in_rollout( $feature, $percent ) {
if ( affapp_feature_enabled( $feature ) ) {
return true; // Explicit opt-in always wins.
}
$seed = get_option( 'affapp_install_id' ); // set once at activation
$bucket = hexdec( substr( md5( $seed . '|' . $feature ), 0, 4 ) ) % 100;
return $bucket < (int) $percent;
}
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.
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.
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.
Why this beats the war room
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.
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.
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.
FAQ
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.
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.
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.
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.
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.