Getting started

Plugin structure

Directory by directory through an installed XPAC plugin, using Forms as the worked example.

This is what you find if you open an XPAC plugin's directory on your own server. The example throughout is xpac-forms; substitute the package name and it describes any of them.

plugin.php
autoload.php

Four entries, and that is the whole plugin. There is no src/, no build config and no tooling in a shipped plugin — packages/ is the source you read, dist/ is what the browser gets.

plugin.php

The entry file WordPress reads. It opens with the plugin header:

/**
 * Plugin Name: Xpac: Forms
 * Plugin URI: https://xpac.io
 * Description: XPAC forms description.
 * Version: 1.0.0
 * Author: XPAC
 * Author URI: https://xpac.io
 * License: GPLv3
 * Text Domain: xpac-forms
 * Domain Path: /languages
 */

Then the entire bootstrap:

defined('ABSPATH') || exit;

require_once __DIR__ . '/vendor/autoload.php';

\XPACGroup\Plugin\Shared\Admin\MenuRoot::boot();

\XPACGroup\Plugin\Forms\Bootstrap::instance(__FILE__);

vendor/autoload.php comes first because everything after it is a class that has to be autoloaded. MenuRoot::boot() claims the single top-level admin menu that every XPAC page hangs off, and it runs before the package bootstraps because the filter it installs has to be in place while they register their settings on plugins_loaded. Then one Bootstrap::instance(__FILE__) per package.

__FILE__ is how the framework learns the plugin's path, directory and URL; it is also the file the activation, deactivation and uninstall hooks are registered against. That is why the argument is passed here and nowhere else.

The header's Version is not duplicated in PHP. Plugin::getVersion() (vendor/xpac/plugin/Plugin.php:324) memoises the value and delegates to getPluginData('Version'), which reads it back out of this file with get_plugin_data() (Plugin.php:341). The assets manager uses it as the cache-busting version on every enqueue.

packages/

The PHP. One directory per package, PascalCase, each mapped to its own namespace:

Bootstrap.php

Inside a package the layout is the package's own business — Forms puts its classes under core/base, core/entries and core/validation, other packages are flatter. The only fixed rule is Bootstrap.php at the package root.

These are real, citable paths. When the hook reference says a hook fires at packages/Forms/core/base/Uploader.php:167, that file is at exactly that path inside the installed plugin.

packages/Shared

Every XPAC plugin ships packages/Shared. It holds the code that belongs to no single feature:

FileWhat it is
Admin/MenuRoot.phpClaims the one top-level admin menu, so page order is not decided by plugin load order
Admin/AdminApp.phpBase class for an admin page that mounts a React app; subclasses pass config and implement bootData()
Admin/Brand.phpThe name, slug and icon of that menu root
Extensions/Extensions.phpThe extensions screen: lists XPAC addons, installs and activates the ones the licence covers
Settings/*The settings API — Settings, Page, Field, MenuPage, Rest, Helper

Because it is copied into every plugin, code in Shared cannot assume it is the only copy loaded on the site. Settings/Settings.php resolves its own asset paths from __DIR__ rather than from the plugin singleton for exactly that reason: on a site running several XPAC plugins, the singleton answers for whichever one constructed it first.

dist/

Compiled output. A feature package's bundle takes the package name in lowercase, so Forms builds to dist/packages/forms/:

dist/packages/forms/
├── admin/           the admin app bundle
├── blocks/          one directory per block
├── form/            the editor sidebar bundle
├── plugins/         standalone scripts, e.g. the input-mask plugin
├── masks.json
├── mime-types.json
└── styles.json

packages/Shared is the exception to that naming. Its settings screen compiles to dist/packages/settings/, so a plugin shipping one feature package still has two directories here.

Each block directory holds the metadata plus everything it references:

dist/packages/forms/blocks/text/
├── block.json
├── index.js          editorScript
├── index.asset.php   its dependency manifest
├── index.css
├── index-rtl.css
├── view.js           viewScript
└── view.asset.php    the viewScript's manifest

PHP registers blocks from dist/, never from packages/. Forms registers its form block with register_block_type(Bootstrap::instance()->getPluginDirPath('dist/packages/forms/blocks/form')) at packages/Forms/core/base/Blocks.php:331. Move or empty dist/ and the plugin registers no blocks at all.

.asset.php next to each script is the dependency manifest WordPress reads: the handles that script needs, plus a version hash used for cache busting. Every script gets its own, which is why text/ carries both index.asset.php and view.asset.php. The editor manifest lists sixteen handles, starting at lodash and react and running through the wp-* packages. Source maps ship beside the bundles.

The block reference names this path for every block — the Form block page cites dist/packages/forms/blocks/form/block.json, which is the file at exactly that path in your install.

vendor/

Composer's directory, and the only place third-party and framework code lives.

autoload.php
ClassLoader.php
autoload_psr4.php
autoload_static.php
installed.php

vendor/xpac/plugin is the framework: Plugin.php (the base class every Bootstrap extends), SettingsManager.php, PagesManager.php and PluginPage.php. vendor/xpac/assets-manager is AssetsManager.php, which handles script and style registration. A package that also needs licensing pulls in xpac/licensing, whose Updater its Bootstrap registers — Akismet does exactly that.

The framework namespaces are versioned: XPACGroup\PluginFramework\v1_0_12, XPACGroup\AssetsManager\v1_0_5. The version is part of the class name, not metadata beside it, so every import spells it out — use XPACGroup\PluginFramework\v1_0_12\Plugin; at the top of every Bootstrap.php.

vendor/composer/autoload_psr4.php is the generated map, and it is worth reading once because it tells you where every class in the plugin actually comes from. Its package and framework entries, with one further legacy entry left out that maps an older third-party namespace onto packages/Shared/Settings:

return array(
	'XPACGroup\\Plugin\\Shared\\' => array($baseDir . '/packages/Shared'),
	'XPACGroup\\Plugin\\Forms\\Validation\\' => array($baseDir . '/packages/Forms/core/validation'),
	'XPACGroup\\Plugin\\Forms\\Rules\\' => array($baseDir . '/packages/Forms/core/validation/rules'),
	'XPACGroup\\Plugin\\Forms\\Entries\\' => array($baseDir . '/packages/Forms/core/entries'),
	'XPACGroup\\Plugin\\Forms\\Core\\' => array($baseDir . '/packages/Forms/core/base'),
	'XPACGroup\\Plugin\\Forms\\' => array($baseDir . '/packages/Forms'),
	'XPACGroup\\PluginFramework\\v1_0_12\\' => array($vendorDir . '/xpac/plugin'),
	'XPACGroup\\AssetsManager\\v1_0_5\\' => array($vendorDir . '/xpac/assets-manager'),
);

$baseDir is the plugin root and $vendorDir is vendor/, so the split is visible in one glance: package namespaces point back into packages/, framework namespaces point into vendor/xpac/.

Next

On this page