A WordPress site can accumulate plugins the way a workshop accumulates extension cords. Each one works. Nobody wants to unplug anything. Then one day you have twelve custom plugins registering content types, templates, rewrite rules and CSS on the same request.
That was the state of Optistream, a French site about Twitch, streaming hardware and esports. The custom stack generated roughly a thousand indexable pages: streamer profiles, emotes, Twitch errors, games, esports teams, players, commands, comparisons, glossary entries and setup pages.
We consolidated the stack into one plugin without changing the public URLs. The PHP was the easy part. The risky part was preserving WordPress behavior that had grown around it.
Why merge plugins that already work?
The old setup had several recurring problems:
- every module registered its own hooks and assets;
- template routing was spread across many files;
- shared helpers had slightly different copies;
- CSS loaded in an unpredictable order;
- debugging meant checking twelve plugin directories;
- a small change could activate several cache layers at once.
The goal was not fewer files. We still have modules, templates and stylesheets. The goal was one lifecycle and one place to reason about dependencies.
Inventory public behavior first
Before moving code, we listed every public contract:
/streamer/{slug}/
/emote/{slug}/
/erreur-twitch/{slug}/
/jeu/{slug}/
/equipe/{slug}/
/joueur/{slug}/
/commande-twitch/{slug}/
/comparatif/{slug}/
/glossaire/{slug}/
/setup/{slug}/
For each route we recorded:
- post type name;
- rewrite slug;
- archive setting;
- template file;
- taxonomies;
- meta keys consumed by the template;
- frontend assets;
- scheduled jobs or import scripts.
That list became the migration contract. If a route, meta key or template lookup changed, the migration was not done.
Keep modules boring
The consolidated plugin has a small loader. Each module owns one content domain and exposes a predictable bootstrap function.
final class Optistream_SEO {
private array $modules = [
'streamers',
'emotes',
'twitch-errors',
'games',
'esport-teams',
'players',
'twitch-commands',
'comparatifs',
'glossaire',
'setups',
'esport-hub',
];
public function boot(): void {
foreach ($this->modules as $module) {
require_once __DIR__ . "/modules/{$module}.php";
}
}
}
There is nothing clever here. That is intentional. Dynamic discovery saved a few lines but made load order harder to see during a failure.
Register content types before routing templates
All custom post types and taxonomies are registered on init. Template filters are added after the modules load.
add_action('init', 'optistream_register_players');
add_filter('single_template', function ($template) {
if (get_post_type() !== 'esport_player') {
return $template;
}
$custom = plugin_dir_path(__FILE__) . 'templates/single-player.php';
return is_readable($custom) ? $custom : $template;
});
A missing template should fall back to WordPress instead of producing a blank response. That fallback was useful while modules moved one by one.
Do not flush rewrites on every request
Calling flush_rewrite_rules() during normal page loads is an expensive way to hide registration bugs. We flush once on activation and once after the final migration.
register_activation_hook(__FILE__, function () {
optistream_register_all_content_types();
flush_rewrite_rules();
});
During development we also tested routes before and after flushing. A route that only works after repeated flushes usually points to a registration-order problem.
Preserve meta keys even when the PHP changes
Templates and import jobs communicated through post meta. Renaming those keys during the plugin merge would have turned a code migration into a data migration.
We kept keys such as:
_osp_real_name
_osp_nationality
_osp_birth_date
_osp_role
_osp_current_team
_osp_history
_osp_achievements
_osp_earnings
New helper functions wrapped them, but the stored keys did not move. Data cleanup can happen later, with its own rollback plan.
CSS was the most annoying failure mode
The site theme injects some inline styles, and the cache plugin can combine external stylesheets. A stylesheet that looked correct locally could lose to a later selector in production.
We fixed the ownership problem before increasing specificity everywhere:
- module styles load late with an explicit priority;
- selectors stay scoped to the module wrapper;
- critical hero styles live close to the template that owns them;
- CSS combine, minify and async modes stay disabled when they break media queries;
- caches are purged after deployment, not before testing the uploaded files.
add_action('wp_enqueue_scripts', function () {
if (!is_singular('esport_player')) {
return;
}
wp_enqueue_style(
'optistream-player',
plugins_url('assets/css/player.css', __FILE__),
[],
filemtime(__DIR__ . '/assets/css/player.css')
);
}, 999);
Using filemtime() removed a surprising amount of cache confusion.
Migrate one module at a time
We did not activate the new plugin and disable all old plugins in one shot. The safer loop was:
- move one module;
- compare its post type registration;
- open archive and single routes;
- inspect structured data and internal links;
- check mobile layout;
- move the next module.
The old plugins remained installed but inactive after the full cutover. That made rollback boring, which is exactly what you want.
Verify production, not just PHP syntax
php -l catches syntax errors. It does not catch a wrong rewrite slug, an empty card image, duplicate related-content blocks or a mobile overflow.
Our production checklist includes:
- representative URL for every content type returns 200
- canonical URL is unchanged
- title and meta description are present
- template path is the new one
- images load after lazy loading
- related content appears once
- no horizontal overflow at 390 px
- browser console has no new errors
- cache-busted CSS and JS return 200
We also read the saved WordPress content back after automated updates. That caught a save_post hook in an older module that was silently reinserting HTML we had removed.
What I would do differently
I would write the public-contract inventory before building the first programmatic module, not before merging the twelfth one. WordPress makes it easy to ship a custom post type. It is much harder to remember every dependency two years later.
The consolidated plugin did not make the system smaller. It made the boundaries visible. That has been more useful than the reduced plugin count.
The current implementation continues to power the programmatic sections on Optistream. If you are planning a similar consolidation, start with URLs and stored data. Code can move. Public contracts should stay boring.