Skip to content

This is part of Build log, a running series on the WordPress plugins I maintain for venue and community sites.

Most WordPress events plugins sit at one of two extremes. At one end you get a full ticketing platform: payments, seat maps, fee structures, an admin area with more screens than the venue has staff. At the other you get something so basic it can’t show a listings page without three companion plugins!

Neither works when you build for a lot of different venues. Community centres, village halls, a restaurant with a function room upstairs — none of them are selling out arenas, and all of them need a page that says what’s on. I wanted one thing I could drop onto any site and trust.

The constraint that shaped everything

The first decision wasn’t about features. It was that the plugin has to install in five minutes on a site I’ve never seen.

That rules out a few things: No Composer. No build step. No npm install producing a dist folder that has to be committed or generated. Nothing that assumes SSH access, a particular PHP package manager, or a deploy pipeline — because half the sites this lands on are on shared hosting with a control panel and an FTP login, and the other half I inherited from someone else.

What makes that possible is about a dozen lines at the top of the main plugin file:

spl_autoload_register(function (string $class): void {
    $prefix = __NAMESPACE__ . '\';

    if (! str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = SCEM_PATH . 'src/' . str_replace('\', '/', $relative) . '.php';

    if (is_file($path)) {
        require $path;
    }
});

It watches for any class in my own namespace, maps the namespace onto a folder path, and requires the file if it exists. Anything that isn’t mine, it ignores and hands back to whatever else is registered. That’s the entire dependency story: nothing to install, nothing to patch, nothing sitting in a corner going quietly out of date.

It’s not clever. Composer’s autoloader does more, faster, with a classmap. But the plugin has perhaps eighty classes, PHP’s own opcode cache handles the repetition, and the difference is unmeasurable next to a single database query. What I bought instead is the ability to say “copy this folder in and activate it” and have that be the whole of the installation instructions.

The same constraint shapes what happens on activation. The plugin doesn’t register anything on install — no example post type, no “Events” you then have to rename or delete. Activation sets a short-lived transient, the next admin page load notices it and redirects you to a screen asking what you want your events called. Nothing exists until you’ve answered. Deactivation flushes the rewrite rules back out, so a disabled plugin doesn’t leave URLs behind that resolve to nothing.

Every feature is a class with one method

The second decision was structural, and it’s the one that’s paid off most. There’s an interface, and it has exactly one method on it:

interface Hookable
{
    public function register(): void;
}

Every feature the plugin has implements it. Event types, the admin calendar, each meta box, the listings shortcode, the REST controller. A feature’s register() adds its own WordPress hooks and nothing else does that on its behalf. Booting the plugin is one list, read top to bottom:

$features = [
    new EventTypesPage($this->settings),
    new CalendarPage($this->settings),
    new EventListingShortcode($this->settings),
    new EventDetailsMetaBox($this->settings),
    new TicketsMetaBox($this->settings),
    new VenueDeletionGuard($this->settings),
    // the next one goes right here
];

foreach ($features as $feature) {
    $feature->register();
}

It looks almost too plain to write a paragraph about. But it means there is exactly one place to look to find out what the plugin does, and exactly one line to add when it does something new. When I rebuilt ticket pricing recently — a new support class, a rewritten meta box, a settings field — the wiring came to one changed line in that array. Everything else was self-contained.

The trade-off: there’s no dependency injection container, so constructor arguments get hand-wired in that list. At eighty classes that’s a non-issue and the explicitness is a feature — you can see what every part depends on. At eight hundred it would be a mistake, and I’d have to go and get a container, but I’ll cross that bridge if/when I come to it!

Every constraint in this plugin comes from the same place: it has to work on a site I’ve never seen, maintained by someone who didn’t ask for a build pipeline.