PrizeX started as a single PHP application that did auctions. Over time it grew lotteries, mystery boxes, a shop, games, rewards and dozens of payment gateways. Today it's a small kernel plus 98 plugins, and buyers choose which plugins they run. This is a write-up of how that plugin system works and what we learned building it. It's for PHP developers, with real code from the codebase. There's no framework involved: plain PHP 8.2 and MySQL/MariaDB through mysqli.

Why a kernel and plugins

We split the code because we sell several products from one codebase. BidKing (auctions), RaffKing (draws) and PrizeX (everything) are the same kernel with different plugins switched on. One rule makes this work: the kernel never needs any plugin to be present. A site has to load, take payments and run its admin with every product plugin deleted. Each product lives entirely in its plugin, and the kernel only offers places for plugins to hook in.

Read the header without running the plugin

A plugin is a folder with a plugin.php whose header comment describes it, the same convention WordPress uses. The admin needs to list installed plugins, including inactive and broken ones, without executing any of their code. So the header is parsed as text:

function plugin_read_header(string $file): ?array
{
    if (!is_file($file)) return null;
    $data = file_get_contents($file, false, null, 0, 8192); // header lives at the top
    // ... match "Plugin Name:", "Version:", "Requires Kernel:" and so on
    if ($out['name'] === '') return null;
    return $out;
}

Richer details, such as the long description, changelog and dependencies, live in an optional plugin.json that is only ever json_decoded, never executed. Several kernel features read plugin.json from plugins that are installed but switched off. That's how a disabled plugin's homepage sections keep their place, and how its email templates still appear in the admin.

Define every registry before any plugin loads

Plugins register routes, admin pages, cron jobs and API endpoints. If a plugin's top-level code calls add_route() before add_route() exists, the site goes down with a fatal error. So the bootstrap loads the hook engine and every registry first, and only then boots plugins:

require_once __DIR__ . "/hooks.php";   // Action/filter hook engine
require_once __DIR__ . "/plugins.php"; // Plugin registry + loader
require_once __DIR__ . "/routes.php";  // Plugin-owned frontend route registry (add_route)
require_once __DIR__ . "/menus.php";   // Storefront navigation menu resolver
require_once __DIR__ . "/assets.php";  // enqueue_script() asset registry
plugins_load($mysqli);                 // Boot active plugins; fires 'plugins_loaded'

The result is that a plugin's registration calls never fatal, even when the plugin ends up inactive. Activation is defensive in the same way. If a plugin's plugin.php throws while loading, it isn't activated, and the admin is shown the error instead of the site breaking.

The kernel names no product

This took the longest. Early on, the kernel had "if auction is active, do X" scattered throughout: homepage, search, cart, admin dashboard, email templates. We replaced each of these with a registry the product fills in. Search sources, homepage sections, cart line types, prize sources, dashboard cards and menu items all work this way. The kernel asks "who registered a search source?", never "is the auction plugin on?".

To stop it creeping back, bin/check-kernel-purity.php counts the places the kernel still names a product. A probe fails the build if any file gets worse than a recorded baseline. Ratchets like this are cheap and they work: the number can only go down.

Schema changes are additive only

Each plugin owns its tables and creates them on activation through one helper, db_delta(). You pass it a CREATE TABLE statement and it does three things:

// Never touch these — can fail against data already inconsistent
// with the new constraint (duplicate values, orphaned rows).
if (preg_match('/^(PRIMARY|UNIQUE|CONSTRAINT|FOREIGN|FULLTEXT|SPATIAL|CHECK)\b/i', $line)) {
    continue;
}

Running activation twice is always safe. Anything destructive goes into a separate, explicit migration file instead of the activation hook.

Versions in both directions

Plugins and the kernel update separately, so compatibility has to be checked both ways:

Themes override components, plugins bring their own

Every storefront theme can override any component. A plugin can also ship its own per-theme copy of the components it owns. Resolution is a simple ordered lookup:

function theme_component_path(string $relPath, string $slug): string {
    $override = themes_dir() . '/' . $slug . '/components/' . $relPath;
    if (is_file($override)) return $override;           // 1. the theme's own copy
    foreach (modules_registered() as $module) {          // 2. the owning plugin's
        // themes/<slug>/components/... then its default components_dir
    }
    return dirname(__DIR__) . '/components/' . $relPath; // 3. the kernel default
}

One detail worth copying: if nothing matches, the function still returns a path rather than an empty string. On PHP 8, include('') throws an uncaught ValueError and takes the whole page down. Including a merely missing file only warns, which leaves a blank section instead of a 500 error. A separate check script verifies that every component actually exists.

What we'd do differently

Common questions

How do you build a plugin system in PHP?

Give each plugin a folder and a header you can read without executing it. Define all hook and registry functions before loading any plugin. Have plugins register behaviour through hooks and registries, rather than the core checking for them. Keep the core working with every plugin removed.

How should plugins manage database tables?

Let each plugin own its tables and create them on activation with an idempotent, additive-only helper: create if missing, add missing columns, never drop or rewrite. Put destructive changes in explicit, separate migrations.

How do you handle plugin and core version compatibility?

Check both directions. A plugin declares the oldest core it needs, and the core declares the oldest version of each plugin it can run with. Refuse installs, activations and updates that would break either rule.

Is PrizeX built on a framework?

No. It's plain PHP 8.2 with mysqli and MariaDB or MySQL, a hand-written hook engine and the registries described here.

PrizeX — the platform this kernel runs Auctions, draws, mystery boxes, a shop and rewards as plugins on one self-hosted kernel. Plain PHP 8.2, full source.