·

Building a WordPress Blueprint

Every new WordPress project starts the same way: someone copies the last project, deletes half of it, forgets the other half, and spends two days rediscovering why PHPCS isn’t running in CI. I wanted that cost to drop to roughly 1% of project time. So I built a Blueprint — a full-site template that a new project is created from, with the boring decisions already made and written down.

This post walks through how it’s put together and, more importantly, why. The code is on GitHub: github.com/bartlomiejw/wordpress-blueprint.

Start with vocabulary, not code

The first file I wrote wasn’t PHP. It was CONTEXT.md — a short glossary. Teams lose surprising amounts of time to words that mean different things to different people (“the template”, “the core plugin”, “the site”). The Blueprint pins down four terms and lists the words to avoid:

  • Blueprint — the template repository itself. Never deployed. Not a “boilerplate” or “starter”.
  • Site Project — a concrete site repository created from the Blueprint. The unit of work the team ships.
  • Custom Theme — the block (FSE) theme inside a Site Project: theme.json, HTML templates, patterns, custom blocks.
  • Site Core — an mu-plugin that owns every data concern: CPTs, taxonomies, meta, integrations. No presentation logic.

Once those words exist, the architecture mostly follows from them.

Classic layout, Composer-managed core — not Bedrock

The repository uses the WordPress-native directory layout, following 10up’s wp-scaffold. Composer installs core into wp/ and third-party plugins from wpackagist; a version-controlled wp-content/ holds everything we actually write.

.
├── .ddev/                  # local environment, config-as-code
├── .github/workflows/      # lint, test, build, e2e, deploy
├── docs/adr/               # architecture decision records
├── wp/                     # WordPress core (Composer, gitignored)
├── wp-config.php           # reads everything from .env
├── composer.json
└── wp-content/
    ├── mu-plugins/
    │   ├── plugin-loader.php
    │   └── site-core/      # data: CPTs, taxonomies, integrations
    └── themes/
        └── custom-theme/   # presentation: templates, blocks, SCSS

Yes, I considered Roots Bedrock — this very blog runs on it. For a team template I rejected it: it’s a third-party convention where I’d rather stay WordPress-native, the web/app/ layout is unfamiliar to most WordPress developers joining a project, and it constrains hosting to custom web roots. Committing core files (fully classic) was rejected too: core updates pollute git history and builds stop being reproducible.

Core lives in wp/, so wp-config.php has to tell WordPress where things are. All configuration comes from .env via vlucas/phpdotenv:

define( 'WP_HOME',    rtrim( $_ENV['WP_HOME'] ?? 'http://localhost', '/' ) );
define( 'WP_SITEURL', WP_HOME . '/wp' );

define( 'WP_CONTENT_DIR', __DIR__ . '/wp-content' );
define( 'WP_CONTENT_URL', WP_HOME . '/wp-content' );

define( 'AUTOMATIC_UPDATER_DISABLED', true );
define( 'DISALLOW_FILE_EDIT', true );

Updates go through Composer and git, never through the dashboard — hence the last two lines.

DDEV locally, wp-env in CI

I looked at what Automattic, 10up and the official docs use locally. They converge on @wordpress/env. I went with DDEV anyway, and wrote down why in an ADR: full-site work benefits from production-like services — real nginx, Mailpit, Xdebug, switchable PHP versions — that wp-env doesn’t give you out of the box. The environment is still config-as-code: .ddev/ is committed, and a post-start hook makes the first run a single command:

hooks:
  post-start:
    - exec: "[ -d vendor ] || composer install"
    - exec: "[ -d wp-content/themes/custom-theme/node_modules ] || (cd wp-content/themes/custom-theme && npm ci)"
    - exec: >-
        wp core is-installed 2>/dev/null ||
        wp core install --url=https://site-project.ddev.site ...

ddev start on a fresh clone gives you dependencies, a built theme and an installed site. The “official WordPress tooling” principle still applies one layer up: inside the container everything is @wordpress/scripts, @wordpress/create-block, WP-CLI and the core PHPUnit framework.

wp-env didn’t disappear, though — it’s exactly the right tool for a throwaway WordPress in CI, so the Playwright job uses it. Two environments only drift when developers use both; here each has one job.

Data in Site Core, presentation in the theme

The split that pays off most over a project’s lifetime: if switching the theme would lose it, it doesn’t belong in the theme. Post types, taxonomies, meta and third-party integrations live in mu-plugins/site-core/. Must-use plugins can’t be deactivated from the dashboard, which is exactly what you want for the data model.

WordPress only auto-loads PHP files sitting directly in mu-plugins/, so a small loader — the pattern WP VIP recommends — pulls in plugins from subdirectories:

$mu_plugins = [
	'site-core/site-core.php',
];

foreach ( $mu_plugins as $plugin ) {
	require_once __DIR__ . '/' . $plugin;
}

Inside Site Core each post type is a small namespaced class with a register() method, so it can be unit-tested and found by name. The Blueprint ships one Example_Post_Type meant to be copied and then deleted.

Where does a custom block go?

Same test, applied to blocks — it’s 10up’s explicit rule and compatible with VIP’s structure:

  • Design and layout blocks tied to the site’s look live in the theme under blocks/.
  • Functional blocks — forms, paywalls, data integrations, anything that must survive a theme switch — live in Site Core or a dedicated plugin.

Block sources live in blocks/{name}/; wp-scripts compiles them into build/blocks/, and the theme registers every compiled block it finds — adding a block is just adding a folder with a block.json:

function register_blocks(): void {
	$block_dirs = glob( CUSTOM_THEME_DIR . '/build/blocks/*/block.json' );

	if ( ! $block_dirs ) {
		return;
	}

	foreach ( $block_dirs as $block_json ) {
		register_block_type( dirname( $block_json ) );
	}
}

The example block is dynamic: save() returns null and render.php produces the markup, escaping every attribute on output. Dynamic blocks avoid the “This block contains unexpected or invalid content” dance every time the markup changes — worth it as a default for a team.

SCSS is the token source, theme.json is a lock

This is the most opinionated call in the Blueprint. Design tokens — colours, type scale, spacing — live in SCSS variables, and components are BEM-named partials compiled by @wordpress/scripts. theme.json is used almost only to turn things off: custom colours, font sizes, spacing controls, drop caps, default palettes.

"color": {
  "custom": false,
  "customGradient": false,
  "defaultPalette": false,
  "link": false,
  "text": false
},
"typography": {
  "customFontSize": false,
  "fontWeight": false,
  "lineHeight": false
}

For agency-built sites the client edits content, not design. Locking the editor down keeps the design system intact, and keeping tokens in one SCSS file means designers and developers argue about one place instead of two. If your project is the opposite — a site where editors are expected to style pages — flip this decision; it’s the easiest one to change.

Quality gates that run before review

Nothing here is exotic. The point is that it’s all wired up on day one, so nobody has to “add linting later”:

  • PHPCS with WordPress Coding Standards, PHP 8.2+ and the project text domains enforced.
  • PHPStan at level 5 with szepeviktor/phpstan-wordpress, over Site Core and the theme.
  • Rector in dry-run mode in CI (PHP 8.2 set, code quality, dead code, type declarations) and composer rector:fix locally.
  • wp-scripts lint-js / lint-style for JavaScript and SCSS.
  • Husky + lint-staged runs the relevant linter only on staged files, so the pre-commit hook stays fast enough that people don’t bypass it.

Three layers of tests

  • PHPUnit, split into unit and integration suites. Integration tests boot the real WordPress test framework against MySQL. CI runs both on PHP 8.2 and 8.3. The test-suite installer is committed in bin/ instead of being fetched on every run.
  • Jest + Testing Library for block editor components, next to the block they test.
  • Playwright for end-to-end smoke tests: the homepage loads, there are no console errors, and the header/main/footer landmarks exist. Small, but it catches the “white screen after deploy” class of bug.

CI/CD: main goes to staging, tags go to production

Five GitHub Actions workflows — lint, test, build, e2e and deploy — run on every pull request and on main. Deployment uses GitHub Environments to pick the target:

on:
  push:
    branches: [main]   # → staging
    tags: ["v*"]       # → production

environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'staging' }}

The runner installs production-only Composer dependencies, builds theme assets, and rsyncs the result to the server — excluding .env, uploads and caches — then flushes the object cache with WP-CLI. Built assets are never committed; the server never runs npm. Releasing to production is git tag v1.4.0 && git push --tags, which also gives you a changelog anchor for free.

Write the decisions down

The most reusable part of the Blueprint isn’t any config file — it’s docs/adr/. Each non-obvious choice (DDEV over wp-env, classic layout over Bedrock) has a short record listing the options that were considered and why they lost. Six months into a project, when someone asks “why don’t we just use Bedrock?”, the answer is a link, not a meeting. And when the reasons stop being true, you know exactly which decision to revisit.

Using it

git clone https://github.com/bartlomiejw/wordpress-blueprint.git my-site
cd my-site
cp .env.example .env
ddev start
ddev launch

Then rename site-project, custom-theme and the text domains, replace the example post type and block with real ones, and fill in the SCSS variables. That’s the 1%.

The repository is here: github.com/bartlomiejw/wordpress-blueprint. Issues and opinions welcome — especially if you think SCSS-over-theme.json is the wrong call.