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:
- Creates the table if it doesn't exist.
- Adds any missing columns or plain indexes if the table does exist.
- Leaves everything else alone. It never drops or rewrites a column, and it deliberately never adds
PRIMARY,UNIQUEorFOREIGNconstraints to an existing table, because those can fail against data that's already inconsistent.
// 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:
- A plugin can need a newer kernel. It says so with
Requires Kernel: x.y.zin its header. The installer refuses it, and activation won't run it until the site is updated. A script,bin/kernel-symbols.php, works out the right version from the kernel functions the plugin actually calls, using a ledger of when each function arrived. - The kernel can need a newer plugin. When the kernel drops code that a plugin now provides itself, it records a floor: the oldest version of each plugin it can run with. A plugin below its floor can't be installed or activated. Software update refuses a kernel release while an active plugin is below that release's floors, and offers the plugin updates on the same screen.
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
- Make the kernel product-neutral from day one. Retrofitting registries into a kernel that already knew about auctions was most of the work described above.
- Never reuse a version number. We did, across branches, and it made "the oldest version that has this fix" ambiguous when we set floors.
- Generate config you have to keep in sync, instead of hand-editing it. The routing rules exist three times: Apache
.htaccess, the PHP dev-server router, and an nginx example. They must be kept in sync by hand, and a mismatch between them is an easy bug to ship. - Write the "plugin absent" test first. The rule that the site must work with a plugin deleted is easy to state and easy to break. A test that boots the site without each plugin catches it.
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.