MWPD Basic Scaffold

A WordPress plugin template · Companion code to Modern WordPress Plugin Development

WordPress plugins whose complexity grows linearly.

A scaffold where every feature is a service that knows its own place in the WordPress lifecycle, with architecture checks that keep it that way for you and your coding agents.

The same architecture runs in the AMP and Web Stories plugins. PHP 7.4+, WordPress 6.0+, MIT.

$ gh repo create my-plugin --template mwpd/basic-scaffold --clone
$ cd my-plugin
$ composer install
$ composer test && composer lint
# unit, integration and architecture suites; PHPCS, PHPStan, Psalm
Which of Content Freshness's four services are built in five kinds of request
Request OutdatedPostNoticeConditional, Delayed to wp PostAgeColumnConditional, Delayed to current_screen FreshnessSettingDelayed to admin_init StaleContentDigestSiteActivateable, SiteDeactivateable
A single post Built at wp Not built: current_screen never fires Not built: admin_init never fires Built at plugins_loaded
Posts list screen Not built: is_needed() says no Built at current_screen Built at admin_init Built at plugins_loaded
Settings › Reading Not built: wp never fires Not built: is_needed() says no Built at admin_init Built at plugins_loaded
A WP-Cron run Not built: wp never fires Not built: current_screen never fires Not built: admin_init never fires Built at plugins_loaded
An archive page Not built: is_needed() says no Not built: current_screen never fires Not built: admin_init never fires Built at plugins_loaded
Figure 1. The four services of Content Freshness, a plugin built from this scaffold, in five kinds of request, as logged on a running site. Nothing outside the services decides this: each one's declarations do. The digest is built everywhere so that its cron hook exists whenever WP-Cron happens to run.

You have maintained this plugin.

It started with one feature: warn readers that an old post may be out of date. Then editors wanted a column in the Posts list, a setting, and a weekly email.

class Content_Freshness {

	private static $instance;

	public static function instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	private function __construct() {
		// The notice. Never true: the query has not run yet.
		if ( ! is_admin() && is_singular( 'post' ) ) {
			add_filter( 'the_content', array( $this, 'notice' ) );
		}

		// The column, the setting... and the digest's cron hook.
		if ( is_admin() ) {
			add_filter( 'manage_posts_columns', array( $this, 'add_column' ) );
			add_action( 'manage_posts_custom_column', array( $this, 'render_column' ), 10, 2 );
			add_action( 'admin_init', array( $this, 'register_setting' ) );
			add_action( 'cf_send_digest', array( $this, 'send_digest' ) ); // wp-cron.php is not admin.
		}

		// Runs on every request, on include, and is never cleared.
		if ( ! wp_next_scheduled( 'cf_send_digest' ) ) {
			wp_schedule_event( time(), 'weekly', 'cf_send_digest' );
		}
	}

	public function notice( $content ) {
		global $post; // Also every post in a related-posts block.
		$days = get_option( 'cf_days', 365 );
		if ( time() - strtotime( $post->post_modified ) > $days * DAY_IN_SECONDS ) {
			$content = '<p class="notice">This post may be out of date.</p>' . $content;
		}
		return $content;
	}

	// add_column(), render_column(), send_digest(): the same option read
	// and the same date arithmetic, three more times.
}

Content_Freshness::instance();
Listing 1 The same three features, written the usual way. Invented for this page, not from any repository

Every line that decides when or whether lives next to every other such line. The column has to stay out of the notice's way, the digest has to survive the column's is_admin(), and none of it can be tested without the rest. The fourth feature has to know about three. The tenth has to know about nine.

Complexity against features A dashed curve rises steeply as features are added when every feature must account for every other. A straight line rises gently when each feature is its own service. features added what each change must know about every feature knows every other one service per feature
Figure 2. A sketch of the argument, not a measurement. In Listing 1 each feature adds conditions the others must respect. A service adds only itself.

The same plugin, one service at a time.

Content Freshness is the scaffold's worked example: a real plugin with those four features, built from this template commit by commit. Watch what each new feature changes.

First feature

The notice decides for itself where it belongs.

The notice is needed on a single post, and WordPress can only say whether this is a single post once the query has run. So the service says both, as static answers the plugin can ask before it builds anything.

final class OutdatedPostNotice implements Service, Registerable, Conditional, Delayed {

	use DefaultRegistrationPriority; // Priority 10 on its action.

	public static function is_needed(): bool {
		return \is_singular( 'post' ); // Only on a single post...
	}

	public static function get_registration_action(): string {
		return 'wp'; // ...asked once the query has run.
	}

	public function __construct(
		FreshnessPolicy $policy,
		MainLoop $main_loop,
		ViewFactory $view_factory,
		DebugMode $debug_mode
	) {
		$this->policy = $policy; // Constructors only store what they get.
		// ...
	}

	public function register(): void {
		\add_filter( 'the_content', [ $this, 'prepend_notice' ] );
	}

	// prepend_notice() asks MainLoop and FreshnessPolicy, renders a view.
}
Listing 2 src/Notice/OutdatedPostNotice.php, trimmed to its declarations. From the repository

It never checks is_admin(). It does not fetch the post when it is built: MainLoop tells it, while the_content runs, whether this is the queried post in the main loop, so a related-posts block further down gets no notice.

Second feature

The column is one more service and one more line.

The Posts list column lives in a different part of WordPress, on an admin screen that is only known once current_screen fires. It says so, the same way. The composition root gains one entry; the notice is not touched.

	WordPressMainLoop
};
use MWPD\ContentFreshness\PostsList\PostAgeColumn;

	protected function get_service_classes(): array {
		return [
			self::OUTDATED_POST_NOTICE_ID => OutdatedPostNotice::class,
			self::POST_AGE_COLUMN_ID      => PostAgeColumn::class,
		];
	}

	public const POST_AGE_COLUMN_ID = self::SERVICE_PREFIX . 'posts-list.post-age-column';
Listing 3 What commit b79fd37 “Add the post age column” changed in src/ContentFreshnessPlugin.php. Trimmed: the same commit also removed the scaffold's sample service.
final class PostAgeColumn implements Service, Registerable, Conditional, Delayed {

	public static function is_needed(): bool {
		$screen = \get_current_screen();

		return null !== $screen && self::SCREEN === $screen->id;   // 'edit-post'
	}

	public static function get_registration_action(): string {
		return 'current_screen';
	}

	public function __construct( FreshnessPolicy $policy, ViewFactory $view_factory, DebugMode $debug_mode ) {
		// ...
	}

	public function register(): void {
		\add_filter( 'manage_post_posts_columns', [ $this, 'add_column' ] );
		\add_filter( 'manage_edit-post_sortable_columns', [ $this, 'make_sortable' ] );
		\add_action( 'manage_post_posts_custom_column', [ $this, 'render_cell' ], 10, 2 );
	}
}
Listing 4 src/PostsList/PostAgeColumn.php, trimmed. From the repository
Third feature

The digest needs the plugin's lifecycle, not a request.

A weekly email is scheduled when the plugin is activated and cleared when it is deactivated, in each site: every site has its own posts, editors and cron events. The service says that by implementing SiteActivateable and SiteDeactivateable, and the scaffold calls it in every site an activation or deactivation reaches, a whole network included.

It is deliberately not Conditional. WP-Cron fires the event in whatever request comes along, so the callback has to be hooked in every request, and the lifecycle events skip a Conditional service that is not needed in the site they reach.

final class StaleContentDigest implements
	Service,
	Registerable,
	SiteActivateable,
	SiteDeactivateable,
	SiteInitializable,
	SiteUninstallable {

	public const CRON_HOOK = 'mwpd_content_freshness_send_digest';

	public function register(): void {
		\add_action( self::CRON_HOOK, [ $this, 'send' ], 10, 0 );
	}

	public function activate_site( SiteContext $context ): void {
		$this->schedule(); // In the site that is switched to.
	}

	public function deactivate_site( SiteContext $context ): void {
		$this->unschedule();
	}

	// ... initialize_site() schedules in a new site of the network,
	// uninstall_site() clears an event left behind.

	private function schedule(): void {
		if ( false !== \wp_next_scheduled( self::CRON_HOOK ) ) {
			return; // Activating twice schedules once.
		}

		$result = \wp_schedule_event(
			$this->clock->now()->getTimestamp() + self::FIRST_DELAY,
			self::RECURRENCE,
			self::CRON_HOOK,
			[],
			true
		);
		// ... a WP_Error becomes FailedToScheduleDigest.
	}
}
Listing 5 src/Digest/StaleContentDigest.php, trimmed. From the repository
			self::POST_AGE_COLUMN_ID      => PostAgeColumn::class,
			self::FRESHNESS_SETTING_ID    => FreshnessSetting::class,
			self::STALE_CONTENT_DIGEST_ID => StaleContentDigest::class,
		];
	...
			MainLoop::class        => WordPressMainLoop::class,

			// The digest asks the database for the stalest posts.
			StalePosts::class      => WordPressStalePosts::class,
		];
Listing 6 Commit 4f6b654 “Add the stale content digest” in the composition root: the service, and a binding for the one collaborator it brought, trimmed of its import and ID constant.

The fourth feature, the setting under Settings › Reading, went in the same way. Removing its option when the plugin is deleted is a lifecycle point too, and the scaffold covers it with a capability interface, never a condition inside a service: the setting implements SiteUninstallable, and the scaffold's static uninstall bridge reaches it in every site when the plugin is deleted.

Commit Composition root Notice/ PostsList/ Settings/ Digest/
Add the outdated post notice94 lines3 files–––
Add the post age column10 lines–1 file––
Add the freshness setting4 lines––1 file–
Add the stale content digest11 lines–––3 files
Figure 3. The four feature commits in Content Freshness and the source directories they touched, from git log --stat. Line counts are additions plus deletions in ContentFreshnessPlugin.php; the first two also removed the scaffold's sample services. Shared code outside the feature directories: the notice reworded one exception, the digest added two. No feature commit touched another feature's code.

That staircase is the whole point. Each feature was one more service that manages its own lifecycle. Complexity grew by one service at a time, not by one more condition in every existing one.

The lifecycle questions a service can answer

Is it needed in this request at all?
Conditional::is_needed(). Asked before the service is built. If the answer is no, the service never exists.
When does it hook in?
Delayed::get_registration_action(), at get_registration_priority(). Registration waits for that action, or runs at once if it has already fired.
Which services must exist first?
HasDependencies::get_dependencies(). Registration waits until they are registered, and fails loudly when they never can be.
What does it hook into?
Registerable::register(). Called once the service is built, to add its actions and filters.
What happens on activation?
Activateable::activate(), once, or SiteActivateable::activate_site() in every site the activation reaches. Safe to run twice.
And on deactivation?
Deactivateable::deactivate(), or SiteDeactivateable::deactivate_site() in every site, the same way.
And when the plugin is deleted?
SiteUninstallable::uninstall_site() in every site, through a static uninstall bridge. Safe to run twice.

The questions are static methods on purpose, so the plugin can decide before it builds. A service a request does not need is never instantiated, which is what Figure 1 shows: each request pays only for the services it uses.

One composition root.

The plugin class implements no features. It lists the services and tells a small injector how to assemble them, in one place.

protected function get_bindings(): array {
	return [
		// Views can be overridden by the theme.
		ViewFactory::class      => TemplatedViewFactory::class,

		// Failures surface when WP_DEBUG is on, and degrade when it is off.
		DebugMode::class        => WordPressDebugMode::class,

		// ... the factories of the optional Query Monitor panel.

		// Ages are measured against the time WordPress tells.
		Clock::class            => WordPressClock::class,

		// A post is outdated once it is older than the maximum age set
		// under Settings > Reading. Rebind this to change the rule for
		// every service at once.
		FreshnessPolicy::class  => OptionFreshnessPolicy::class,

		// The notice asks WordPress's main loop which post it renders.
		MainLoop::class         => WordPressMainLoop::class,

		// The digest asks the database for the stalest posts.
		StalePosts::class       => WordPressStalePosts::class,
	];
}

protected function get_shared_instances(): array {
	return [
		// One policy for the whole request: it reads the option once, and
		// every service that asks gets the same answer.
		FreshnessPolicy::class,
	];
}
Listing 7 src/ContentFreshnessPlugin.php, the bindings and shared instances, trimmed. From the repository

Services ask for interfaces in their constructors. The notice, the column and the digest all ask for a FreshnessPolicy; the highlighted line decides which one they get. new is reserved for value objects and exceptions, the inside of factories, and the built-in default a constructor falls back to. Everything else comes from the injector, so there is one place to read when you want to know how the plugin fits together.

Swapping an implementation is one binding.

Change that line, and every service that asks for a FreshnessPolicy gets the new rule. None of them changes. The plugin's own test proves it by rebinding the policy to a test double through the injector's filter, and checking that the option is never read:

public function test_a_rebound_policy_changes_the_rule_without_touching_the_service(): void {
	Functions\expect( 'get_option' )->never();
	Filters\expectApplied( 'mwpd.content_freshness.bindings' )
		->once()
		->andReturnUsing(
			static function ( array $bindings ): array {
				$bindings[ FreshnessPolicy::class ] = FixedFreshnessPolicy::class;

				return $bindings;
			}
		);
	// The fixed policy says "fresh" unless told otherwise.
	Filters\expectApplied( 'mwpd.content_freshness.arguments' )
		->once()
		->andReturnUsing(
			static function ( array $arguments ): array {
				$arguments[ FixedFreshnessPolicy::class ] = [ 'outdated' => false ];

				return $arguments;
			}
		);

	$this->assertSame( '<p>Content.</p>', $this->registered_notice()->prepend_notice( '<p>Content.</p>' ) );
}
Listing 8 tests/php/Integration/Notice/OutdatedPostNoticeTest.php. FixedFreshnessPolicy is a fixture in tests/php/Fixture/Freshness/. From the repository

A site does the same from a plugin or must-use plugin, for example with a policy that leaves posts in an evergreen category alone:

add_filter(
	'mwpd.content_freshness.bindings',
	static function ( array $bindings ): array {
		$bindings[ \MWPD\ContentFreshness\Freshness\FreshnessPolicy::class ] = My_Freshness_Policy::class;

		return $bindings;
	}
);
Listing 9 From the plugin's README. My_Freshness_Policy is a class you would write. Hypothetical class

Architecture that is checked, not just documented.

38 architecture rules, as PHPStan rules and an architecture test suite, run in composer lint, composer test and CI. A failure never only says no.

Here is the column as a coding agent might first write it, the way most plugins do: no lifecycle declarations, and an is_admin() guard at the top of register().

final class PostAgeColumn implements Service, Registerable {

	public function register(): void {
		if ( ! \is_admin() ) {
			return;
		}

		\add_filter( 'manage_post_posts_columns', [ $this, 'add_column' ] );
		\add_filter( 'manage_edit-post_sortable_columns', [ $this, 'make_sortable' ] );
		\add_action( 'manage_post_posts_custom_column', [ $this, 'render_cell' ], 10, 2 );
	}
}
Listing 10 A first draft of PostAgeColumn, written for this page. Not from the repository
$ vendor/bin/phpstan analyze -vv src/PostsList/PostAgeColumn.php
 ------ -----------------------------------------------------------------------------------------
  Line   PostAgeColumn.php
 ------ -----------------------------------------------------------------------------------------
  83     is_admin() in MWPD\ContentFreshness\PostsList\PostAgeColumn::register() decides at
         registration whether the service applies to this request. Implement Conditional and
         return the answer from is_needed(), so the plugin does not even build the service
         when it is not needed.
         🪪  architecture.noContextChecksInRegister
         💡 Principle 7: Show the alternative to WordPress anti-patterns, see
            docs/design-principles.md#7-show-the-alternative-to-wordpress-anti-patterns. Pattern:
            docs/patterns.md#i-need-a-service-only-in-some-requests. Rule:
            docs/architecture-rules.md#architecturenocontextchecksinregister
 ------ -----------------------------------------------------------------------------------------

 [ERROR] Found 1 error
Listing 11 Real output: Listing 10 dropped into a copy of Content Freshness and analysed. The message is the one the rule's test asserts.

Every failure has the same three parts: what is wrong and where, what to do instead, and why, with links to the principle, to the pattern that meets the need the rejected code was after, and to the rule's own entry.

The rules cannot be silenced inline or baselined. architecture.noSuppression rejects an @phpstan-ignore or an ignoreErrors entry aimed at an architecture rule, and architecture.gatesAtStrength fails the build if PHPStan drops below max, Psalm rises above level 2, or a baseline appears. A real exception becomes reviewed configuration under parameters.architecture in phpstan.neon.dist, where it stays visible.

Around the architecture rules: PHPStan at max, Psalm at level 2, the WordPress Coding Standards and PHPCompatibility for 7.4, and PHPUnit on PHP 7.4 and 8.3, all in CI.

Built for the code your agent writes.

Agents write WordPress code from the average of the internet, and the average looks like Listing 1. The scaffold gives an agent three things a prompt does not.

Rules up front

An agent-agnostic AGENTS.md with the design principles and the hard rules, read by Claude Code, Codex, Cursor and others before the first edit.

Answers indexed by need

docs/patterns.md has 20 sections named the way the problem is phrased: “I need a singleton”, “I need a service locator”, “I need to hook in later”. Each gives the scaffold's answer, with code that passes the checks.

A feedback loop that teaches

When an agent reaches for the wrong pattern, the check fails with the fix and a link to the pattern. The agent corrects itself in the next iteration instead of shipping it.

  1. The agent writes the columnwith is_admin() inside register(), because that is what most plugins do (Listing 10).
  2. The check failswith Listing 11: what is wrong, what to do instead, and where to read why.
  3. The agent reads the pattern“I need a service only in some requests”: the condition moves into is_needed(), and because the screen is only known at current_screen, the service becomes Delayed too.
  4. The check passesand the column declares its own lifecycle, like every other service (Listing 12).
Figure 4. The loop an agent runs inside composer lint. The right way is enforced and explained, not hoped for.
final class PostAgeColumn implements Service, Registerable {
final class PostAgeColumn implements Service, Registerable, Conditional, Delayed {

	use DefaultRegistrationPriority;

	public static function is_needed(): bool {
		$screen = \get_current_screen();

		return null !== $screen && self::SCREEN === $screen->id;
	}

	public static function get_registration_action(): string {
		return 'current_screen';
	}

	public function register(): void {
		if ( ! \is_admin() ) {
			return;
		}

		\add_filter( 'manage_post_posts_columns', [ $this, 'add_column' ] );
Listing 12 From Listing 10 to the shape that ships in PostAgeColumn.php, trimmed of imports and constants.

Agents assemble plugins the right way for the same reason people do: the architecture is written down where they look first, the fix is in the error, and the gate does not open until it is applied.

The WordPress anti-patterns, and what to do instead.

From principle 7 of the design principles. In a review, an item from the left column is a defect even if the code works.

Common in pluginsIn the scaffold
Hooks registered in a constructor
Constructors only store dependencies. Hooks go in register(), called by the plugin at the right time.
A singleton with ::instance() and static state
Shared instances come from the injector. The plugin factory keeps one plugin instance without the Singleton pattern.
A service locator, such as Services::get( 'id' )
Each need behind it has its own answer: constructor injection, a factory, a typed registry, a shared instance, or a bridge at the edge of the graph.
A global or singleton so WordPress callbacks can reach the plugin
A bridge in the main file gets the plugin through the plugin factory and delegates at once, as the activation hooks do.
new for collaborators inside a class, or pulling them from globals
Dependencies arrive through the constructor and are configured as bindings in the composition root.
is_admin() wrapped around hook calls throughout the code
The service states when it is needed once, in Conditional::is_needed().
Hooking an early action just to wait for a later one
The service names its registration action through Delayed, and the plugin defers it.
Query-dependent checks before the query has run
A Conditional service also implements Delayed and returns wp or later, so the plugin waits before asking.
Work done when a file is included
Only the main plugin file has side effects. Everything else is autoloadable declarations.
One god class holding the whole plugin
A composition root wires small services, each with one responsibility.
Echoing HTML from business logic
Output goes through views from a ViewFactory, escaped by default.

The patterns behind each row, with code, are in the principles and the patterns.

Runs on PHP and WordPress alone.

Copy the plugin directory and activate it. There is no build step on the server and nothing to install there.

Requirements
PHP 7.4 or later, WordPress 6.0 or later. The code is written to the 7.4 floor and PHPCompatibility checks it.
Runtime dependencies
None. composer.json requires nothing but PHP. Composer is a development tool for tests, static analysis and coding standards.
Autoloading
The Composer autoloader when vendor/ is present; the bundled Infrastructure\Autoloader when it is not. CI proves the second path on PHP 7.4 without installing anything.
Views
Escaped by default; raw() is the explicit exception. Templates can be overridden from the child theme, then the parent theme, then the plugin.
Errors
Typed exceptions on PHP's SPL hierarchy, one marker interface, named constructors. Loud with WP_DEBUG on; with it off, the broken part degrades and the page around it stays up.
Infrastructure
Injector, container, views and autoloader are small in-house implementations behind interfaces, so a stronger one can replace any of them when a real need appears.

Proven at scale, twice.

The architecture in this scaffold powers two of Google's WordPress plugins. Each keeps its own adapted copy of the infrastructure, and fixes proven there flow back here.

AMP: the official AMP plugin for WordPressInfrastructure in src/Infrastructure

AMP active installs on WordPress.orgLive count from WordPress.org

Web Stories: Google's visual storytelling pluginInfrastructure in includes/Infrastructure

Web Stories active installs on WordPress.orgLive count from WordPress.org

Your next plugin, from this one.

$ gh repo create my-plugin --template mwpd/basic-scaffold --clone
$ cd my-plugin
$ composer install
$ composer test && composer lint
Listing 13 Or press Use this template on GitHub. composer test runs the unit, integration and architecture suites; composer lint runs PHPCS, PHPStan, Psalm and the tests of the PHPStan rules.
  1. Make it yours. Rename the plugin header and file name in basic-scaffold.php, the MWPD\BasicScaffold namespace in src/, tests/ and composer.json, the namespaces under parameters.architecture in phpstan.neon.dist, the BasicScaffoldPlugin, BasicScaffoldPluginFactory and BasicScaffoldException classes, and the HOOK_PREFIX and SERVICE_PREFIX constants.
  2. Replace the samples. Swap the services in src/SampleSubsystem/ and the templates in views/ for your own. Content Freshness's history shows the whole journey, one commit per step.
  3. List your services in BasicScaffoldPlugin::get_service_classes(). Then keep adding one per feature.

Add the next feature. Change nothing else.

That is the promise, and the checks are what keep it. Generate a plugin and see whether the first service you write passes.

Use this template

Read the worked example