Most commercial WordPress plugins ship as a pair: a free plugin in the repository and a paid one that adds to it. The interesting engineering question is not what the paid one does. It is how the two are joined.
The common answer is copying. Pro duplicates a class from Free and edits three lines. Pro forks a query because it needs one more column. Pro adds a table that means almost the same thing as a table Free already owns. Every one of those works on the day it is written, and every one of them is a scheduled failure: the next update to Free lands on code Pro no longer shares.
We hit this on our own free and pro pair and ended up writing the coupling down as a contract rather than a convention. This is what that contract contains, and which parts of it are worth stealing regardless of what you are building.
The rule that removes most of the problem
Pro never copies Free code and never duplicates Free tables.
That single sentence eliminates the two failure modes that account for most breakage in plugin pairs. Everything else in the contract exists to make it possible to obey.
If Pro cannot copy a class, it needs a supported way to change that class's behaviour. If Pro cannot add a parallel table, it needs a supported way to write into the one that exists. Those supported ways are the seams.
The table half of the rule matters more than it looks, and it is easier to obey if you decided your storage deliberately in the first place. We went through that reasoning separately in why we put community data in 20 custom tables instead of wp_posts, and the short version is that once each table has one owner and one purpose, "Pro must not duplicate it" stops being a rule you have to enforce and becomes the obvious thing to do.
Three seams, in preference order
There are exactly three, and the order matters. Reach for the first one that solves your problem.
One: rebind a container service key. Pro binds a Pro class onto an existing Free service key. The Pro class extends the Free class. Every part of the system that resolves that key keeps resolving the same key and transparently receives the Pro behaviour. No caller changes.
Two: inheritance. A Pro service class extends a Free service class and overrides one method, calling parent:: for the base behaviour and adding its own work around it.
Three: filters and actions. Pro attaches callbacks to Free's documented seams to modify a value or collect a signal, with no change to Free at all. This is ordinary WordPress hook work, the same mechanism covered in our guide to building your first plugin; what makes it a contract rather than a convenience is that the specific hooks are written down and guaranteed.
Preference order is not aesthetic. A container rebind changes behaviour for every caller at once, which is powerful and blunt. A filter changes one value at one point, which is precise and limited. Start with the sharpest tool that fits, and you avoid reaching for the blunt one out of habit.
What resolving through the container actually looks like
The mechanical part is small. Pro reads Free services out of the container by key instead of instantiating Free classes itself:
$follows = buddynext_service( 'follows' ); // global helper
$posts = $container->get( 'post_service' ); // from a class holding the containerNever new. The moment Pro writes new \BuddyNext\SocialGraph\FollowService(...), it has hard-coded a constructor signature it does not own, and it has opted out of every rebind anyone else makes.
In our case Pro consumes sixteen Free keys this way, covering assets, the email sender, the feed cache, follows, moderation, notifications and their preferences, permissions, the post service, privacy, profiles, reactions, search, spaces and outbound webhooks. Renaming any one of those in Free is a break, which is exactly why the list is written down rather than discovered.
The rebind seam, and the risk nobody documents
Here is the part that is genuinely sharp.
Our Pro rebinds two keys, feed and search. Both are behind feature toggles that default to off, so a default Pro install still hands you Free's class from the container. Turn the AI feed on and the bind runs:
// Runs only when the AI-feed toggle is on. The Pro service takes the same
// dependencies as the parent constructor.
$container->bind( 'feed', fn( $c ) => new \BuddyNextPro\AI\AiRankedFeedService(
$c->get( 'follows' ),
$c->get( 'post_service' ),
$c->get( 'feed_cache' )
) );AiRankedFeedService extends Free's FeedService, overrides homefeed(), calls parent::homefeed() and re-ranks the hydrated result.
Now the risk. A rebind changes what every caller of that key receives. Not just Pro's callers. Free's own callers, and yours. On a site with semantic search enabled, buddynext_service( 'search' ) does not return SearchService. It returns a subclass that reaches an embedding provider Free knows nothing about.
That is the point of the seam and it is also the trap, and the only honest thing to do is write it down. Two consequences follow:
- The subclass does not have to keep the parent's constructor signature. The bind closure constructs it explicitly, so it can take entirely different dependencies. Our
SemanticSearchServicetakes a singleembedding_providerwhere Free'sSearchServicetakes something else. - The subclass absolutely must keep the parent's public method contract. Existing callers keep calling it, and they have no idea a swap happened.
Pro also binds its own new keys, prefixed to avoid collision. Those are additions rather than rebinds. They overwrite nothing, and they are the safe majority of what Pro registers.
One more design decision worth copying: AiRankedFeedService re-ranks in PHP rather than rewriting the SQL. The SQL-level ordering filter stays free for third-party rerankers, so our own Pro feature does not consume the extension point everyone else needs.
Boot order is load-bearing, so freeze it
Three plugins loading in the wrong order produce bugs that look like anything except an ordering problem. So the order is fixed and documented:
- Free boots at
plugins_loaded:15, and fires its own loaded action at the end. - Pro boots at
plugins_loaded:20. - Bridge classes boot at
plugins_loaded:25.
Pro guards its own initialisation on Free's loaded action, so Pro never runs when Free is absent. An addon that needs Pro to be present hooks Pro's equivalent action rather than guessing a priority.
The practical rule for anyone building on top: do not add plugins_loaded hooks at other priorities from addon code that depends on this ordering. Priority numbers between the documented ones are not a supported place to stand.
Which hooks are frozen, and which are merely quiet
This distinction saves a lot of argument later.
Some hooks are load-bearing for the pair, because a real listener in the other plugin depends on them. Rename one and the pair breaks silently, with no fatal error and no log line. Post created, comment created, reaction added, user followed, ability granted and ability revoked all carry first-party listeners across the free and pro boundary.
Other hooks fire with no first-party listener at all. Those are stable extension seams for third-party code, and the important claim is this: no first-party consumer is not the same as private. A hook nobody internal listens to is still part of the public surface, and removing it because "nothing uses it" breaks every addon you never heard about.
The seams Pro attaches to are ordinary Free filters, and your addon can hook the same ones:
// Raise the pinned-post limit the way Pro does.
add_filter( 'buddynext_post_pin_limit', static function ( int $limit ): int {
return max( $limit, 5 );
} );
// Collect the same post-created signal Pro collects.
add_action( 'buddynext_post_created', static function ( int $post_id, int $user_id, string $type ): void {
// your analytics or indexing here
}, 10, 3 );The part that makes the whole thing hold
Everything above is a document, and documents rot. A contract nobody verifies is a comment.
So the list of container keys, the rebind table, and the frozen-hook list are covered by a test that fails when the code and the documentation drift apart. Rename a container key and the test goes red before anyone ships. Delete a hook that carries a cross-plugin listener and the test goes red.
This is the single most valuable thing in the entire arrangement, and it is also the cheapest. The test reads the code, reads the documented list, and compares them. It does not test behaviour. It tests that the promise still matches the implementation.
Building one is less work than it sounds. You need three things: a way to enumerate what the code actually registers, a machine-readable copy of what you promised, and an assertion that the two sets match. For container keys, the enumeration is a scan for every bind() call and every service lookup. For hooks, it is a scan for doaction and applyfilters alongside the list of hooks you documented as frozen.
The test should fail in both directions, and this is the part people get wrong. Catching a removed key is obvious. Catching an added key that nobody documented matters just as much, because an undocumented service key becomes load-bearing the moment a second class starts resolving it, and by then nobody remembers it was meant to be private.
If you take one idea from this piece, take that one. Not the container. Not the boot priorities. The test that stops your architecture document from quietly becoming fiction.
What this costs
It would be dishonest to present this as free.
Resolving everything through a container is more indirection than calling a class directly, and a developer new to the codebase has to learn the container before they can follow a call. The rebind seam is genuinely dangerous in the way described above. Writing down sixteen keys and a hook table is work, and keeping them current is more work, even with a test doing the checking.
What you get for it is a pair of plugins that can be updated independently, an addon surface that third parties can actually build on, and a very short list of things that are allowed to break the coupling. We think that trade is clearly worth it past the point where two plugins need to ship on different schedules. Below that point, a single plugin with a licence check is simpler and you should probably do that instead.
A short checklist
If you are building or auditing a free and pro pair, these are the questions worth asking:
- Does Pro ever instantiate a Free class with
new? Every instance is a hard-coded constructor signature you do not own. - Does Pro own any table that duplicates a Free table? Decide which plugin owns each table and write it down.
- Is boot order documented, or is it a set of priority numbers someone tuned until the bugs stopped?
- Is there a written list of the hooks that carry cross-plugin listeners?
- Does anything verify that list automatically, or does it depend on the person making the change remembering it exists?
Question five is the one that decides whether the other four stay true in a year.




No comments yet