← all writing

Nº02 // WRITING

How I build a Booknetic addon

I’ve shipped three Booknetic addons in the last few months, and on the surface they could not be more different: an offline bank-transfer payment gateway, a “force a deposit on first-time customers” rule that adds no gateway at all, and a SaaS-only addon that routes whole subdomains and custom domains (CNAME) to the right tenant. Different problems, three separate git repos. But under the hood they share one spine, and that spine — how I structure my own code on top of Booknetic — is what I actually want to talk about, because it’s the part that decides whether an addon stays maintainable or turns into a pile of glue I’m scared to touch.

Booknetic exposes a documented addon API: a way to register an addon, lifecycle hooks for where it runs, a capabilities layer for SaaS, a migration mechanism, and a set of add_filter / add_action extension points. The platform owns all of that. My job is to build on top of it cleanly. So this post is about my decisions, not a teardown of Booknetic’s internals.

Standalone plugin, never a fork

The first rule I set for myself: each addon is a standalone plugin in its own repo. I never fork Booknetic, never patch core, never edit a file that ships with the platform. Everything I do hangs off the public extension points Booknetic provides. The payoff is that a core update can’t silently undo my work, and I can reason about each addon as a small, self-contained unit. The cost is discipline — if I want to change behaviour, I have to find the right hook rather than reaching into something that isn’t mine. I’ll take that trade every time.

The skeleton every addon shares

Open any of my addons and you’ll see the same handful of files. init.php carries the plugin header, sets up autoloading, and registers the addon through Booknetic’s load filter. uninstall.php is guarded with defined('WP_UNINSTALL_PLUGIN') or exit;. composer.json maps a PSR-4 namespace to App/. The addon’s main class lives in App/ and plugs into Booknetic’s addon base class. Static hook callbacks live in App/Listener.php. Assets split into assets/{backend,frontend}/{js,css}, translations into a languages/booknetic-<slug>.pot.

I keep my namespace, folder name, slug, and text domain consistent across all three — booknetic-bank-transfer, booknetic-force-deposit, and booknetic-cname each line up the same way. That alignment is a convention I follow for my own sanity, so I never have to chase down a string that won’t translate or an addon that won’t appear. It’s cheap to keep consistent and expensive to get wrong, so I just always do it.

Two of the three (bank-transfer, force-deposit) ship without bundled dependencies, so init.php uses the generated Composer autoloader when it’s present and otherwise registers a tiny PSR-4 SPL autoloader, so the plugin works out of the box even if composer install was never run on the server. The CNAME addon goes the other way — it depends on a built vendor, so it just does require_once __DIR__ . '/vendor/autoload.php';, runs declare(strict_types=1) everywhere, and is the only one that commits its composer.lock because it pins dev tools I rely on.

Choosing where the addon lives

Booknetic’s addon base class gives you lifecycle methods, and the documented split is the useful part: an always-on path, separate backend and frontend paths, and SaaS-specific paths for behaviour that should only exist on the multi-tenant product. I treat picking the right one as a design decision, not a detail.

The CNAME addon is the clean illustration. It only makes sense on Booknetic SaaS, so its regular init paths stay empty and all the real work happens in the SaaS paths. I don’t fight the lifecycle Booknetic gives me — I let it route the code to the right place. Force-deposit is the opposite: it should behave identically on a single-site install and on SaaS, so its logic sits in the always-on path and the SaaS capability gate is the only thing that changes.

Gating for SaaS the way the platform asks

Booknetic’s SaaS layer provides a capabilities system: plan-level capabilities the super-admin toggles per plan, and per-role capabilities for what a tenant’s own users can do. When I want an addon to behave correctly on SaaS, I register through that system and gate my features on it, rather than inventing my own permission scheme. Bank-transfer, for example, registers a small tree of related capabilities — a settings cap, a “see transfers” cap, and approve/reject caps nested under it — and each of my handlers checks the right one before doing anything.

The important part on my side is that the gate sits at the very top of my own code paths and inside every AJAX handler, so there’s no path where a tenant who lacks a capability slips through. I treat “is this allowed?” as the first line of the function, every time, and I lean on Booknetic’s capability checks to answer it.

Storage: I encode defaults where the database can enforce them

When an addon needs storage, I lean on Booknetic’s migration mechanism rather than hand-rolling schema where I can avoid it. Here’s the shape of a table I own, written so the database itself enforces the invariants:

CREATE TABLE IF NOT EXISTS `{tableprefix}cname_domains` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `tenant_id` INT UNSIGNED NOT NULL,
  `hostname` VARCHAR(253) NOT NULL,
  `verification_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
  `is_primary` TINYINT(1) NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_hostname` (`hostname`),
  KEY `idx_tenant_primary` (`tenant_id`,`is_primary`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The choice I care about: I push defaults and uniqueness into the DDL (DEFAULT 'pending', the unique key on hostname) so the data layer is the source of truth, not some scattered PHP. That UNIQUE KEY is what makes “two tenants can’t claim the same host” a guarantee instead of a hope. When bank-transfer needed a different lifecycle I let it manage its own table and intentionally kept the table on uninstall, so a reinstall doesn’t wipe a tenant’s history — a product decision, encoded once.

Pure service + thin listener

This is the architectural thesis across all three, and it’s the part I’m proudest of. I keep every real decision in a plain service class that has no framework in it, and I quarantine every WordPress and Booknetic call in a thin listener. The service doesn’t know it’s running inside a plugin; the listener doesn’t make decisions, it just wires inputs in and side effects out.

ForceDepositService is the cleanest example — two static methods, isFirstTime() and resolveForcedDeposit(), that take plain values and return plain values. All the actual policy (percent math, fixed amount, the larger of the two, a subtotal cap, the zero-subtotal edge) lives there, framework-free, and is covered by unit tests that run with no WordPress loaded at all. The bootstrap just defines ABSPATH so the guarded entry files don’t exit, and the tests exercise the math directly.

final class ForceDepositService
{
    public static function resolveForcedDeposit(float $subtotal, array $rule): float
    {
        $percentPart = $subtotal * ($rule['percent'] ?? 0) / 100;
        $fixedPart   = (float) ($rule['fixed'] ?? 0);
        $amount      = max($percentPart, $fixedPart);
        return min($amount, $subtotal); // never charge more than the booking
    }
}

That one method has eight tests behind it precisely because money math is where a quiet off-by-something costs a real customer real money.

CNAME takes the same idea further into ports-and-adapters. DomainResolver::resolve($host, $baseDomain): ResolutionResult is a pure function over a roughly twelve-case truth table — apex passthrough, www→root redirect, canonical redirect to the primary host, wildcard/unknown host, tenant match. It does no I/O. The side effects live in a separate adapter that calls the pure resolver and then talks to WordPress. The pieces it needs from the outside world — a hostname lookup, a DNS lookup, a place to store verification state — are small interfaces. In production they get real adapters (the DNS one wraps dns_get_record(..., DNS_TXT)); in tests they get in-memory fakes, so a whole suite of resolver tests runs without touching DNS or a database. I keep the project PHPStan-clean at a strict level and run it on every change.

The reason I bother with the interfaces isn’t purity for its own sake. It’s that I can sit a fake DNS server in a test and assert exactly what happens when a TXT record is missing, wrong, or right — cases that are miserable to reproduce against the real internet.

The lesson stubbed tests don’t teach

Here’s the honest part. All those unit tests gave me real confidence in the logic, and they were worth every minute. But they also lied to me by omission: a service that’s perfect in isolation can still be wired into the wrong hook, fire at the wrong time, or hand back a shape the surrounding flow doesn’t expect. Stubs assert what I think the boundary looks like, not what it actually does.

So my rule now is that unit tests prove the decision and a live smoke test proves the integration. I keep a local Docker Booknetic SaaS site for exactly this. After the green test run I install the addon there, click through the real flow — book, pay, approve, or in CNAME’s case point a host at it and watch the redirect — and only then do I believe it. Every integration bug I’ve actually shipped would have sailed past my unit suite; the smoke test on the Docker site is what caught them. The bank-transfer “don’t leave the customer on an infinite spinner” behaviour and the CNAME hook-ordering both only revealed themselves live, never in a stub.

How a feature gets from idea to merged

The workflow is the same loop for every addon, and keeping it boring is the point. I brainstorm first — just me arguing with myself about what the thing should and shouldn’t do, where the edges are, what I’m deliberately leaving out. That turns into a short spec: the behaviour in plain language, the data it owns, the failure modes. The spec turns into a plan broken into small chunks I can finish and verify one at a time. Then I work the chunks: write the service and its tests, write the thin listener, run PHPStan and the unit suite, and smoke-test the chunk on the Docker SaaS site before moving on.

Brainstorm → spec → plan → chunks isn’t ceremony. It’s how I keep a feature from sprawling, and it’s why each addon ended up small enough that the service classes read like the spec and the listeners are almost boring. Boring listeners are the goal.

That’s the shape of it. Stay a standalone plugin, build only on the extension points Booknetic gives you and attribute them honestly, keep every real decision in a framework-free service you can test without WordPress, push your invariants down into the database, and never trust a green unit run until you’ve watched the thing work on a real site. The logic is where I spend my care; the integration is where the truth lives.