<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://gratta.pro/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gratta.pro/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-25T23:37:03+02:00</updated><id>https://gratta.pro/feed.xml</id><title type="html">Gratta Studio</title><subtitle>Gratta Studio builds bespoke WordPress and WooCommerce solutions for organizations that need reliable, scalable, secure and high-performance websites.</subtitle><author><name>Gratta Studio</name></author><entry><title type="html">Where the time goes in a slow WooCommerce store</title><link href="https://gratta.pro/blog/where-woocommerce-time-goes/" rel="alternate" type="text/html" title="Where the time goes in a slow WooCommerce store" /><published>2026-09-24T00:00:00+02:00</published><updated>2026-09-24T00:00:00+02:00</updated><id>https://gratta.pro/blog/where-woocommerce-time-goes</id><content type="html" xml:base="https://gratta.pro/blog/where-woocommerce-time-goes/"><![CDATA[<p>Most WooCommerce performance work starts with the wrong question: “which caching plugin should we install?” Caching hides cost; it does not remove it, and the pages that matter in a store (cart, checkout, account) are the ones that cannot be cached anyway.</p>

<p>The right first question is “where does the time go?” This article walks through the places we look first when a store is slow, roughly in the order they tend to matter, and what we do about each. The method is always the same: measure, change one thing, measure again.</p>

<h2 id="measure-before-touching-anything">Measure before touching anything</h2>

<p>Three tools cover almost everything:</p>

<ul>
  <li><strong>Query Monitor</strong> on a staging copy with production data, to see the queries, hooks and HTTP calls behind a single request.</li>
  <li><strong>Server timing or an APM</strong> (New Relic, Blackfire, or <code class="language-plaintext highlighter-rouge">Server-Timing</code> headers from the host) to see the distribution across many requests, including the ones you would not think to load yourself.</li>
  <li><strong>A slow query log</strong> on the database, with a low threshold, for a day.</li>
</ul>

<p>From these you get a short list: the slowest routes, the slowest queries, the plugins that appear in every trace. Fix in that order. Everything below is the catalogue of what usually turns up.</p>

<h2 id="the-product-data-model">The product data model</h2>

<p>WooCommerce stores products as posts and almost everything about them as post meta. Price, stock, SKU, attributes, visibility: each is a row in <code class="language-plaintext highlighter-rouge">wp_postmeta</code>, and a catalogue of ten thousand products with fifty meta keys each is half a million rows in a table indexed on <code class="language-plaintext highlighter-rouge">meta_key</code> but not usefully on <code class="language-plaintext highlighter-rouge">meta_value</code>.</p>

<p>Symptoms:</p>

<ul>
  <li>Shop and category pages that get slower as the catalogue grows.</li>
  <li>Filters and sorting by price or attribute that take seconds.</li>
  <li>Admin product lists that time out.</li>
</ul>

<p>What works:</p>

<ul>
  <li><strong>Use the lookup tables.</strong> WooCommerce maintains <code class="language-plaintext highlighter-rouge">wp_wc_product_meta_lookup</code> precisely so that price, stock and rating queries do not hit post meta. Custom queries that sort or filter by price should join that table, not <code class="language-plaintext highlighter-rouge">wp_postmeta</code>. Check that the table is populated (<code class="language-plaintext highlighter-rouge">wc update</code> and the regeneration tools exist for a reason).</li>
  <li><strong>Stop querying meta for lists.</strong> A product grid that calls <code class="language-plaintext highlighter-rouge">get_post_meta()</code> per product for a value the lookup table already has is the most common self-inflicted wound we see in themes.</li>
  <li><strong>Index what you actually filter on.</strong> If the business genuinely filters on a custom meta key, that data belongs in a custom table with an index, or at minimum in a taxonomy.</li>
</ul>

<h2 id="orders-move-to-hpos">Orders: move to HPOS</h2>

<p>Orders have the same problem as products, only worse, because order data grows without limit and is written on every checkout. High-Performance Order Storage (HPOS) moves orders into dedicated tables with real columns and indexes. On stores with more than a few tens of thousands of orders it is the single largest database win available, and it is now the default for new installations.</p>

<p>The work is in compatibility. Every plugin that touches orders must declare HPOS support and use the CRUD API (<code class="language-plaintext highlighter-rouge">$order-&gt;get_meta()</code>, <code class="language-plaintext highlighter-rouge">wc_get_orders()</code>) instead of <code class="language-plaintext highlighter-rouge">get_post_meta()</code> and <code class="language-plaintext highlighter-rouge">WP_Query</code> on the <code class="language-plaintext highlighter-rouge">shop_order</code> post type. The migration is safe and reversible, but it is a project, not a checkbox: audit the plugins, run with compatibility mode on staging, then switch.</p>

<h2 id="cart-fragments-and-the-uncacheable-request">Cart fragments and the uncacheable request</h2>

<p>Every page in a default WooCommerce theme fires an AJAX request after load to refresh the mini-cart (“cart fragments”). It is uncached by definition, it starts WordPress from scratch, and it runs even for visitors who have no cart. On a busy store it can be the majority of PHP requests.</p>

<p>Options, in order of preference:</p>

<ol>
  <li><strong>Do not load the fragments script on pages that do not need it.</strong> Product and content pages rarely need a live mini-cart. Dequeue <code class="language-plaintext highlighter-rouge">wc-cart-fragments</code> outside the cart and checkout, and render the cart count from the session on the rare pages that show it.</li>
  <li><strong>Use the Store API and the Blocks mini-cart</strong>, which fetch cart state only when opened.</li>
  <li>If the design insists on a live count everywhere, at least make that one endpoint as cheap as possible by unloading unrelated plugins on AJAX requests.</li>
</ol>

<p>The same reasoning applies to every plugin that adds an uncached request to each page view: live search, wishlist counters, recently viewed products, analytics trackers routed through WordPress. Each one is a full WordPress boot.</p>

<h2 id="sessions-and-the-object-cache">Sessions and the object cache</h2>

<p>WooCommerce keeps a session per visitor with a cart, in <code class="language-plaintext highlighter-rouge">wp_woocommerce_sessions</code>, and reads it early on nearly every request. Without a persistent object cache, transients, options and sessions all round-trip to MySQL.</p>

<p>A persistent object cache (Redis or Memcached) is the first infrastructure change we make on any store that lacks one. It turns thousands of repeated small queries into memory lookups. It also has two consequences that need managing:</p>

<ul>
  <li><strong>Autoloaded options.</strong> Everything with <code class="language-plaintext highlighter-rouge">autoload = yes</code> in <code class="language-plaintext highlighter-rouge">wp_options</code> is loaded on every request. Plugins that store large serialized arrays as autoloaded options make every request slower, cache or not. We audit the autoloaded size (anything over about a megabyte is worth cleaning) and fix the offenders.</li>
  <li><strong>Transient hygiene.</strong> WooCommerce and many extensions cache computed results in transients. With an object cache these live in memory and expire cleanly; without one they accumulate in the database until something cleans them up.</li>
</ul>

<h2 id="checkout">Checkout</h2>

<p>Checkout is the request that matters most and is the hardest to speed up, because it runs the most code: shipping calculation, tax, payment gateway initialisation, validation, plus every plugin that hooks into the process.</p>

<p>Where the time usually goes:</p>

<ul>
  <li><strong>Shipping methods that call external APIs</strong> on every recalculation. Rates should be cached per destination and cart signature for a sensible interval.</li>
  <li><strong>Tax lookups</strong> done live instead of from a cached table.</li>
  <li><strong>Gateways that initialise on every page</strong>, not just the checkout. Payment plugins are frequent offenders here, loading SDKs and making token calls on the home page.</li>
  <li><strong>Order creation hooks</strong> that send emails synchronously, call a CRM, or regenerate reports before the customer sees the confirmation page. Those belong in a queue (Action Scheduler is already there).</li>
</ul>

<p>Blocks-based checkout with the Store API is measurably lighter than the shortcode checkout, because the front end talks to a purpose-built REST API instead of posting the entire form and re-rendering. Whether a store can use it depends on gateway and extension support, which is the audit we do first.</p>

<h2 id="plugins-that-touch-every-request">Plugins that touch every request</h2>

<p>The last category is the most political. Every active plugin has a fixed cost on every request: files loaded, hooks registered, options read. Most are cheap. A few are not, and they do not announce themselves.</p>

<p>The trace tells you which ones. In our experience the usual names are page builders with front-end assets, all-in-one SEO and security suites, translation plugins that scan output, and analytics or marketing integrations that load their SDK server-side. The fix is rarely “delete it”; it is usually “load it only where it does something”, which is a small must-use plugin that deactivates plugins by route.</p>

<h2 id="what-does-not-help-much">What does not help much</h2>

<p>Two things show up in every performance conversation and rarely move the numbers on a store:</p>

<ul>
  <li><strong>Front-end optimisation plugins</strong> (minify, combine, defer). They improve a Lighthouse score on a cached product page and do nothing for the checkout, which is where revenue is lost. Do them last, not first.</li>
  <li><strong>Moving hosts without changing anything else.</strong> A faster server makes a slow query faster in absolute terms and changes nothing about how the store scales. If the plan is to grow, the data model and the request profile have to change.</li>
</ul>

<h2 id="the-order-we-work-in">The order we work in</h2>

<ol>
  <li>Measure: traces, slow query log, autoload size, request counts per route.</li>
  <li>Infrastructure that removes whole classes of cost: object cache, PHP 8.x, HTTP/2, a CDN for static assets.</li>
  <li>Data model: lookup tables, HPOS, custom tables for anything filtered at scale.</li>
  <li>Request profile: remove uncached requests per page view, load plugins by route.</li>
  <li>Checkout: cache external calls, queue side effects, evaluate Blocks checkout.</li>
  <li>Front-end polish, once the server side is honest.</li>
</ol>

<p>Every step is reported with a before and an after, on the routes that matter. That is the difference between performance engineering and performance plugins.</p>]]></content><author><name>Valerii Vasyliev</name></author><category term="Performance" /><category term="WooCommerce" /><category term="Performance" /><category term="Caching" /><category term="MySQL" /><summary type="html"><![CDATA[A profiling-first tour of the usual suspects in WooCommerce performance: the post meta model, cart fragments, uncached sessions, the checkout, and the plugins that touch every request. With the fixes that work and the ones that only look like they do.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gratta.pro/assets/img/og-image.png" /><media:content medium="image" url="https://gratta.pro/assets/img/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One project structure for every WordPress build</title><link href="https://gratta.pro/blog/wordpress-project-structure-for-automated-testing/" rel="alternate" type="text/html" title="One project structure for every WordPress build" /><published>2026-09-24T00:00:00+02:00</published><updated>2026-09-24T00:00:00+02:00</updated><id>https://gratta.pro/blog/wordpress-project-structure-for-automated-testing</id><content type="html" xml:base="https://gratta.pro/blog/wordpress-project-structure-for-automated-testing/"><![CDATA[<p>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.</p>

<p>None of it is exotic. The value is not in any single tool but in never having to decide again where things go.</p>

<h2 id="why-a-uniform-structure-matters-more-than-the-tools">Why a uniform structure matters more than the tools</h2>

<p>WordPress does not impose a project layout. A site can be a theme with a <code class="language-plaintext highlighter-rouge">functions.php</code> 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.</p>

<p>When every project uses the same layout:</p>

<ul>
  <li><strong>Onboarding is measured in hours.</strong> 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.</li>
  <li><strong>Tooling is copied, not rebuilt.</strong> The CI workflow, the linter configuration and the test bootstrap are the same files. Improvements made on one project are pulled into the others.</li>
  <li><strong>Reviews focus on the change.</strong> Nobody spends review time arguing about file placement or formatting; the linter already did.</li>
</ul>

<h2 id="the-repository-layout">The repository layout</h2>

<p>The root of a site repository looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.
├── .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
</code></pre></div></div>

<p>A few decisions in there are worth explaining.</p>

<p><strong>WordPress core is a dependency, not a checkout.</strong> Composer installs it into <code class="language-plaintext highlighter-rouge">web/wp/</code>, and <code class="language-plaintext highlighter-rouge">wp-content</code> moves to <code class="language-plaintext highlighter-rouge">web/app/</code>. Core is never edited, and upgrading it is a version bump in <code class="language-plaintext highlighter-rouge">composer.json</code> that goes through the same pull request flow as any other change.</p>

<p><strong>Site logic lives in must-use plugins.</strong> Business logic that must always run (custom post types, integrations, roles) goes into <code class="language-plaintext highlighter-rouge">web/app/mu-plugins/</code>, 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.</p>

<p><strong>The theme is presentation only.</strong> Templates, block patterns, <code class="language-plaintext highlighter-rouge">theme.json</code> and assets. If a function in the theme does something a different theme would also need, it is in the wrong place.</p>

<p><strong>Third-party plugins are pinned.</strong> 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.</p>

<h2 id="coding-standards-as-a-gate-not-a-suggestion">Coding standards as a gate, not a suggestion</h2>

<p><code class="language-plaintext highlighter-rouge">phpcs.xml.dist</code> 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.</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0"?&gt;</span>
<span class="nt">&lt;ruleset</span> <span class="na">name=</span><span class="s">"Project"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;file&gt;</span>web/app/mu-plugins<span class="nt">&lt;/file&gt;</span>
    <span class="nt">&lt;file&gt;</span>web/app/themes/project<span class="nt">&lt;/file&gt;</span>

    <span class="nt">&lt;arg</span> <span class="na">name=</span><span class="s">"extensions"</span> <span class="na">value=</span><span class="s">"php"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;arg</span> <span class="na">value=</span><span class="s">"sp"</span><span class="nt">/&gt;</span>

    <span class="nt">&lt;rule</span> <span class="na">ref=</span><span class="s">"WordPress"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;rule</span> <span class="na">ref=</span><span class="s">"WordPress.WP.I18n"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;properties&gt;</span>
            <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"text_domain"</span> <span class="na">type=</span><span class="s">"array"</span> <span class="na">value=</span><span class="s">"project"</span><span class="nt">/&gt;</span>
        <span class="nt">&lt;/properties&gt;</span>
    <span class="nt">&lt;/rule&gt;</span>
    <span class="nt">&lt;rule</span> <span class="na">ref=</span><span class="s">"WordPress.NamingConventions.PrefixAllGlobals"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;properties&gt;</span>
            <span class="nt">&lt;property</span> <span class="na">name=</span><span class="s">"prefixes"</span> <span class="na">type=</span><span class="s">"array"</span> <span class="na">value=</span><span class="s">"project"</span><span class="nt">/&gt;</span>
        <span class="nt">&lt;/properties&gt;</span>
    <span class="nt">&lt;/rule&gt;</span>

    <span class="nt">&lt;config</span> <span class="na">name=</span><span class="s">"testVersion"</span> <span class="na">value=</span><span class="s">"8.1-"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;rule</span> <span class="na">ref=</span><span class="s">"PHPCompatibilityWP"</span><span class="nt">/&gt;</span>
<span class="nt">&lt;/ruleset&gt;</span>
</code></pre></div></div>

<p>Two things make this work in practice. First, the linter runs in CI and blocks the merge, so the standard cannot drift. Second, <code class="language-plaintext highlighter-rouge">phpcbf</code> 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.</p>

<p>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.</p>

<h2 id="unit-and-integration-tests">Unit and integration tests</h2>

<p>We use PHPUnit with the WordPress test framework, run against a real database inside <code class="language-plaintext highlighter-rouge">wp-env</code>. The distinction we keep:</p>

<ul>
  <li><strong>Unit tests</strong> cover pure logic: price calculations, data transformations, validation. They do not need WordPress loaded and run in milliseconds.</li>
  <li><strong>Integration tests</strong> 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.</li>
</ul>

<p>A typical integration test for a REST endpoint:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_endpoint_requires_capability</span><span class="p">():</span> <span class="kt">void</span> <span class="p">{</span>
    <span class="nf">wp_set_current_user</span><span class="p">(</span> <span class="k">self</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">user</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">(</span> <span class="p">[</span> <span class="s1">'role'</span> <span class="o">=&gt;</span> <span class="s1">'subscriber'</span> <span class="p">]</span> <span class="p">)</span> <span class="p">);</span>

    <span class="nv">$request</span>  <span class="o">=</span> <span class="k">new</span> <span class="nc">WP_REST_Request</span><span class="p">(</span> <span class="s1">'POST'</span><span class="p">,</span> <span class="s1">'/project/v1/export'</span> <span class="p">);</span>
    <span class="nv">$response</span> <span class="o">=</span> <span class="nf">rest_get_server</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">dispatch</span><span class="p">(</span> <span class="nv">$request</span> <span class="p">);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertSame</span><span class="p">(</span> <span class="mi">403</span><span class="p">,</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">get_status</span><span class="p">()</span> <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>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.</p>

<h2 id="end-to-end-tests-for-the-flows-that-pay-the-bills">End-to-end tests for the flows that pay the bills</h2>

<p>Unit tests cannot tell you that checkout works. For that we use Playwright against a full WordPress instance started by <code class="language-plaintext highlighter-rouge">wp-env</code> in CI, with a seed script that creates the products, pages and users the tests need.</p>

<p>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:</p>

<ol>
  <li>Browse to a product, add it to the cart, complete checkout with the test payment gateway.</li>
  <li>Log in to the account area and see the order.</li>
  <li>Submit the main lead or contact form.</li>
  <li>Load the home page and the top landing pages without console errors.</li>
</ol>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">test</span><span class="p">(</span><span class="dl">'</span><span class="s1">guest checkout completes</span><span class="dl">'</span><span class="p">,</span> <span class="k">async </span><span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="dl">'</span><span class="s1">/product/sample-product/</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">getByRole</span><span class="p">(</span><span class="dl">'</span><span class="s1">button</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Add to cart</span><span class="dl">'</span> <span class="p">}).</span><span class="nf">click</span><span class="p">();</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="dl">'</span><span class="s1">/checkout/</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">getByLabel</span><span class="p">(</span><span class="dl">'</span><span class="s1">First name</span><span class="dl">'</span><span class="p">).</span><span class="nf">fill</span><span class="p">(</span><span class="dl">'</span><span class="s1">Test</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">getByLabel</span><span class="p">(</span><span class="dl">'</span><span class="s1">Last name</span><span class="dl">'</span><span class="p">).</span><span class="nf">fill</span><span class="p">(</span><span class="dl">'</span><span class="s1">Customer</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">getByLabel</span><span class="p">(</span><span class="dl">'</span><span class="s1">Email address</span><span class="dl">'</span><span class="p">).</span><span class="nf">fill</span><span class="p">(</span><span class="dl">'</span><span class="s1">test@example.com</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">getByRole</span><span class="p">(</span><span class="dl">'</span><span class="s1">button</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Place order</span><span class="dl">'</span> <span class="p">}).</span><span class="nf">click</span><span class="p">();</span>
  <span class="k">await</span> <span class="nf">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nf">getByText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Your order has been received</span><span class="dl">'</span><span class="p">)).</span><span class="nf">toBeVisible</span><span class="p">();</span>
<span class="p">});</span>
</code></pre></div></div>

<p>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.</p>

<h2 id="continuous-integration">Continuous integration</h2>

<p>The workflow is the same file on every project, with the project name as the only variable:</p>

<ol>
  <li>Install PHP and Composer dependencies with caching.</li>
  <li>Run <code class="language-plaintext highlighter-rouge">phpcs</code> and <code class="language-plaintext highlighter-rouge">phpstan</code>.</li>
  <li>Start <code class="language-plaintext highlighter-rouge">wp-env</code>, run PHPUnit.</li>
  <li>Build front-end assets, start the site, run Playwright.</li>
  <li>On the main branch, deploy to staging; on a tag, deploy to production.</li>
</ol>

<p>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.</p>

<h2 id="what-this-costs-and-what-it-returns">What this costs, and what it returns</h2>

<p>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.</p>

<p>What it returns, over the years a site is in production:</p>

<ul>
  <li><strong>Regression testing that does not consume QA hours.</strong> The suite runs on every change, including plugin and core updates.</li>
  <li><strong>Fast onboarding.</strong> New engineers ship on their first day because the structure and the tooling are already familiar.</li>
  <li><strong>A codebase that can be handed over.</strong> If a client ever moves in-house or to another agency, they receive a repository that explains itself.</li>
</ul>

<p>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.</p>]]></content><author><name>Valerii Vasyliev</name></author><category term="Engineering" /><category term="WordPress" /><category term="Testing" /><category term="PHPCS" /><category term="CI" /><summary type="html"><![CDATA[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.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gratta.pro/assets/img/og-image.png" /><media:content medium="image" url="https://gratta.pro/assets/img/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>