Getting started

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 rules

Two rules, and only two:

  1. The directory is PascalCase and matches the package name.
  2. Bootstrap.php sits at its root and holds a class called exactly Bootstrap.

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 Plugin

Plugin 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:

MethodRequiredWhen it runs
init()Yes — abstract protected at Plugin.php:141Immediately, during construction
hooks()No — no-op default at Plugin.php:148Immediately after init()
handleActivation()No — Plugin.php:356On register_activation_hook
handleDeactivate()No — Plugin.php:365On register_deactivation_hook
handleUninstall()No — public static no-op at Plugin.php:245On 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 null if the class does not exist. A component that is not present degrades to nothing instead of fataling.
  • Pass an $id and the instance is cached on the plugin and retrievable later with getComponent($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 $id is 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:

PropertyResolves to
$this->assetsAn AssetsManager, built lazily from the plugin URL, path and version
$this->settingsSettingsManager::instance()
$this->pagesPagesManager::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.

On this page