Case study
wp-base
A logic-only WordPress framework shipped as a Composer package and consumed by client themes. The version pin is a checked fact rather than a declared one, so two live sites cannot quietly drift apart.
- WordPress
- PHP 8.4
- Composer
- PHPUnit
- Jest
- GitHub Actions

What it is
A shared WordPress framework, published as a private Composer package and required by a client theme
rather than sitting under it. Not a parent theme. Not a plugin. The theme's functions.php boots it
and declares which modules it wants; the framework owns the mechanism.
- Consent
- Seo
- Security
- Comments
- Assets
- Blocks
- Models
- Theme
Two live client sites run on it today, on the same release.
The problem it exists to solve
The normal way to run several WordPress sites is to keep a good starter theme and copy it forward. That works until the second site.
I had two sites from a common starting point, left to evolve separately. When someone finally measured, they had diverged by 67 to 79 percent. Neither was bad. The problem was that a fix had stopped being a fix and become two fixes, written twice, tested twice, and remembered once. A consent bug on one site was not a consent bug on the other; it was a different consent bug in adjacent code.
A framework does not prevent divergence by itself. What prevents divergence is making the version a fact somebody checks.
The pin is a checked fact, not a declaration
Each site records which release it consumes. The tempting version of this is a line of prose in a project doc, which is self-certifying: a site can claim it is current while its lockfile sits two majors behind and nothing notices.
So the check reads composer.lock, not the prose, and compares it against the framework's latest
stable tag. Prose is intent; the lockfile is what Composer actually resolved. Prereleases are
excluded, so a release candidate cannot outrank the stable release it precedes. A major version lag
fails; a minor lag reports.
It is deliberately not Dependabot. Automatically bumping your own framework across live client sites is the wrong default: it turns "a release exists" into "a client site changed", and those are different decisions with different blast radius. The check reports lag. A human decides when a live site moves.
The same idea runs through everything here. Assert the fact, not the declaration.
Where the boundary is, and what cannot live in the framework
The rule is: fix behaviour upstream, cut a release, bump the pin. Never patch around the framework inside a site, because that is how the 67 percent happened.
The interesting part is the exception. Some things are correct only because of where in the tree
they sit, and those can never move upstream. .htaccess governs its own directory and everything
below it, and the framework installs below the theme root at vendor/, so a framework-supplied
rule would sit underneath the very files it needs to protect. Deployment ignore lists have the same
shape.
"Fix it upstream" is right for behaviour and wrong for anything whose correctness is positional. Knowing which of the two you are looking at is most of the skill.
Tested, because a shared dependency multiplies mistakes
146 PHP tests carrying 270 assertions, plus 88 JavaScript tests. That is not thoroughness for its own sake: a bug in a theme is one site, and a bug in the framework is every site at once.
One failure is worth reporting because tests could not catch it. A rename swept the codebase and shipped a malformed cookie name, and the whole suite agreed it was correct, because the same sweep had rewritten the string literals in the tests. A rename is invisible to the suite the rename edited. It was caught by reading the served page, which is now a step rather than an instinct.
Deployment: the pipeline is the only way in
Every deploy runs from CI, on manual dispatch, and nothing else can reach a server. No push to any branch triggers one, so deploying is a decision rather than a side effect of merging. There is no path where a person moves files onto a client server by hand.
That is the control model, and it is the part that does not change.
Two invariants, both learned by getting them wrong:
- A health check asserts content, not status. A cached page returns 200 whether or not your deploy landed, so a status check passes against the previous build. The assertion is a string that changes every release.
- On a cached site, the purge is part of the deploy. A deploy that changes rendered HTML is not finished until the cache is cleared, and clearing it is not one step.
What is changing is the transport inside that pipeline, from FTPS to rsync over SSH. The reason is a failure mode rather than a preference: the FTPS tooling decides what to delete by diffing a manifest it keeps on the server, so the manifest is a second source of truth that can disagree with reality. rsync compares the real filesystem.
The SSH credential belongs to the pipeline, not to a laptop. It is a deploy key held in CI, scoped to the one job it does, and it is deliberately not the same credential a human would use to log in and look around. Those are different levels of access with different risk, and collapsing them into one key is how "we needed to check something" becomes "someone has a shell on a client's production server". Interactive access, where it exists at all, is separate, and read-only work stays read-only.
The larger gain is what comes with the connection rather than the transfer. WP-CLI turns post-deploy verification from inference into assertion: instead of fetching a page and reasoning about what it implies, the pipeline asks the installation directly what version, what options, what plugin state, and fails the job when the answer is wrong. The cache purge stops being a manual afterthought and becomes a step in the same run. And a credential type retires rather than accumulating.
Written while that migration is in progress, so read this as the direction and the reasoning rather than a finished description. The control model above is already how it works.
What I would change
The framework is private, which is right for now and will not stay right forever. The modules that have nothing client-specific in them, particularly consent, would be more useful and better tested as something public. The blocker is not the code; it is that a public package acquires users, and users acquire expectations about support that a two-site framework has not earned yet.