One project structure for every WordPress build
How we lay out a WordPress repository so that coding standards, unit tests and end-to-end tests run on every change, and why the same structure on every project is the real productivity win.
Our GitHub profile makes three claims: one project structure, automated testing including end-to-end tests, and WordPress Coding Standards on every project. This article is the practical version of those three lines. It describes the repository layout we start from, the tools wired into it, and what each one buys you over the life of a site.
None of it is exotic. The value is not in any single tool but in never having to decide again where things go.
Why a uniform structure matters more than the tools
WordPress does not impose a project layout. A site can be a theme with a functions.php of four thousand lines, a dozen plugins from a marketplace, or a Composer-managed application with a proper dependency tree. All three run. Only one of them can be tested, reviewed and handed to a new engineer without a walkthrough.
When every project uses the same layout:
- Onboarding is measured in hours. An engineer who has worked on one of our projects knows where the code, the tests, the environment definition and the deployment scripts are on all of them.
- Tooling is copied, not rebuilt. The CI workflow, the linter configuration and the test bootstrap are the same files. Improvements made on one project are pulled into the others.
- Reviews focus on the change. Nobody spends review time arguing about file placement or formatting; the linter already did.
The repository layout
The root of a site repository looks like this:
.
├── .github/workflows/ # CI: lint, unit, e2e, deploy
├── config/ # Environment config (never secrets)
├── web/ # Web root
│ ├── wp/ # WordPress core, installed by Composer
│ ├── app/ # wp-content equivalent
│ │ ├── mu-plugins/ # Site-specific, always-on code
│ │ ├── plugins/ # Third-party and our own plugins
│ │ └── themes/ # The site theme (and only that)
│ └── index.php
├── tests/
│ ├── phpunit/ # Unit and integration tests
│ └── e2e/ # Playwright specs
├── composer.json
├── package.json
├── phpcs.xml.dist
├── phpstan.neon.dist
└── .wp-env.json
A few decisions in there are worth explaining.
WordPress core is a dependency, not a checkout. Composer installs it into web/wp/, and wp-content moves to web/app/. Core is never edited, and upgrading it is a version bump in composer.json that goes through the same pull request flow as any other change.
Site logic lives in must-use plugins. Business logic that must always run (custom post types, integrations, roles) goes into web/app/mu-plugins/, split into small, single-purpose files or a single namespaced plugin with an autoloader. It does not live in the theme, so a redesign never risks breaking an integration.
The theme is presentation only. Templates, block patterns, theme.json and assets. If a function in the theme does something a different theme would also need, it is in the wrong place.
Third-party plugins are pinned. Every plugin, including premium ones through a private Composer repository, is listed with a version. The lock file is committed. Production runs exactly what was tested.
Coding standards as a gate, not a suggestion
phpcs.xml.dist extends the WordPress Coding Standards ruleset with a small set of project-specific rules: the text domain, the prefix for global functions and hooks, and the minimum PHP version for the compatibility sniffs.
<?xml version="1.0"?>
<ruleset name="Project">
<file>web/app/mu-plugins</file>
<file>web/app/themes/project</file>
<arg name="extensions" value="php"/>
<arg value="sp"/>
<rule ref="WordPress"/>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array" value="project"/>
</properties>
</rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array" value="project"/>
</properties>
</rule>
<config name="testVersion" value="8.1-"/>
<rule ref="PHPCompatibilityWP"/>
</ruleset>
Two things make this work in practice. First, the linter runs in CI and blocks the merge, so the standard cannot drift. Second, phpcbf fixes formatting automatically, so engineers are not asked to hand-indent code to satisfy a robot. What remains for humans are the sniffs that matter: escaping output, sanitising input, nonces on state-changing requests, prepared SQL.
PHPStan sits next to it at a moderate level with the WordPress stubs installed. It catches the class of bug the linter cannot: calling a function with the wrong argument type, a property that may be null, a hook callback with the wrong arity.
Unit and integration tests
We use PHPUnit with the WordPress test framework, run against a real database inside wp-env. The distinction we keep:
- Unit tests cover pure logic: price calculations, data transformations, validation. They do not need WordPress loaded and run in milliseconds.
- Integration tests cover code that talks to WordPress: a custom REST endpoint, a hook that changes an order status, a query with meta conditions. They load WordPress and use its factories.
A typical integration test for a REST endpoint:
public function test_endpoint_requires_capability(): void {
wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) );
$request = new WP_REST_Request( 'POST', '/project/v1/export' );
$response = rest_get_server()->dispatch( $request );
$this->assertSame( 403, $response->get_status() );
}
The goal is not coverage as a number. The goal is that every piece of logic a client would be upset to see broken has a test that would fail if it broke.
End-to-end tests for the flows that pay the bills
Unit tests cannot tell you that checkout works. For that we use Playwright against a full WordPress instance started by wp-env in CI, with a seed script that creates the products, pages and users the tests need.
We keep the end-to-end suite deliberately small: the handful of journeys whose failure would be a real incident. On a store that is usually:
- Browse to a product, add it to the cart, complete checkout with the test payment gateway.
- Log in to the account area and see the order.
- Submit the main lead or contact form.
- Load the home page and the top landing pages without console errors.
test('guest checkout completes', async ({ page }) => {
await page.goto('/product/sample-product/');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.goto('/checkout/');
await page.getByLabel('First name').fill('Test');
await page.getByLabel('Last name').fill('Customer');
await page.getByLabel('Email address').fill('test@example.com');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Your order has been received')).toBeVisible();
});
Each test takes seconds. The suite runs on every pull request and again after every deployment, against the deployed environment. The second run is the one that catches configuration drift: a payment gateway left in the wrong mode, a caching rule that broke the cart.
Continuous integration
The workflow is the same file on every project, with the project name as the only variable:
- Install PHP and Composer dependencies with caching.
- Run
phpcsandphpstan. - Start
wp-env, run PHPUnit. - Build front-end assets, start the site, run Playwright.
- On the main branch, deploy to staging; on a tag, deploy to production.
A pull request that fails any of the first four steps cannot be merged. It sounds strict. In practice it removes an entire category of conversation (“did you test it?”) and replaces it with a green check.
What this costs, and what it returns
Setting this up on a new project takes a day, most of it copying from the previous one. Maintaining it costs a few hours a month, mostly dependency updates.
What it returns, over the years a site is in production:
- Regression testing that does not consume QA hours. The suite runs on every change, including plugin and core updates.
- Fast onboarding. New engineers ship on their first day because the structure and the tooling are already familiar.
- A codebase that can be handed over. If a client ever moves in-house or to another agency, they receive a repository that explains itself.
That last point is the real reason. A site built this way belongs to the client in a meaningful sense, not just legally. That is what we mean by engineering, as opposed to assembling.
Next step
Working on something similar?
Tell us about the site, the stack and what is not working. We reply with questions, not a sales deck.