Package anatomy
Inside a package: Bootstrap.php, namespaces, components, and how blocks, settings and REST routes get registered.
A package is one feature, in one directory, under one namespace. It is the unit XPAC builds everything out of, and it is small enough to copy: if you want your own code to sit alongside XPAC's and behave the same way, this page is the shape to follow.
The worked examples are packages/Forms — a full feature package — and
packages/Shared, the package every XPAC plugin carries. Every claim below cites
the file it came from.
What a package is
packages/Forms/
├── Bootstrap.php
└── core/
├── base/ Blocks, PostType, Rest, Settings, Email, Uploader, …
├── entries/ Database, Logger, Cleaner, Migrations
└── validation/ Validators and the individual rulesTwo rules, and only two:
- The directory is PascalCase and matches the package name.
Bootstrap.phpsits at its root and holds a class called exactlyBootstrap.
Everything below that is the package's own business. Forms groups its classes
into core/base, core/entries and core/validation; packages/Shared is a
flat three directories, Admin/, Extensions/ and Settings/. Neither layout
is more correct — but whichever you pick has to be declared, because the
namespace does not follow the directory automatically.
The Bootstrap entry point
packages/Forms/Bootstrap.php opens like this:
namespace XPACGroup\Plugin\Forms;
use XPACGroup\PluginFramework\v1_0_12\Plugin;
final class Bootstrap extends PluginPlugin is the framework base class at vendor/xpac/plugin/Plugin.php. Its
constructor is private, so a package is never instantiated with new. The way
in is the static factory, called once from the plugin entry file:
\XPACGroup\Plugin\Forms\Bootstrap::instance(__FILE__);instance() is final public static and memoises one object per called class
(Plugin.php:159). That is what lets any class in the package call
Bootstrap::instance() later with no argument at all and get the same object
back — which is how components reach $this->assets, the plugin directory path
and the version without any of it being passed around.
The constructor runs a fixed sequence: record the plugin file, directory and URL;
call init(); register the activation, deactivation and uninstall hooks; add
loadTextDomain on plugins_loaded; call hooks().
That leaves a package five places to put its own code:
| Method | Required | When it runs |
|---|---|---|
init() | Yes — abstract protected at Plugin.php:141 | Immediately, during construction |
hooks() | No — no-op default at Plugin.php:148 | Immediately after init() |
handleActivation() | No — Plugin.php:356 | On register_activation_hook |
handleDeactivate() | No — Plugin.php:365 | On register_deactivation_hook |
handleUninstall() | No — public static no-op at Plugin.php:245 | On register_uninstall_hook |
init() runs while the plugin entry file is still executing — before
plugins_loaded, long before init. Almost no WordPress API is safe there.
Register components and let them subscribe to hooks; do not do work.
Forms uses hooks() for the two things that genuinely need a hook, and fires its
own action so that its components — and anybody else's code — have a safe moment
to run:
protected function hooks(): void
{
add_action('plugins_loaded', [$this, 'maybeRunMigrations'], 9);
add_action('init', function (): void {
do_action('xpac_forms_init');
}, 200);
}packages/Forms/Bootstrap.php:88. That xpac_forms_init action is the package's
front door: every Forms component hangs off it, and so does every addon.
handleActivation() is where one-time install work goes — Forms creates its
entries tables, runs the migration runner, and clears rewrite_rules
(packages/Forms/Bootstrap.php:33).
Namespace and autoloading
The convention is XPACGroup\Plugin\<PackageName>, so packages/Forms is
XPACGroup\Plugin\Forms and packages/Shared is XPACGroup\Plugin\Shared.
Sub-namespaces are mapped explicitly, not derived. Forms needs five entries
because its directory names and its namespace segments deliberately differ —
core/base is Core, core/validation is Validation:
"autoload": {
"psr-4": {
"XPACGroup\\Plugin\\Forms\\": "packages/Forms",
"XPACGroup\\Plugin\\Forms\\Core\\": "packages/Forms/core/base",
"XPACGroup\\Plugin\\Forms\\Validation\\": "packages/Forms/core/validation",
"XPACGroup\\Plugin\\Forms\\Rules\\": "packages/Forms/core/validation/rules",
"XPACGroup\\Plugin\\Forms\\Entries\\": "packages/Forms/core/entries"
}
}The same composer.json names the package xpac-plugins/forms, requires
php >=7.4 plus xpac/plugin and xpac/assets-manager, and points at the
xpac.repo.repman.io registry for them. A package that needs licensing adds
xpac/licensing the same way.
What an installed plugin ships is the compiled result of all of that, at
vendor/composer/autoload_psr4.php — one map, every package and every framework
namespace in it. If a class will not autoload, that file is where the answer is.
Nothing derives a namespace from a folder name. Add a directory whose namespace segment does not match its path and it must get its own entry in the map, or its classes will not load.
Components
init() is a list of registerComponent() calls and nothing else:
protected function init(): void
{
$this->registerComponent(PostType::class);
$this->registerComponent(Settings::class, '', $this->settings);
$this->registerComponent(Rest::class);
$this->registerComponent(Blocks::class);
$this->registerComponent(Preview::class);
$this->registerComponent(Email::class);
$this->registerComponent(Honeypot::class);
$this->registerComponent(Logger::class);
$this->registerComponent(AdminPage::class, '', $this);
$this->registerComponent(AdminRest::class);
$this->registerComponent(SubmissionsRest::class);
Cleaner::init();
}packages/Forms/Bootstrap.php:63.
The signature is registerComponent(string $class, string $id = '', ...$params)
(Plugin.php:290) and its behaviour is worth knowing exactly:
- It returns
nullif the class does not exist. A component that is not present degrades to nothing instead of fataling. - Pass an
$idand the instance is cached on the plugin and retrievable later withgetComponent($id). - Pass an empty
$id, as Forms does throughout, and you get a fresh instance the plugin does not hold on to. The component stays alive because its constructor put a callback into WordPress's hook arrays. - Everything after
$idis forwarded to the constructor.
A component is a plain class whose constructor adds its hooks and returns. That is the whole convention:
public function __construct()
{
add_action('xpac_forms_init', [$this, 'init'], PHP_INT_MIN + 10);
}packages/Forms/core/base/Blocks.php:47.
The $this->settings being passed into Settings::class above is not a declared
property. The base class has a __get (Plugin.php:111) that resolves three
magic names:
| Property | Resolves to |
|---|---|
$this->assets | An AssetsManager, built lazily from the plugin URL, path and version |
$this->settings | SettingsManager::instance() |
$this->pages | PagesManager::instance() |
Registering blocks
Blocks are registered from dist/, by PHP, inside a component that waits for the
package's own action. The path is resolved through the plugin so it is absolute
and correct wherever the plugin is installed:
$block = register_block_type(
Bootstrap::instance()->getPluginDirPath(
'dist/packages/forms/blocks/form'
),
[
'render_callback' => [$this, 'render'],
]
);packages/Forms/core/base/Blocks.php:331. getPluginDirPath() is
Plugin.php:397; it normalises the plugin directory plus whatever relative path
you hand it.
The directory it points at contains the block.json WordPress reads. Its
load-bearing fields:
{
"apiVersion": 3,
"name": "xp/form",
"title": "Form",
"category": "xpac",
"attributes": { "postId": { "type": "integer", "default": 0 } },
"editorScript": "file:./index.js",
"style": "file:./view.css",
"viewScript": "file:./view.js"
}dist/packages/forms/blocks/form/block.json. Block names are prefixed xp/, the
category is xpac, and Forms field blocks carry the further prefix
xp/form-field- (Blocks::FIELD_BLOCK_NAME_PREFIX) — the directory
blocks/date/ holds the block named xp/form-field-date. The xpac category itself
is added by the same component through block_categories_all
(Blocks.php:272).
The registration loop is ordinary PHP. Forms registers the form block first, then
walks an array of seventeen field and action blocks — text, email, file,
submit and the rest — registering each from its own directory under
dist/packages/forms/blocks/ and attaching a render_callback only to the ones
that render dynamically.
Registering settings
Settings go through SettingsManager, which the package receives as a
constructor argument rather than reaching for globally. The registration itself
is deferred to plugins_loaded:
class Settings
{
public const MODULE = 'xpac_forms';
public function __construct(SettingsManager $manager)
{
add_action('plugins_loaded', function() use ($manager) {
$manager->registerModule(
self::MODULE,
__('Forms Settings', 'xpac-forms'),
[],
[
'option_name' => 'xpac_forms',
'menu_slug' => 'xpac-forms-settings',
]
);
});
}
}packages/Forms/core/base/Settings.php. A module is a settings namespace: an
option_name to store under and a menu_slug to live at.
Modules hold pages, and pages hold fields. addModulePage()
(vendor/xpac/plugin/SettingsManager.php:278) takes the module id, the page
definition, an array of field definitions, an optional key prefix and an optional
callback:
Bootstrap::instance()->settings
->registerModule(
self::SETTINGS_MODULE_ID,
__('Integrations', 'xpac-akismet'),
[],
[
'option_name' => 'xpac',
'menu_slug' => 'xpac-settings',
'position' => 61,
]
)
->addModulePage(
self::SETTINGS_MODULE_ID,
[
'name' => 'akismet',
'title' => __('Akismet', 'xpac-akismet'),
'pageTitle' => __('Akismet Settings', 'xpac-akismet'),
'priority' => 300,
],
[
[
'type' => 'text',
'name' => 'api_key',
'default' => '',
'label' => __('Api Key', 'xpac-akismet'),
'placeholder' => __('Enter api key', 'xpac-akismet'),
]
],
'akismet'
);packages/Akismet/Addon.php:40, with one argument dropped: Akismet passes a
fifth, a callback that adds a Settings link to its row on the Plugins screen.
Note the pattern. registerModule() is a no-op if that module id already exists
(SettingsManager.php:228), so every package that wants a page on a shared
module calls it first and chains addModulePage() onto the result. That is how
several packages land pages on one settings screen without any of them owning
it.
Reading a value back needs the module id, because the same key can exist in more than one module:
Bootstrap::instance()->settings->get('akismet_api_key', self::SETTINGS_MODULE_ID);The 'akismet' prefix argument to addModulePage() is why the field named
api_key is read as akismet_api_key.
The screens themselves are rendered by packages/Shared/Settings, which is why
every XPAC settings page looks and behaves the same.
Registering REST routes
A REST component follows the same shape as any other: subscribe in the constructor, register in the callback.
public function __construct()
{
add_action('xpac_forms_init', [$this, 'init'], PHP_INT_MIN + 11);
}
public function init(): void
{
if (! doing_action('xpac_forms_init')) {
return;
}
add_action('rest_api_init', [$this, 'registerRestRoutes']);
}packages/Forms/core/base/Rest.php:24, abridged — the real init() also wires
two admin-ajax handlers. The doing_action() guard is the part to copy: it
means the method cannot be called directly to force early registration, a
pattern Forms uses in every component that has an init().
Routes register under the framework's shared namespace:
register_rest_route(
Bootstrap::getRestNamespace(),
self::getBaseRoute('/(?P<form>\d+)/structure'),
[
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true',
'callback' => [$this, 'handleStructureRequest'],
]
);Bootstrap::getRestNamespace() is final public static on the base class and
returns xpac/v1 (Plugin.php:205). getBaseRoute() prepends the package's own
base — form for Forms — so the route above is served at
/wp-json/xpac/v1/form/123/structure. The real registration carries an args
array too, giving each captured segment a validate_callback; that is the
package's own choice, not something the framework adds.
To build a URL to your own route, use Plugin::getRestRouteUrl()
(Plugin.php:194) rather than assembling it by hand; it resolves through
get_rest_url() and so respects whatever permalink setup the site has. The form
block's action attribute is produced exactly that way:
$form_attributes['action'] = esc_url(
Bootstrap::getRestRouteUrl(
Rest::getBaseRoute('/' . $post->ID . '/submit')
)
);packages/Forms/core/base/Blocks.php:823.
Packages talk through hooks
Packages do not call into each other's internals. A package that extends another
one checks that it is loaded, then subscribes to the actions and filters it
fires. packages/Akismet is spam protection for Forms and it is written entirely
that way:
public function wakeup(): void
{
if (class_exists('\XPACGroup\Plugin\Forms\Bootstrap')) {
$this->registerComponent(Addon::class);
} else {
add_action('admin_notices', [$this, 'renderAdminNotice']);
}
}packages/Akismet/Bootstrap.php, hooked on plugins_loaded from its hooks().
Forms present, the addon registers; Forms absent, the site gets an admin notice
telling the user what to install. No fatal either way.
The addon's own init(), hooked to xpac_forms_init, registers its editor
script and then four callbacks:
add_filter('xpac_forms_post_script_dependencies', [self::class, 'registerPostScriptDependency']);
add_filter('xpac_forms_post_meta_schema', [self::class, 'editPostMetaSchema']);
add_filter('xpac_forms_post_meta_default_values', [self::class, 'editPostMetaDefaultValues']);
add_filter('xpac_forms_submission_validation', [self::class, 'checkTokenValidity'], (PHP_INT_MAX - 10), 2);packages/Akismet/Addon.php:148. Add a script dependency, extend the form's
stored settings schema, extend its defaults, and vet the submission. The last one
runs at PHP_INT_MAX - 10 because a spam verdict has to be the final word.
Akismet does import Forms classes — Submission appears in
checkTokenValidity(array $validation, Submission $submission) — but only as the
type of an argument Forms hands it. Nothing in the addon runs until the guard has
passed and Forms has fired its own action.
Every hook used above appears in the hook reference, which is generated from these same call sites. If you are looking for the seam to extend a package, that list is the complete set.
Writing your own
The conventions, as a checklist:
One directory per feature under packages/, PascalCase, with
Bootstrap.php at its root holding a class called Bootstrap that
extends the framework Plugin.
Namespace XPACGroup\Plugin\<PackageName>, with an explicit PSR-4 entry
for every sub-namespace whose path does not match it.
Construct it once from the plugin entry file with
Bootstrap::instance(__FILE__).
Put nothing but registerComponent() calls in init(). Give each
component a constructor that adds hooks and returns.
Fire your own _init action late on init, and hang your components off
it — that is the seam other packages will use to extend you.
Register blocks from dist/packages/<pkg>/blocks/<block> via
getPluginDirPath(), settings through the SettingsManager your
component was handed, and REST routes under
Bootstrap::getRestNamespace().
Reach other packages through their hooks, behind a class_exists() guard
— never through their internals.