<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://wpvilla.in/feed.xml" rel="self" type="application/atom+xml" /><link href="https://wpvilla.in/" rel="alternate" type="text/html" /><updated>2026-08-14T02:12:40+00:00</updated><id>https://wpvilla.in/feed.xml</id><title type="html">wp villain</title><subtitle>A blog about modern WordPress development using Gutenberg blocks,  Roots Sage theme, Ollie WP, ACF, and other cutting-edge WordPress  development techniques and tools.</subtitle><entry><title type="html">Catching WordPress Emails Locally with MailHog (No More Test Mail Hitting Real Inboxes)</title><link href="https://wpvilla.in/mailhog-local-wordpress-email-testing/" rel="alternate" type="text/html" title="Catching WordPress Emails Locally with MailHog (No More Test Mail Hitting Real Inboxes)" /><published>2026-08-14T03:00:00+00:00</published><updated>2026-08-14T03:00:00+00:00</updated><id>https://wpvilla.in/mailhog-local-wordpress-email-testing</id><content type="html" xml:base="https://wpvilla.in/mailhog-local-wordpress-email-testing/"><![CDATA[<p>Every WordPress site sends more mail than people think: password resets, contact form notifications, WooCommerce order confirmations, plugin alerts. That’s fine in production. It’s a liability in local development — the last thing you want is a bug in a test script firing a “your order has shipped” email at an actual customer, or a password-reset flood landing in a real inbox while you’re debugging a form.</p>

<p><a href="https://github.com/mailhog/MailHog">MailHog</a> solves this cleanly: it’s a local SMTP server that catches every outgoing email instead of delivering it, and gives you a web UI to inspect what WordPress actually sent — headers, body, attachments — without any of it leaving your machine.</p>

<p>Here’s how it’s set up on a <a href="https://laravel.com/docs/valet">Laravel Valet</a> local WordPress install.</p>

<h2 id="the-setup">The Setup</h2>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Web Server</strong></td>
      <td>Laravel Valet</td>
    </tr>
    <tr>
      <td><strong>MailHog</strong></td>
      <td>Installed via Homebrew, running as a service</td>
    </tr>
    <tr>
      <td><strong>SMTP Port</strong></td>
      <td>1025</td>
    </tr>
    <tr>
      <td><strong>Web UI Port</strong></td>
      <td>8025</td>
    </tr>
    <tr>
      <td><strong>WordPress Config</strong></td>
      <td>Must-use plugin in <code class="language-plaintext highlighter-rouge">wp-content/mu-plugins/</code></td>
    </tr>
  </tbody>
</table>

<p>MailHog was already available via Homebrew:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew list | <span class="nb">grep </span>mailhog
ps aux | <span class="nb">grep </span>mailhog
</code></pre></div></div>

<p>It runs as a persistent background process — it survives Valet restarts, so there’s no per-session setup once it’s running.</p>

<h2 id="wiring-wordpress-to-mailhog">Wiring WordPress to MailHog</h2>

<p>WordPress sends mail through <code class="language-plaintext highlighter-rouge">wp_mail()</code>, which under the hood uses PHPMailer. Rather than touch PHP’s <code class="language-plaintext highlighter-rouge">sendmail_path</code> or reach for a full SMTP plugin, a small must-use plugin is enough to redirect PHPMailer at MailHog’s SMTP port:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="cd">/**
 * Plugin Name: MailHog SMTP Override
 * Description: Configures WordPress to send emails through MailHog SMTP (localhost:1025)
 * Version: 1.0
 */</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">defined</span><span class="p">(</span><span class="s1">'ABSPATH'</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">exit</span><span class="p">;</span>
<span class="p">}</span>

<span class="nf">add_action</span><span class="p">(</span><span class="s1">'phpmailer_init'</span><span class="p">,</span> <span class="s1">'mailhog_configure_smtp'</span><span class="p">);</span>
<span class="k">function</span> <span class="n">mailhog_configure_smtp</span><span class="p">(</span><span class="nv">$phpmailer</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nf">isSMTP</span><span class="p">();</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">Hostname</span> <span class="o">=</span> <span class="s1">'example.test'</span><span class="p">;</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">Host</span> <span class="o">=</span> <span class="s1">'localhost'</span><span class="p">;</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">Port</span> <span class="o">=</span> <span class="mi">1025</span><span class="p">;</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">SMTPSecure</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">SMTPAutoTLS</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
    <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">SMTPAuth</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="k">empty</span><span class="p">(</span><span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">From</span><span class="p">))</span> <span class="p">{</span>
        <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">From</span> <span class="o">=</span> <span class="s1">'noreply@example.test'</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span><span class="k">empty</span><span class="p">(</span><span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">FromName</span><span class="p">))</span> <span class="p">{</span>
        <span class="nv">$phpmailer</span><span class="o">-&gt;</span><span class="nc">FromName</span> <span class="o">=</span> <span class="s1">'Example Site'</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Save that as <code class="language-plaintext highlighter-rouge">wp-content/mu-plugins/mailhog-smtp.php</code> and it loads automatically — must-use plugins don’t need activating, and they can’t accidentally get deactivated mid-debug session either, which matters here since the whole point is that this stays on for every local request.</p>

<p>No authentication, no TLS — MailHog doesn’t need either since nothing actually leaves localhost.</p>

<h2 id="verifying-it-works">Verifying It Works</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp eval-file /dev/stdin <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">PHP</span><span class="sh">'
&lt;?php
</span><span class="nv">$to</span><span class="sh"> = 'test@example.com';
</span><span class="nv">$subject</span><span class="sh"> = 'MailHog Test Email';
</span><span class="nv">$message</span><span class="sh"> = 'This is a test email sent through MailHog SMTP';
</span><span class="nv">$result</span><span class="sh"> = wp_mail(</span><span class="nv">$to</span><span class="sh">, </span><span class="nv">$subject</span><span class="sh">, </span><span class="nv">$message</span><span class="sh">);
echo "Email sent: " . (</span><span class="nv">$result</span><span class="sh"> ? "SUCCESS" : "FAILED") . "</span><span class="se">\n</span><span class="sh">";
</span><span class="no">PHP
</span></code></pre></div></div>

<p>Then open <code class="language-plaintext highlighter-rouge">http://localhost:8025</code> — the email shows up in the inbox immediately, with full headers and body, and it never touched <code class="language-plaintext highlighter-rouge">test@example.com</code> for real.</p>

<h2 id="day-to-day-use">Day-to-Day Use</h2>

<p><strong>View emails:</strong> <code class="language-plaintext highlighter-rouge">http://localhost:8025</code> — click any message for headers, body, and attachments.</p>

<p><strong>Clear the inbox</strong> via the UI, or via the API:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> DELETE http://localhost:8025/api/v1/messages
</code></pre></div></div>

<p><strong>Check message count</strong> (useful in a script or CI-style check after a test run):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> http://localhost:8025/api/v2/messages | jq <span class="s1">'.total'</span>
</code></pre></div></div>

<p>Multiple recipients and HTML email both work exactly as they would against a real SMTP server — MailHog doesn’t need special-casing for either:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">wp_mail</span><span class="p">(</span><span class="s1">'user1@example.com, user2@example.com'</span><span class="p">,</span> <span class="s1">'Subject'</span><span class="p">,</span> <span class="s1">'Message'</span><span class="p">);</span>
<span class="nf">wp_mail</span><span class="p">(</span><span class="s1">'test@example.com'</span><span class="p">,</span> <span class="s1">'HTML Email'</span><span class="p">,</span> <span class="s1">'&lt;h1&gt;Hello&lt;/h1&gt;&lt;p&gt;This is HTML&lt;/p&gt;'</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="if-emails-dont-show-up">If Emails Don’t Show Up</h2>

<p>Work through these in order — in practice it’s almost always the first one:</p>

<ol>
  <li><strong>Is MailHog actually running?</strong>
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ps aux | <span class="nb">grep </span>mailhog
</code></pre></div>    </div>
  </li>
  <li><strong>Is something listening on 1025?</strong>
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsof <span class="nt">-i</span> :1025
</code></pre></div>    </div>
  </li>
  <li><strong>Manual SMTP smoke test:</strong>
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>telnet localhost 1025
</code></pre></div>    </div>
    <p>Type <code class="language-plaintext highlighter-rouge">HELO test</code>, then <code class="language-plaintext highlighter-rouge">QUIT</code> — if that hangs or refuses, the problem is MailHog itself, not WordPress.</p>
  </li>
  <li><strong>Check the WordPress debug log</strong> for a PHPMailer error the mu-plugin didn’t catch:
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tail</span> <span class="nt">-f</span> wp-content/debug.log
</code></pre></div>    </div>
  </li>
</ol>

<p>If MailHog isn’t running at all, <code class="language-plaintext highlighter-rouge">mailhog &amp;</code> starts it manually, or <code class="language-plaintext highlighter-rouge">brew services start mailhog</code> if it’s registered as a service. Port conflicts on 1025/8025 are the other common failure — <code class="language-plaintext highlighter-rouge">lsof -i :1025</code> / <code class="language-plaintext highlighter-rouge">lsof -i :8025</code> to find the PID, <code class="language-plaintext highlighter-rouge">kill -9</code> it, then restart MailHog.</p>

<h2 id="alternatives">Alternatives</h2>

<p>If a full SMTP plugin fits the project better than a bare mu-plugin, <a href="https://wordpress.org/plugins/wp-mail-smtp/">WP Mail SMTP</a> points at the same MailHog port through its own settings screen (Mailer: Other SMTP, Host: <code class="language-plaintext highlighter-rouge">localhost</code>, Port: <code class="language-plaintext highlighter-rouge">1025</code>, Encryption: None, Authentication: Off) — same destination, more UI for anyone who’d rather not touch PHP.</p>

<p>The one case a <code class="language-plaintext highlighter-rouge">phpmailer_init</code> hook can’t catch: code that bypasses <code class="language-plaintext highlighter-rouge">wp_mail()</code> entirely and calls PHP’s <code class="language-plaintext highlighter-rouge">mail()</code> directly. That needs <code class="language-plaintext highlighter-rouge">sendmail_path</code> overridden in <code class="language-plaintext highlighter-rouge">php.ini</code> instead, which is more invasive and worth avoiding unless something in your stack actually forces it.</p>

<h2 id="summary">Summary</h2>

<ul>
  <li>MailHog catches outgoing WordPress mail locally instead of delivering it — no risk of test runs emailing real people.</li>
  <li>A single <code class="language-plaintext highlighter-rouge">phpmailer_init</code> mu-plugin is enough to redirect <code class="language-plaintext highlighter-rouge">wp_mail()</code> at MailHog’s SMTP port; no plugin activation, no config file changes.</li>
  <li>The web UI (<code class="language-plaintext highlighter-rouge">localhost:8025</code>) and its REST API cover everything you need day to day: inspecting, clearing, and counting captured mail.</li>
  <li>Keep MailHog running as a background service so it survives Valet restarts and there’s nothing to remember to start.</li>
</ul>

<p>This is the same kind of local-environment hygiene we set up as part of managed WordPress hosting work at <a href="https://imagewize.com">Imagewize</a> — getting a dev environment that behaves like production without any of the production risk. If you’re setting up a WordPress local dev workflow and want a hand, <a href="https://imagewize.com/contact/">get in touch</a>.</p>

<hr />

<p><em>Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a> if you’ve got a MailHog or local-mail setup worth comparing notes on.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="php" /><category term="devops" /><category term="wordpress" /><category term="php" /><category term="devops" /><category term="mailhog" /><category term="smtp" /><category term="valet" /><category term="local-development" /><category term="testing" /><summary type="html"><![CDATA[Every WordPress site sends more mail than people think: password resets, contact form notifications, WooCommerce order confirmations, plugin alerts. That’s fine in production. It’s a liability in local development — the last thing you want is a bug in a test script firing a “your order has shipped” email at an actual customer, or a password-reset flood landing in a real inbox while you’re debugging a form.]]></summary></entry><entry><title type="html">WP 2FA on Roots Bedrock/Trellis: Per-Environment Encryption Keys and a Composer Patch for a Fatal Bug</title><link href="https://wpvilla.in/wp-2fa-bedrock-trellis-encryption-key-patch/" rel="alternate" type="text/html" title="WP 2FA on Roots Bedrock/Trellis: Per-Environment Encryption Keys and a Composer Patch for a Fatal Bug" /><published>2026-07-03T07:00:00+00:00</published><updated>2026-07-03T07:00:00+00:00</updated><id>https://wpvilla.in/wp-2fa-bedrock-trellis-encryption-key-patch</id><content type="html" xml:base="https://wpvilla.in/wp-2fa-bedrock-trellis-encryption-key-patch/"><![CDATA[<p>We added <a href="https://wordpress.org/plugins/wp-2fa/">WP 2FA</a> (two-factor authentication) to a client site this week to help lock down <code class="language-plaintext highlighter-rouge">/wp-login.php</code> after a brute-force wave that hit us — 20 IPs, 176 to 851 login attempts each in 48 hours. Blocking the IPs at the Nginx level stops that specific wave, but 2FA is the actual fix: even a leaked or brute-forced password stops being enough on its own.</p>

<p>Getting it working cleanly on a <a href="https://roots.io/bedrock/">Roots Bedrock</a>/<a href="https://roots.io/trellis/">Trellis</a> stack took two separate fixes, not one.</p>

<h2 id="the-encryption-key-problem">The Encryption Key Problem</h2>

<p>WP 2FA stores an encryption key for 2FA secrets in the <code class="language-plaintext highlighter-rouge">wp_options</code> table by default, under <code class="language-plaintext highlighter-rouge">wp_2fa_secret_key</code>. That’s fine for a stock WordPress install, but Bedrock keeps environment-specific config <em>out</em> of <code class="language-plaintext highlighter-rouge">wp-config.php</code> and out of the database — everything lives in <code class="language-plaintext highlighter-rouge">.env</code> and <code class="language-plaintext highlighter-rouge">config/environments/{development,staging,production}.php</code>, loaded via <code class="language-plaintext highlighter-rouge">Roots\WPConfig\Config</code>. A secret sitting in the database instead of environment config breaks that model, and WP 2FA actually warns you about it on its own settings page.</p>

<p><a href="https://barrd.dev/article/wordpress-install-2fa-plugin-when-using-roots-bedrock/">Dave at barrd.dev wrote up the fix for this back in 2023</a>, and it’s still the right approach:</p>

<ol>
  <li>Deactivate the plugin.</li>
  <li>Copy the key from <code class="language-plaintext highlighter-rouge">wp_options</code> (<code class="language-plaintext highlighter-rouge">wp_2fa_secret_key</code>, watch for a non-<code class="language-plaintext highlighter-rouge">wp_</code> table prefix).</li>
  <li>Move it into the environment config file instead:</li>
</ol>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// config/environments/production.php</span>
<span class="kn">use</span> <span class="nc">Roots\WPConfig\Config</span><span class="p">;</span>

<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP2FA_ENCRYPT_KEY'</span><span class="p">,</span> <span class="s1">'your-encryption-key-here'</span><span class="p">);</span>
</code></pre></div></div>

<ol>
  <li>Delete the <code class="language-plaintext highlighter-rouge">wp_2fa_secret_key</code> row from the database.</li>
  <li>Reactivate the plugin.</li>
</ol>

<p>The one thing worth adding to that: <strong>give each environment its own key</strong>, not the same value copied into both files. Development and production are different databases with different 2FA-secret data, so there’s no reason to share a key between them — if anything, sharing weakens the isolation between environments for no benefit.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// config/environments/development.php</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP2FA_ENCRYPT_KEY'</span><span class="p">,</span> <span class="s1">'a-different-key-for-dev'</span><span class="p">);</span>

<span class="c1">// config/environments/production.php</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP2FA_ENCRYPT_KEY'</span><span class="p">,</span> <span class="s1">'a-different-key-for-production'</span><span class="p">);</span>
</code></pre></div></div>

<p>Generate each with <code class="language-plaintext highlighter-rouge">wp_generate_password(32, false)</code> or <code class="language-plaintext highlighter-rouge">openssl rand -base64 24</code> — anything sufficiently random works, since it’s only ever read by <code class="language-plaintext highlighter-rouge">Config::define()</code>.</p>

<h2 id="the-second-problem-a-fatal-bug-barrddev-didnt-hit">The Second Problem: A Fatal Bug barrd.dev Didn’t Hit</h2>

<p>Once the encryption key was sorted, saving the plugin’s <strong>Policies</strong> tab threw a fatal error under <code class="language-plaintext highlighter-rouge">WP_DEBUG</code> in development (and silently no-op’d in production, which is worse — it just quietly doesn’t save).</p>

<p>The plugin’s Policies settings page has an option to generate a custom “user account” page for the 2FA setup wizard. If you never enable that option, <code class="language-plaintext highlighter-rouge">custom-user-page-id</code> stays an empty string. But the save handler calls <code class="language-plaintext highlighter-rouge">wp_delete_post()</code> unconditionally whenever certain settings change, without checking that a page ID actually exists:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// includes/classes/Admin/SettingsPages/class-settings-page-policies.php (WP 2FA 3.1.1.2)</span>
<span class="nf">\wp_delete_post</span><span class="p">(</span> <span class="no">WP2FA</span><span class="o">::</span><span class="nf">get_wp2fa_setting</span><span class="p">(</span> <span class="s1">'custom-user-page-id'</span> <span class="p">),</span> <span class="kc">true</span> <span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">wp_delete_post('')</code> isn’t a no-op in every code path — it’s enough to throw a fatal type error under <code class="language-plaintext highlighter-rouge">WP_DEBUG</code>. This is a plugin bug, not something we control, so the fix has to live at the vendor-patch level rather than in our own code.</p>

<h3 id="patching-a-composer-installed-plugin">Patching a Composer-Installed Plugin</h3>

<p>Bedrock installs plugins via Composer, so hand-editing the file in <code class="language-plaintext highlighter-rouge">vendor/</code>/<code class="language-plaintext highlighter-rouge">web/app/plugins/</code> doesn’t survive the next <code class="language-plaintext highlighter-rouge">composer install</code>. The standard way to patch a Composer dependency and have the patch reapply automatically is <a href="https://github.com/cweagans/composer-patches"><code class="language-plaintext highlighter-rouge">cweagans/composer-patches</code></a>.</p>

<p>Add it as a dependency and allow it to run:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"cweagans/composer-patches"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.7"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"config"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"allow-plugins"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"cweagans/composer-patches"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"extra"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"patches"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"wpackagist-plugin/wp-2fa"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"Fix wp_delete_post() fatal on Policies save when no custom user page exists"</span><span class="p">:</span><span class="w"> </span><span class="s2">"patches/wp-2fa-fix-custom-user-page-delete.patch"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then the patch itself, guarding the delete with an actual ID check:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- a/includes/classes/Admin/SettingsPages/class-settings-page-policies.php
</span><span class="gi">+++ b/includes/classes/Admin/SettingsPages/class-settings-page-policies.php
</span><span class="p">@@ -419,7 +419,10 @@</span>
 					$output['custom-user-page-id']         = '';
 					$output['separate-multisite-page-url'] = '';
 					$output['hide_page_generated_by']      = '';
<span class="gd">-					\wp_delete_post( WP2FA::get_wp2fa_setting( 'custom-user-page-id' ), true );
</span><span class="gi">+					$custom_user_page_id = (int) WP2FA::get_wp2fa_setting( 'custom-user-page-id' );
+					if ( $custom_user_page_id &gt; 0 ) {
+						\wp_delete_post( $custom_user_page_id, true );
+					}
</span> 				}
 			}
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">composer install</code> (or <code class="language-plaintext highlighter-rouge">composer update wpackagist-plugin/wp-2fa</code>) applies it automatically from then on — no manual edits to <code class="language-plaintext highlighter-rouge">vendor/</code> after every deploy, and the patch travels with the repo so every environment gets the same fix.</p>

<h2 id="why-bother-with-both-fixes">Why Bother With Both Fixes</h2>

<p>It would’ve been easy to stop at “the plugin works, 2FA is enabled” and leave the Policies-tab bug alone since it only breaks a secondary settings page, not authentication itself. But a settings page that silently fails to save in production is exactly the kind of thing that causes a support ticket three months from now when someone tries to change a policy and can’t figure out why nothing sticks. Composer patches are cheap insurance for that — a few lines, applied automatically, documented in the repo instead of tribal knowledge.</p>

<h2 id="summary">Summary</h2>

<ul>
  <li>Bedrock/Trellis stacks need 2FA secrets moved out of the database and into <code class="language-plaintext highlighter-rouge">config/environments/*.php</code>, per environment — <a href="https://barrd.dev/article/wordpress-install-2fa-plugin-when-using-roots-bedrock/">barrd.dev’s original write-up</a> covers the mechanics.</li>
  <li>Give dev and production separate <code class="language-plaintext highlighter-rouge">WP2FA_ENCRYPT_KEY</code> values.</li>
  <li>If you hit a fatal error saving WP 2FA’s Policies tab, it’s a real bug in the plugin (<code class="language-plaintext highlighter-rouge">wp_delete_post()</code> called without checking the ID first) — patch it with <code class="language-plaintext highlighter-rouge">cweagans/composer-patches</code> rather than editing <code class="language-plaintext highlighter-rouge">vendor/</code> by hand.</li>
</ul>

<p>This is part of the hardening work we do as part of managed Trellis/Bedrock hosting at <a href="https://imagewize.com">Imagewize</a> — brute-force IP blocking, rate limiting, and now 2FA rollout. If you’re running a Bedrock/Trellis stack and want a hand with your security setup, <a href="https://imagewize.com/contact/">get in touch</a>.</p>

<hr />

<p><em>Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a> if you’ve hit other WP 2FA quirks on a Bedrock stack.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="php" /><category term="security" /><category term="wordpress" /><category term="php" /><category term="security" /><category term="2fa" /><category term="bedrock" /><category term="trellis" /><category term="composer" /><summary type="html"><![CDATA[We added WP 2FA (two-factor authentication) to a client site this week to help lock down /wp-login.php after a brute-force wave that hit us — 20 IPs, 176 to 851 login attempts each in 48 hours. Blocking the IPs at the Nginx level stops that specific wave, but 2FA is the actual fix: even a leaked or brute-forced password stops being enough on its own.]]></summary></entry><entry><title type="html">How Trellis’s database-pull Playbook Works (And What We Fixed)</title><link href="https://wpvilla.in/trellis-database-pull-playbook-explained/" rel="alternate" type="text/html" title="How Trellis’s database-pull Playbook Works (And What We Fixed)" /><published>2026-02-27T03:00:00+00:00</published><updated>2026-02-27T03:00:00+00:00</updated><id>https://wpvilla.in/trellis-database-pull-playbook-explained</id><content type="html" xml:base="https://wpvilla.in/trellis-database-pull-playbook-explained/"><![CDATA[<p>One of the most useful Trellis commands in day-to-day WordPress development is <code class="language-plaintext highlighter-rouge">trellis db pull</code>. It syncs your production (or staging) database down to local — switching URLs automatically so your dev site works immediately. But the playbook behind it does more than most developers realise, and the default Trellis version had some issues we needed to fix before it worked reliably.</p>

<p>This post walks through exactly what <code class="language-plaintext highlighter-rouge">database-pull.yml</code> does, the safety feature hidden in the middle of it, and the two patches we made to get it working correctly with a Bedrock project.</p>

<h2 id="what-the-playbook-does">What the Playbook Does</h2>

<p>Running <code class="language-plaintext highlighter-rouge">trellis db pull production</code> executes <code class="language-plaintext highlighter-rouge">database-pull.yml</code> against your production host. Here’s the sequence:</p>

<h3 id="1-pre-flight-validation">1. Pre-flight validation</h3>

<p>Before touching any database, the playbook checks three things:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Abort if environment variable is equal to development</span>
  <span class="na">fail</span><span class="pi">:</span>
    <span class="na">msg</span><span class="pi">:</span> <span class="s2">"</span><span class="s">ERROR:</span><span class="nv"> </span><span class="s">development</span><span class="nv"> </span><span class="s">is</span><span class="nv"> </span><span class="s">not</span><span class="nv"> </span><span class="s">a</span><span class="nv"> </span><span class="s">valid</span><span class="nv"> </span><span class="s">environment</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">this</span><span class="nv"> </span><span class="s">mode..."</span>
  <span class="na">when</span><span class="pi">:</span> <span class="s">env == "development"</span>
</code></pre></div></div>

<p>You cannot pull from development to development — a useful guard if you ever mis-type the environment flag.</p>

<p>It also checks that your local project folder exists:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Check if Jekyll::Drops::SiteDrop local folder exists</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">stat</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">register</span><span class="pi">:</span> <span class="s">result</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">no</span>
</code></pre></div></div>

<p>If the path doesn’t exist the playbook aborts with a clear message rather than partially running and leaving things in an inconsistent state.</p>

<h3 id="2-create-local-backup-directory">2. Create local backup directory</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create local database_backup directory if it doesn't exist</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">file</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/database_backup"</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">directory</span>
    <span class="na">mode</span><span class="pi">:</span> <span class="m">0755</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">no</span>
</code></pre></div></div>

<p>On first run this creates a <code class="language-plaintext highlighter-rouge">database_backup/</code> folder inside your local Bedrock project root. All backups end up here.</p>

<h3 id="3-dump-and-transfer-the-remote-database">3. Dump and transfer the remote database</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create database dump on</span> 
  <span class="na">shell</span><span class="pi">:</span> <span class="s">wp db export --allow-root - | gzip &gt;</span> 
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
</code></pre></div></div>

<p>The dump is created on the remote server, piped through gzip. Then it’s fetched to local via Ansible’s <code class="language-plaintext highlighter-rouge">fetch</code> module:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Pull database dump from  to development</span>
  <span class="na">fetch</span><span class="pi">:</span>
    <span class="na">src</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/"</span>
    <span class="na">dest</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/"</span>
    <span class="na">flat</span><span class="pi">:</span> <span class="s">yes</span>
</code></pre></div></div>

<p>After the transfer the remote dump is deleted immediately — no production files left hanging around on the server.</p>

<h3 id="4-the-hidden-safety-feature-backup-local-first">4. The hidden safety feature: backup local first</h3>

<p>This is the part most developers don’t expect. <strong>Before importing the production dump, the playbook backs up your current local database:</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Export development database before importing dump (backup)</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">shell</span><span class="pi">:</span> <span class="s">wp db export - | gzip &gt; database_backup/</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/web/wp"</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">no</span>
</code></pre></div></div>

<p>The backup filename includes a timestamp:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>imagewize_com_development_2026_02_27_10_30_45.sql.gz
</code></pre></div></div>

<p>So if the import causes any problems — or you realise after the fact that you needed that local data — you can restore it from <code class="language-plaintext highlighter-rouge">database_backup/</code>. Every pull creates a new timestamped backup, meaning you accumulate a history of your local database states. Worth clearing out periodically.</p>

<h3 id="5-import-and-search-replace">5. Import and search-replace</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Import database dump on development</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">shell</span><span class="pi">:</span> <span class="s">gzip -c -d  | wp db import -</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">chdir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/web/wp"</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">no</span>
</code></pre></div></div>

<p>Then the URL swap:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Search for  and replace with  on development</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">wp search-replace '//' '//' --allow-root --all-tables --precise</span>
</code></pre></div></div>

<p>Both <code class="language-plaintext highlighter-rouge">url_from</code> (production) and <code class="language-plaintext highlighter-rouge">url_to</code> (local) are resolved from config automatically. After this step your local WordPress is fully functional with production content.</p>

<hr />

<h2 id="what-we-fixed">What We Fixed</h2>

<p>The version of <code class="language-plaintext highlighter-rouge">database-pull.yml</code> we started with had two problems.</p>

<h3 id="fix-1-broken-hostvars-references">Fix 1: Broken hostvars references</h3>

<p>The original playbook resolved local site config like this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">host</span><span class="pi">:</span> <span class="s2">"</span><span class="s">_host"</span>
<span class="na">from_host</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
<span class="na">url_from</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
<span class="na">url_to</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
<span class="na">local_bedrock_dir</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
</code></pre></div></div>

<p>It was trying to read the development site config via <code class="language-plaintext highlighter-rouge">hostvars.development_host</code>, which required that host to be in the play’s host inventory. Since the playbook only targets <code class="language-plaintext highlighter-rouge">web:&amp;</code> (the remote server), <code class="language-plaintext highlighter-rouge">development_host</code> wasn’t always populated at the right time. This caused intermittent failures that were hard to reproduce.</p>

<p>The fix was to load the development config directly using <code class="language-plaintext highlighter-rouge">vars_files</code> and a file lookup:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">vars_files</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">group_vars/development/wordpress_sites.yml</span>

<span class="na">vars</span><span class="pi">:</span>
  <span class="na">url_from</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">dev_wordpress_sites</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">url_to</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">project_local_path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
</code></pre></div></div>

<p>Reading the file directly is more reliable than relying on <code class="language-plaintext highlighter-rouge">hostvars</code> being populated for a host that isn’t part of the current play.</p>

<h3 id="fix-2-wrong-delegate_to-target">Fix 2: Wrong delegate_to target</h3>

<p>The original used <code class="language-plaintext highlighter-rouge">delegate_to: development_host</code> for local tasks:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create database_backup directory if it doesn't exist</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">development_host</span>
  <span class="na">file</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/database_backup"</span>
    <span class="s">...</span>
</code></pre></div></div>

<p>Two problems here: <code class="language-plaintext highlighter-rouge">development_host</code> has the same availability issue as above, and the path was using <code class="language-plaintext highlighter-rouge">project_web_dir</code> (the remote path) instead of the local Bedrock path.</p>

<p>Replacing with <code class="language-plaintext highlighter-rouge">delegate_to: localhost</code> and correcting the path:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create local database_backup directory if it doesn't exist</span>
  <span class="na">delegate_to</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">file</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/database_backup"</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">directory</span>
    <span class="na">mode</span><span class="pi">:</span> <span class="m">0755</span>
  <span class="na">become</span><span class="pi">:</span> <span class="s">no</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">become: no</code> is also important — local tasks shouldn’t run as root.</p>

<hr />

<h2 id="using-it">Using It</h2>

<p>Once the playbook is correct, pulling production to local is a single command run from the Trellis directory:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>trellis
trellis db pull production
</code></pre></div></div>

<p>For a Bedrock project the WP path is in the <code class="language-plaintext highlighter-rouge">web/wp</code> subdirectory, which the playbook handles automatically via the chdir arguments.</p>

<p>If you want to run just the search-replace step again (useful if something went wrong):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>trellis db pull production <span class="nt">--tags</span> search-replace
</code></pre></div></div>

<hr />

<h2 id="takeaway">Takeaway</h2>

<p>The Trellis database-pull playbook is well-designed, but the default version had brittle <code class="language-plaintext highlighter-rouge">hostvars</code> lookups that could fail depending on how hosts were configured. Switching to direct file lookups and <code class="language-plaintext highlighter-rouge">delegate_to: localhost</code> made it deterministic.</p>

<p>The built-in local backup before import is a genuinely useful safety net that’s easy to miss since it’s buried in the middle of the playbook. Your <code class="language-plaintext highlighter-rouge">database_backup/</code> directory quietly accumulates timestamped snapshots of every pull — worth knowing about before you hit a situation where you need it.</p>

<p>This is part of the Trellis-based WordPress deployment workflow we use at <a href="https://imagewize.com">Imagewize</a> for client projects. If you’re running a Trellis stack and want help setting up or debugging your deployment pipeline, <a href="https://imagewize.com/contact-us/">get in touch</a>.</p>

<hr />

<p><em>Questions or issues with your Trellis setup? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a>.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="trellis" /><category term="ansible" /><category term="bedrock" /><category term="wordpress" /><category term="trellis" /><category term="ansible" /><category term="bedrock" /><category term="database" /><category term="wp-cli" /><category term="roots" /><category term="deployment" /><summary type="html"><![CDATA[One of the most useful Trellis commands in day-to-day WordPress development is trellis db pull. It syncs your production (or staging) database down to local — switching URLs automatically so your dev site works immediately. But the playbook behind it does more than most developers realise, and the default Trellis version had some issues we needed to fix before it worked reliably.]]></summary></entry><entry><title type="html">WooCommerce vs Shopify 2025: Which E-Commerce Platform is Right for Your Business?</title><link href="https://wpvilla.in/woocommerce-vs-shopify-2025-which-platform-is-right-for-your-business/" rel="alternate" type="text/html" title="WooCommerce vs Shopify 2025: Which E-Commerce Platform is Right for Your Business?" /><published>2025-11-25T13:00:00+00:00</published><updated>2025-11-25T13:00:00+00:00</updated><id>https://wpvilla.in/woocommerce-vs-shopify-2025-which-platform-is-right-for-your-business</id><content type="html" xml:base="https://wpvilla.in/woocommerce-vs-shopify-2025-which-platform-is-right-for-your-business/"><![CDATA[<p>If you’re starting an online store in 2025, you’ve likely narrowed your platform choice down to <strong>WooCommerce</strong> or <strong>Shopify</strong>. Both are excellent options, but they take fundamentally different approaches to e-commerce.</p>

<p>After building and maintaining 100+ e-commerce stores over 15 years, I’ve worked extensively with both platforms. In this guide, I’ll break down exactly when to choose each platform based on your business needs, budget, and technical comfort level.</p>

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#quick-summary-which-should-you-choose">Quick Summary: Which Should You Choose?</a></li>
  <li><a href="#platform-fundamentals">Platform Fundamentals</a></li>
  <li><a href="#cost-comparison">Cost Comparison</a></li>
  <li><a href="#features--functionality">Features &amp; Functionality</a></li>
  <li><a href="#flexibility--customization">Flexibility &amp; Customization</a></li>
  <li><a href="#performance--speed">Performance &amp; Speed</a></li>
  <li><a href="#seo-capabilities">SEO Capabilities</a></li>
  <li><a href="#ease-of-use">Ease of Use</a></li>
  <li><a href="#payment-processing">Payment Processing</a></li>
  <li><a href="#support--maintenance">Support &amp; Maintenance</a></li>
  <li><a href="#real-world-use-cases">Real-World Use Cases</a></li>
  <li><a href="#migration-between-platforms">Migration Between Platforms</a></li>
  <li><a href="#final-verdict">Final Verdict</a></li>
</ol>

<hr />

<h2 id="quick-summary-which-should-you-choose">Quick Summary: Which Should You Choose?</h2>

<h3 id="choose-woocommerce-if">Choose WooCommerce If:</h3>
<ul>
  <li>✅ You want <strong>complete control</strong> over your store</li>
  <li>✅ You have (or can hire) <strong>technical expertise</strong></li>
  <li>✅ You need <strong>extensive customization</strong> beyond templates</li>
  <li>✅ You want to <strong>avoid monthly platform fees</strong> long-term</li>
  <li>✅ You need <strong>unlimited products</strong> and <strong>no transaction fees</strong></li>
  <li>✅ You prefer <strong>one-time costs</strong> over recurring subscriptions</li>
  <li>✅ You’re already comfortable with <strong>WordPress</strong></li>
</ul>

<h3 id="choose-shopify-if">Choose Shopify If:</h3>
<ul>
  <li>✅ You want <strong>fast, easy setup</strong> without technical work</li>
  <li>✅ You prefer <strong>all-in-one hosting</strong> and don’t want to manage servers</li>
  <li>✅ You need <strong>24/7 support</strong> from the platform</li>
  <li>✅ You value <strong>simplicity</strong> over deep customization</li>
  <li>✅ You’re okay with <strong>monthly fees</strong> in exchange for convenience</li>
  <li>✅ You need <strong>quick time-to-market</strong> (launch in days, not weeks)</li>
</ul>

<p><strong>TLDR:</strong> Shopify is easier and faster to set up. WooCommerce is more flexible and cheaper long-term.</p>

<hr />

<h2 id="platform-fundamentals">Platform Fundamentals</h2>

<h3 id="woocommerce">WooCommerce</h3>
<ul>
  <li><strong>Type:</strong> WordPress plugin (self-hosted)</li>
  <li><strong>First Released:</strong> 2011</li>
  <li><strong>Market Share:</strong> ~39% of all e-commerce sites</li>
  <li><strong>Model:</strong> Free, open-source plugin + paid extensions + hosting costs</li>
</ul>

<p><strong>How It Works:</strong></p>
<ol>
  <li>Install WordPress</li>
  <li>Install WooCommerce plugin (free)</li>
  <li>Add products, payment gateways, shipping</li>
  <li>Customize with themes and plugins</li>
</ol>

<h3 id="shopify">Shopify</h3>
<ul>
  <li><strong>Type:</strong> Hosted SaaS platform</li>
  <li><strong>First Released:</strong> 2006</li>
  <li><strong>Market Share:</strong> ~28% of all e-commerce sites</li>
  <li><strong>Model:</strong> Monthly subscription + transaction fees + app costs</li>
</ul>

<p><strong>How It Works:</strong></p>
<ol>
  <li>Sign up for Shopify account</li>
  <li>Choose a theme (free or paid)</li>
  <li>Add products and configure settings</li>
  <li>Launch (no hosting or technical setup needed)</li>
</ol>

<hr />

<h2 id="cost-comparison">Cost Comparison</h2>

<h3 id="woocommerce-true-costs-year-1">WooCommerce True Costs (Year 1)</h3>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Cost Range</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>WordPress Core</strong></td>
      <td>Free</td>
    </tr>
    <tr>
      <td><strong>WooCommerce Plugin</strong></td>
      <td>Free</td>
    </tr>
    <tr>
      <td><strong>Domain Name</strong></td>
      <td>€10-€15/year</td>
    </tr>
    <tr>
      <td><strong>Hosting</strong></td>
      <td>€100-€1,200/year*</td>
    </tr>
    <tr>
      <td><strong>SSL Certificate</strong></td>
      <td>Free (Let’s Encrypt)</td>
    </tr>
    <tr>
      <td><strong>Theme</strong></td>
      <td>€0-€200 (one-time)</td>
    </tr>
    <tr>
      <td><strong>Essential Plugins</strong></td>
      <td>€0-€300/year</td>
    </tr>
    <tr>
      <td><strong>Payment Gateway Fees</strong></td>
      <td>1.5-3% per transaction</td>
    </tr>
    <tr>
      <td><strong>TOTAL (Year 1)</strong></td>
      <td><strong>€110 - €1,715</strong></td>
    </tr>
  </tbody>
</table>

<p>*Hosting costs vary wildly:</p>
<ul>
  <li>Shared hosting: €100-€200/year (not recommended for WooCommerce)</li>
  <li>Managed WooCommerce: €300-€600/year (Kinsta, WP Engine)</li>
  <li>VPS with Trellis: €600-€1,200/year (best performance)</li>
</ul>

<h3 id="shopify-true-costs-year-1">Shopify True Costs (Year 1)</h3>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Cost Range</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Basic Plan</strong></td>
      <td>€29/month = €348/year</td>
    </tr>
    <tr>
      <td><strong>Shopify Plan</strong> (mid-tier)</td>
      <td>€79/month = €948/year</td>
    </tr>
    <tr>
      <td><strong>Advanced Plan</strong></td>
      <td>€289/month = €3,468/year</td>
    </tr>
    <tr>
      <td><strong>Domain Name</strong></td>
      <td>€14/year</td>
    </tr>
    <tr>
      <td><strong>Transaction Fees</strong></td>
      <td>0.5-2% (if not using Shopify Payments)</td>
    </tr>
    <tr>
      <td><strong>Apps/Plugins</strong></td>
      <td>€0-€500/year</td>
    </tr>
    <tr>
      <td><strong>Theme</strong></td>
      <td>€0-€350 (one-time)</td>
    </tr>
    <tr>
      <td><strong>TOTAL (Year 1)</strong></td>
      <td><strong>€362 - €4,332</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>Key Difference:</strong> WooCommerce has higher upfront costs but lower ongoing costs. Shopify has low setup costs but continuous monthly fees.</p>

<h3 id="5-year-cost-projection">5-Year Cost Projection</h3>

<p><strong>Small Store (€50k/year revenue):</strong></p>
<ul>
  <li>WooCommerce: ~€2,500-€3,500 total</li>
  <li>Shopify (Basic): ~€2,100-€3,000 total</li>
</ul>

<p><strong>Medium Store (€250k/year revenue):</strong></p>
<ul>
  <li>WooCommerce: ~€3,500-€5,500 total</li>
  <li>Shopify (Shopify Plan): ~€5,200-€7,500 total</li>
</ul>

<p><strong>Large Store (€1M+/year revenue):</strong></p>
<ul>
  <li>WooCommerce: ~€6,000-€10,000 total</li>
  <li>Shopify (Advanced): ~€18,000-€25,000 total</li>
</ul>

<p><strong>Winner:</strong> WooCommerce becomes significantly cheaper at scale.</p>

<hr />

<h2 id="features--functionality">Features &amp; Functionality</h2>

<h3 id="core-e-commerce-features-both-platforms">Core E-Commerce Features (Both Platforms)</h3>

<p>Both WooCommerce and Shopify handle the essentials:</p>
<ul>
  <li>✅ Product management (simple, variable, digital, subscriptions)</li>
  <li>✅ Inventory tracking</li>
  <li>✅ Order management</li>
  <li>✅ Customer accounts</li>
  <li>✅ Discount codes and promotions</li>
  <li>✅ Shipping calculations</li>
  <li>✅ Tax calculations</li>
  <li>✅ Analytics and reporting</li>
</ul>

<h3 id="where-woocommerce-excels">Where WooCommerce Excels</h3>

<ol>
  <li><strong>Content Marketing Integration</strong>
    <ul>
      <li>Native WordPress blogging (best CMS for content)</li>
      <li>Seamless content + commerce integration</li>
      <li>Superior SEO-focused content tools</li>
    </ul>
  </li>
  <li><strong>Product Flexibility</strong>
    <ul>
      <li>Unlimited product variations (Shopify limits to 100 per product)</li>
      <li>Custom product types via plugins</li>
      <li>Advanced product filtering</li>
    </ul>
  </li>
  <li><strong>Membership &amp; Subscriptions</strong>
    <ul>
      <li>Better native membership plugins (MemberPress, Restrict Content Pro)</li>
      <li>More flexible subscription models</li>
    </ul>
  </li>
  <li><strong>Payment Gateway Options</strong>
    <ul>
      <li>100+ payment gateways available</li>
      <li>No transaction fees regardless of gateway choice</li>
      <li>Complete control over checkout process</li>
    </ul>
  </li>
</ol>

<h3 id="where-shopify-excels">Where Shopify Excels</h3>

<ol>
  <li><strong>Abandoned Cart Recovery</strong>
    <ul>
      <li>Built-in abandoned cart emails (WooCommerce requires paid plugin)</li>
      <li>Better automation and segmentation</li>
    </ul>
  </li>
  <li><strong>Multi-Channel Selling</strong>
    <ul>
      <li>Native Facebook Shop integration</li>
      <li>Instagram Shopping built-in</li>
      <li>Amazon and eBay integrations</li>
      <li>Point-of-sale (POS) system for physical retail</li>
    </ul>
  </li>
  <li><strong>Inventory Management</strong>
    <ul>
      <li>More sophisticated multi-location inventory</li>
      <li>Better bulk editing tools</li>
      <li>Automatic SKU generation</li>
    </ul>
  </li>
  <li><strong>Mobile App</strong>
    <ul>
      <li>Native Shopify mobile app for managing orders on-the-go</li>
      <li>Better mobile admin experience</li>
    </ul>
  </li>
</ol>

<p><strong>Winner:</strong> Tie - depends on your specific needs.</p>

<hr />

<h2 id="flexibility--customization">Flexibility &amp; Customization</h2>

<h3 id="woocommerce-flexibility-1010">WooCommerce Flexibility: 10/10</h3>

<p><strong>Complete Control:</strong></p>
<ul>
  <li>Access to all source code (it’s open-source)</li>
  <li>Modify any functionality via hooks and filters</li>
  <li>Create completely custom checkout flows</li>
  <li>Build unique product types</li>
  <li>Integrate with any third-party system</li>
</ul>

<p><strong>Example Custom Solutions We’ve Built:</strong></p>
<ul>
  <li>Multi-vendor marketplaces</li>
  <li>Custom quote request systems</li>
  <li>Complex B2B pricing rules</li>
  <li>Subscription boxes with customization options</li>
  <li>Integration with proprietary ERP systems</li>
</ul>

<p><strong>Reality Check:</strong> Customization requires developer expertise or hiring a developer.</p>

<h3 id="shopify-flexibility-610">Shopify Flexibility: 6/10</h3>

<p><strong>What You Can Customize:</strong></p>
<ul>
  <li>Theme design (via Liquid templating language)</li>
  <li>Add functionality via Shopify Apps</li>
  <li>Custom fields and metafields</li>
  <li>Some checkout customization (Shopify Plus only)</li>
</ul>

<p><strong>Limitations:</strong></p>
<ul>
  <li>No access to core platform code</li>
  <li>Checkout page heavily locked down (except Shopify Plus)</li>
  <li>Must work within Shopify’s app ecosystem</li>
  <li>Some integrations require Shopify Plus (expensive)</li>
</ul>

<p><strong>Reality Check:</strong> 90% of stores can work within Shopify’s limitations. But if you need something truly custom, you’ll hit walls.</p>

<p><strong>Winner:</strong> WooCommerce (by a large margin).</p>

<hr />

<h2 id="performance--speed">Performance &amp; Speed</h2>

<h3 id="woocommerce-performance">WooCommerce Performance</h3>

<p><strong>Depends entirely on your hosting:</strong></p>

<p><strong>Shared Hosting (Bad):</strong></p>
<ul>
  <li>❌ Slow (3-5+ second load times)</li>
  <li>❌ Struggles with traffic spikes</li>
  <li>❌ Limited resources</li>
</ul>

<p><strong>Managed WordPress Hosting (Good):</strong></p>
<ul>
  <li>✅ Fast (1-2 second load times)</li>
  <li>✅ Handle moderate traffic</li>
  <li>✅ Server-level caching</li>
</ul>

<p><strong>Premium VPS with Trellis (Excellent):</strong></p>
<ul>
  <li>✅ Sub-1 second load times possible</li>
  <li>✅ Handle high traffic</li>
  <li>✅ Complete performance control</li>
  <li>✅ Micro-caching at Nginx level</li>
</ul>

<p><strong>At <a href="https://imagewize.com/services/">Imagewize</a>, we achieve sub-1s load times</strong> on WooCommerce stores using Trellis stack (Nginx + PHP 8.3 + Redis + micro-caching).</p>

<h3 id="shopify-performance">Shopify Performance</h3>

<p><strong>Consistent &amp; Reliable:</strong></p>
<ul>
  <li>✅ Fast global CDN (content delivery network)</li>
  <li>✅ Automatic image optimization</li>
  <li>✅ No server management needed</li>
  <li>✅ Handles traffic spikes automatically</li>
  <li>✅ Average load time: 1.5-2.5 seconds</li>
</ul>

<p><strong>Limitations:</strong></p>
<ul>
  <li>Limited control over server-level optimization</li>
  <li>Can’t implement advanced caching strategies</li>
  <li>App bloat can slow down stores</li>
</ul>

<p><strong>Winner:</strong> Shopify for consistency and ease. WooCommerce for peak performance (if properly configured).</p>

<hr />

<h2 id="seo-capabilities">SEO Capabilities</h2>

<h3 id="woocommerce-seo-1010">WooCommerce SEO: 10/10</h3>

<p><strong>Built on WordPress (the best SEO platform):</strong></p>
<ul>
  <li>✅ Complete control over URLs, meta tags, schema markup</li>
  <li>✅ Superior blogging capabilities (content is king for SEO)</li>
  <li>✅ Best SEO plugins: Yoast, Rank Math, The SEO Framework</li>
  <li>✅ Full control over site structure and internal linking</li>
  <li>✅ Better pagination and filtering options</li>
  <li>✅ Can optimize every element for speed (major ranking factor)</li>
</ul>

<p><strong>SEO Advantages:</strong></p>
<ul>
  <li>Create category hierarchies and breadcrumbs exactly how you want</li>
  <li>Unlimited blog posts integrated with products</li>
  <li>Complete schema markup control</li>
  <li>Better handling of product variations for SEO</li>
</ul>

<h3 id="shopify-seo-710">Shopify SEO: 7/10</h3>

<p><strong>Good, But Limitations:</strong></p>
<ul>
  <li>✅ Clean URL structure</li>
  <li>✅ Built-in SSL</li>
  <li>✅ Fast load times (CDN)</li>
  <li>✅ Mobile-responsive themes</li>
  <li>❌ Limited blogging features (basic at best)</li>
  <li>❌ Forced <code class="language-plaintext highlighter-rouge">/products/</code> and <code class="language-plaintext highlighter-rouge">/collections/</code> URLs</li>
  <li>❌ Can’t fully customize URL structure</li>
  <li>❌ Limited schema markup customization</li>
</ul>

<p><strong>Shopify SEO Challenges:</strong></p>
<ul>
  <li>Duplicate content issues (product/collection pages)</li>
  <li>Less control over meta tags</li>
  <li>Blogging is weak compared to WordPress</li>
  <li>Can’t remove <code class="language-plaintext highlighter-rouge">/collections/</code> from URLs without apps</li>
</ul>

<p><strong>Winner:</strong> WooCommerce (significantly better for content-driven SEO).</p>

<hr />

<h2 id="ease-of-use">Ease of Use</h2>

<h3 id="shopify-910">Shopify: 9/10</h3>

<p><strong>Setup Time:</strong> 1-2 days to launch a basic store</p>

<p><strong>Pros:</strong></p>
<ul>
  <li>✅ Incredibly intuitive interface</li>
  <li>✅ Step-by-step onboarding</li>
  <li>✅ No technical knowledge required</li>
  <li>✅ Everything in one dashboard</li>
  <li>✅ Automatic updates (no maintenance)</li>
</ul>

<p><strong>Cons:</strong></p>
<ul>
  <li>❌ Feels limited once you outgrow basics</li>
  <li>❌ App bloat can make things confusing</li>
</ul>

<h3 id="woocommerce-610">WooCommerce: 6/10</h3>

<p><strong>Setup Time:</strong> 1-2 weeks to launch properly (or hire a developer)</p>

<p><strong>Pros:</strong></p>
<ul>
  <li>✅ WordPress familiarity (if you know WordPress)</li>
  <li>✅ Extensive documentation and tutorials</li>
  <li>✅ Huge community for support</li>
</ul>

<p><strong>Cons:</strong></p>
<ul>
  <li>❌ Steeper learning curve</li>
  <li>❌ More moving parts (hosting, security, updates)</li>
  <li>❌ Requires ongoing maintenance</li>
  <li>❌ Can be overwhelming for beginners</li>
</ul>

<p><strong>Winner:</strong> Shopify (much easier, especially for non-technical users).</p>

<hr />

<h2 id="payment-processing">Payment Processing</h2>

<h3 id="woocommerce-1">WooCommerce</h3>

<p><strong>Payment Gateways:</strong></p>
<ul>
  <li>100+ gateway options (Stripe, PayPal, Square, Mollie, etc.)</li>
  <li><strong>Zero transaction fees</strong> (just gateway fees: ~1.5-3%)</li>
  <li>Complete checkout customization</li>
  <li>Can integrate any payment system</li>
</ul>

<p><strong>Best Practice:</strong> Use Stripe (1.5% + €0.25 per transaction in Europe)</p>

<h3 id="shopify-1">Shopify</h3>

<p><strong>Shopify Payments (Recommended):</strong></p>
<ul>
  <li>1.6-1.9% per transaction (varies by plan)</li>
  <li>No additional Shopify transaction fee</li>
  <li>Built-in, seamless</li>
</ul>

<p><strong>Third-Party Gateways (PayPal, Stripe, etc.):</strong></p>
<ul>
  <li>1.5-3% gateway fee</li>
  <li><strong>+ 0.5-2% Shopify transaction fee</strong> (this hurts!)</li>
</ul>

<p><strong>Real Cost Example (€10,000/month sales):</strong></p>
<ul>
  <li>WooCommerce + Stripe: €150-€300 fees</li>
  <li>Shopify Basic + PayPal: €200-€500 fees (gateway + Shopify fees)</li>
  <li>Shopify + Shopify Payments: €160-€190 fees</li>
</ul>

<p><strong>Winner:</strong> WooCommerce (no platform transaction fees = lower costs).</p>

<hr />

<h2 id="support--maintenance">Support &amp; Maintenance</h2>

<h3 id="shopify-support">Shopify Support</h3>

<p><strong>Official Support:</strong></p>
<ul>
  <li>✅ 24/7 live chat, email, phone support</li>
  <li>✅ Extensive documentation</li>
  <li>✅ Active community forums</li>
  <li>✅ Shopify handles all technical issues (hosting, security, uptime)</li>
</ul>

<p><strong>Maintenance:</strong></p>
<ul>
  <li>✅ Automatic platform updates</li>
  <li>✅ Automatic security patches</li>
  <li>✅ Zero server maintenance</li>
</ul>

<h3 id="woocommerce-support">WooCommerce Support</h3>

<p><strong>Official Support:</strong></p>
<ul>
  <li>❌ No official phone/chat support (it’s free, open-source)</li>
  <li>✅ Extensive documentation</li>
  <li>✅ Huge community (forums, Facebook groups, Stack Overflow)</li>
</ul>

<p><strong>Maintenance:</strong></p>
<ul>
  <li>❌ Manual WordPress, plugin, and theme updates</li>
  <li>❌ Responsible for site security</li>
  <li>❌ Hosting maintenance (backups, server updates)</li>
</ul>

<p><strong>Solution:</strong> Hire a <a href="https://imagewize.com/services/">WordPress maintenance service</a> (€100-€300/month)</p>

<p><strong>Winner:</strong> Shopify (hands-down better support and zero maintenance).</p>

<hr />

<h2 id="real-world-use-cases">Real-World Use Cases</h2>

<h3 id="when-we-recommend-woocommerce">When We Recommend WooCommerce</h3>

<p><strong>1. Content-Heavy Stores</strong></p>
<ul>
  <li>Businesses that blog regularly for SEO</li>
  <li>Example: Organic skincare brand with 100+ educational blog posts</li>
</ul>

<p><strong>2. Complex Product Catalogs</strong></p>
<ul>
  <li>Stores with 100+ product variations per item</li>
  <li>Example: Custom furniture store with 20+ wood types × 15+ finishes</li>
</ul>

<p><strong>3. B2B E-Commerce</strong></p>
<ul>
  <li>Wholesale pricing, quote requests, custom pricing rules</li>
  <li>Example: Industrial equipment supplier with tiered pricing</li>
</ul>

<p><strong>4. Custom Integrations</strong></p>
<ul>
  <li>Need to connect to proprietary ERP/CRM systems</li>
  <li>Example: Manufacturing company syncing inventory with production system</li>
</ul>

<p><strong>5. Long-Term Cost Savings</strong></p>
<ul>
  <li>High-volume stores (€500k+/year) wanting to minimize fees</li>
  <li>Example: Fashion retailer processing 10,000+ orders/year</li>
</ul>

<h3 id="when-we-recommend-shopify">When We Recommend Shopify</h3>

<p><strong>1. Quick Market Testing</strong></p>
<ul>
  <li>Need to launch fast and test product-market fit</li>
  <li>Example: Dropshipping business testing new niches</li>
</ul>

<p><strong>2. Multi-Channel Selling</strong></p>
<ul>
  <li>Selling on Instagram, Facebook, Amazon simultaneously</li>
  <li>Example: Fashion brand selling everywhere their audience is</li>
</ul>

<p><strong>3. Physical + Online Retail</strong></p>
<ul>
  <li>Need point-of-sale (POS) integration</li>
  <li>Example: Boutique with both physical store and online shop</li>
</ul>

<p><strong>4. Non-Technical Founders</strong></p>
<ul>
  <li>Don’t want to deal with technical details</li>
  <li>Example: Artist selling prints who wants to focus on creating, not tech</li>
</ul>

<p><strong>5. Subscription Boxes (Simple)</strong></p>
<ul>
  <li>Straightforward recurring billing</li>
  <li>Example: Monthly snack box subscription</li>
</ul>

<hr />

<h2 id="migration-between-platforms">Migration Between Platforms</h2>

<h3 id="shopify--woocommerce">Shopify → WooCommerce</h3>

<p><strong>Difficulty:</strong> Moderate</p>

<p><strong>What Transfers:</strong></p>
<ul>
  <li>✅ Products (title, description, images, variants)</li>
  <li>✅ Customers (name, email, address)</li>
  <li>✅ Orders (historical data)</li>
</ul>

<p><strong>What Doesn’t:</strong></p>
<ul>
  <li>❌ Theme design (need to rebuild)</li>
  <li>❌ App functionality (need to find WooCommerce equivalents)</li>
  <li>❌ URL structure (will need redirects for SEO)</li>
</ul>

<p><strong>Tools:</strong></p>
<ul>
  <li>Cart2Cart migration service (€69-€199)</li>
  <li>LitExtension migration tool</li>
  <li>Manual CSV export/import</li>
</ul>

<p><strong>Timeframe:</strong> 2-4 weeks for full migration</p>

<h3 id="woocommerce--shopify">WooCommerce → Shopify</h3>

<p><strong>Difficulty:</strong> Easy</p>

<p><strong>What Transfers:</strong></p>
<ul>
  <li>✅ Products</li>
  <li>✅ Customers</li>
  <li>✅ Orders</li>
</ul>

<p><strong>What Doesn’t:</strong></p>
<ul>
  <li>❌ Custom functionality (will need to find Shopify apps)</li>
  <li>❌ Blog posts (need manual migration)</li>
  <li>❌ Custom checkout (Shopify checkout is locked)</li>
</ul>

<p><strong>Tools:</strong></p>
<ul>
  <li>Shopify’s built-in WooCommerce importer</li>
  <li>Cart2Cart</li>
</ul>

<p><strong>Timeframe:</strong> 1-2 weeks</p>

<hr />

<h2 id="final-verdict">Final Verdict</h2>

<p>There’s no universal “better” platform. The right choice depends on your specific situation:</p>

<h3 id="choose-woocommerce-if-1">Choose WooCommerce If:</h3>
<ul>
  <li>You want maximum flexibility and control</li>
  <li>You have (or can hire) technical expertise</li>
  <li>You plan to scale to high volume (cost savings at scale)</li>
  <li>Content marketing and SEO are critical</li>
  <li>You need deep customization</li>
  <li>You prefer ownership over convenience</li>
</ul>

<p><strong>Best For:</strong> Growing businesses, content-driven brands, B2B, complex catalogs</p>

<p><strong>Get Started:</strong> <a href="https://imagewize.com/services/e-commerce/woocommerce/">Imagewize WooCommerce development</a> (custom WooCommerce stores from €999)</p>

<hr />

<h3 id="choose-shopify-if-1">Choose Shopify If:</h3>
<ul>
  <li>You want fast, easy setup with minimal technical work</li>
  <li>You value convenience and 24/7 support</li>
  <li>You’re selling on multiple channels (Instagram, Facebook, etc.)</li>
  <li>You have a physical store and need POS integration</li>
  <li>You don’t want to manage hosting/security/updates</li>
  <li>You’re okay with higher ongoing costs for simplicity</li>
</ul>

<p><strong>Best For:</strong> Quick launches, dropshipping, multi-channel retailers, non-technical founders</p>

<p><strong>Get Started:</strong> <a href="https://imagewize.com/shopify/">Imagewize Shopify services</a> (Shopify store setup and optimization from €999)</p>

<hr />

<h2 id="hybrid-approach-advanced">Hybrid Approach (Advanced)</h2>

<p>Some businesses use <strong>both platforms</strong>:</p>
<ul>
  <li><strong>WordPress/WooCommerce</strong> for main website + blog (SEO)</li>
  <li><strong>Shopify</strong> for e-commerce transactions (ease of use)</li>
</ul>

<p>This works via:</p>
<ul>
  <li>Buy Button integration (embed Shopify checkout on WordPress)</li>
  <li>Headless commerce (WordPress frontend + Shopify backend)</li>
</ul>

<p><strong>Complexity:</strong> High, but offers best of both worlds.</p>

<hr />

<h2 id="need-help-deciding">Need Help Deciding?</h2>

<p>Choosing the right e-commerce platform is a big decision. At <a href="https://imagewize.com">Imagewize</a>, we build stores on both WooCommerce and Shopify and can help you make the right choice for your business.</p>

<h3 id="our-e-commerce-services">Our E-Commerce Services:</h3>

<p><strong>WooCommerce Development:</strong></p>
<ul>
  <li>Custom WooCommerce store setup (from €999)</li>
  <li>Speed optimization for WooCommerce</li>
  <li>Custom checkout flows and product types</li>
  <li>WooCommerce maintenance (from €100/month)</li>
</ul>

<p><strong>Shopify Development:</strong></p>
<ul>
  <li>Shopify store setup and theme customization (from €999)</li>
  <li>App integrations and custom functionality</li>
  <li>Shopify speed optimization</li>
  <li>Migration from other platforms</li>
</ul>

<p><strong><a href="https://imagewize.com/contact-us/">Contact us for a free consultation</a></strong> - we’ll analyze your needs and recommend the best platform for your business.</p>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p><strong>WooCommerce</strong> = Flexibility, control, lower long-term costs, better SEO (but requires technical work)</p>

<p><strong>Shopify</strong> = Simplicity, speed, excellent support, multi-channel selling (but higher ongoing costs)</p>

<p>For most SMEs serious about scaling and content marketing, <strong>WooCommerce is the better long-term choice</strong>. But if you need to launch quickly without technical help, <strong>Shopify is hard to beat</strong>.</p>

<p>Still unsure? <strong><a href="https://imagewize.com/contact-us/">Talk to our team</a></strong> - we’ll help you choose.</p>

<hr />

<p><strong>About the Author:</strong> Jasper Frumau is the founder of <a href="https://imagewize.com">Imagewize</a>, a WordPress and Shopify development agency specializing in e-commerce for SMEs. With 15+ years of experience building 100+ online stores, he helps businesses choose and implement the right e-commerce platform.</p>]]></content><author><name></name></author><category term="ecommerce" /><category term="woocommerce" /><category term="shopify" /><category term="woocommerce" /><category term="shopify" /><category term="ecommerce-platforms" /><category term="comparison" /><category term="sme-ecommerce" /><summary type="html"><![CDATA[Choosing between WooCommerce and Shopify? This comprehensive comparison covers costs, features, flexibility, and real-world use cases to help SMEs make the right decision in 2025.]]></summary></entry><entry><title type="html">WordPress Speed Optimization Guide 2025: Proven Techniques to Achieve Sub-1 Second Load Times</title><link href="https://wpvilla.in/wordpress-speed-optimization-guide-2025/" rel="alternate" type="text/html" title="WordPress Speed Optimization Guide 2025: Proven Techniques to Achieve Sub-1 Second Load Times" /><published>2025-11-25T09:00:00+00:00</published><updated>2025-11-25T09:00:00+00:00</updated><id>https://wpvilla.in/wordpress-speed-optimization-guide-2025</id><content type="html" xml:base="https://wpvilla.in/wordpress-speed-optimization-guide-2025/"><![CDATA[<p>If your WordPress site takes more than 2 seconds to load, you’re losing visitors and potential customers. In 2025, speed isn’t just a nice-to-have—it’s a business requirement.</p>

<p>After optimizing 100+ WordPress sites for SMEs over the past 15 years, I’ve identified the exact techniques that make the biggest impact on page load times. This guide shares the proven strategies we use at <a href="https://imagewize.com/speed-optimization/">Imagewize</a> to consistently achieve sub-1 second load times.</p>

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#why-wordpress-speed-matters">Why WordPress Speed Matters</a></li>
  <li><a href="#core-web-vitals-the-metrics-that-matter">Core Web Vitals: The Metrics That Matter</a></li>
  <li><a href="#the-10-most-impactful-speed-optimizations">The 10 Most Impactful Speed Optimizations</a></li>
  <li><a href="#advanced-speed-optimizations-expert-level">Advanced Speed Optimizations (Expert Level)</a></li>
  <li><a href="#real-world-results">Real-World Results</a></li>
  <li><a href="#tools-for-measuring-speed">Tools for Measuring Speed</a></li>
  <li><a href="#need-professional-help">Need Professional Help?</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
</ol>

<hr />

<h2 id="why-wordpress-speed-matters">Why WordPress Speed Matters</h2>

<p>Before diving into the technical optimizations, let’s understand why speed is critical for your business:</p>

<h3 id="business-impact">Business Impact</h3>
<ul>
  <li><strong>40% of visitors abandon</strong> sites that take more than 3 seconds to load</li>
  <li><strong>1-second delay = 7% reduction</strong> in conversions</li>
  <li><strong>Google uses speed as a ranking factor</strong> (Core Web Vitals)</li>
  <li><strong>Faster sites = better user experience</strong> = more engaged visitors</li>
</ul>

<h3 id="seo-impact">SEO Impact</h3>
<p>Since Google’s Page Experience Update, Core Web Vitals are now part of search rankings. A slow site directly impacts your visibility in search results.</p>

<hr />

<h2 id="core-web-vitals-the-metrics-that-matter">Core Web Vitals: The Metrics That Matter</h2>

<p>Google measures three key performance metrics:</p>

<h3 id="1-largest-contentful-paint-lcp">1. Largest Contentful Paint (LCP)</h3>
<p><strong>Target: &lt; 2.5 seconds</strong></p>

<p>LCP measures how long it takes for the main content to load. This is typically your hero image or largest text block.</p>

<p><strong>Common issue:</strong> Lazy-loading your hero image causes 2+ second delays.</p>

<h3 id="2-cumulative-layout-shift-cls">2. Cumulative Layout Shift (CLS)</h3>
<p><strong>Target: &lt; 0.1</strong></p>

<p>CLS measures visual stability. Text shouldn’t shift around while the page loads.</p>

<p><strong>Common issue:</strong> Fonts loading late causes text to reflow and shift layout.</p>

<h3 id="3-first-input-delay-fid--interaction-to-next-paint-inp">3. First Input Delay (FID) / Interaction to Next Paint (INP)</h3>
<p><strong>Target: &lt; 100ms (FID) / &lt; 200ms (INP)</strong></p>

<p>Measures how quickly your site responds to user interactions.</p>

<p><strong>Common issue:</strong> Large JavaScript bundles block the main thread.</p>

<hr />

<h2 id="the-10-most-impactful-speed-optimizations">The 10 Most Impactful Speed Optimizations</h2>

<p>Based on real-world implementations from our client projects, here are the optimizations that deliver the biggest improvements:</p>

<h3 id="1-eager-load-your-lcp-image--impact-23-seconds-saved">1. <strong>Eager Load Your LCP Image</strong> ⚡ Impact: ~2.3 seconds saved</h3>

<p><strong>The Problem:</strong>
By default, browsers lazy-load all images. If your hero image is the Largest Contentful Paint (LCP), lazy-loading adds massive delay.</p>

<p><strong>The Solution:</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Add to your hero image --&gt;</span>
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"hero.jpg"</span>
     <span class="na">loading=</span><span class="s">"eager"</span>
     <span class="na">fetchpriority=</span><span class="s">"high"</span>
     <span class="na">alt=</span><span class="s">"Your hero image"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p><strong>Real Result:</strong> We reduced LCP from 3.8s to 1.4s on a client site just by adding these two attributes.</p>

<p><strong>Reference:</strong> <a href="https://github.com/imagewize/nynaeve/blob/main/CHANGELOG.md#2017---2025-11-24">Nynaeve CHANGELOG v2.0.17</a></p>

<hr />

<h3 id="2-preload-critical-fonts--impact-cls-from-0602---01">2. <strong>Preload Critical Fonts</strong> ⚡ Impact: CLS from 0.602 → &lt; 0.1</h3>

<p><strong>The Problem:</strong>
When custom fonts load late, text renders in fallback fonts first, then “jumps” when the real font loads. This creates layout shift.</p>

<p><strong>The Solution:</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Add to &lt;head&gt; before any CSS --&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"preload"</span>
      <span class="na">href=</span><span class="s">"/fonts/open-sans-regular.woff2"</span>
      <span class="na">as=</span><span class="s">"font"</span>
      <span class="na">type=</span><span class="s">"font/woff2"</span>
      <span class="na">crossorigin</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"preload"</span>
      <span class="na">href=</span><span class="s">"/fonts/open-sans-semibold.woff2"</span>
      <span class="na">as=</span><span class="s">"font"</span>
      <span class="na">type=</span><span class="s">"font/woff2"</span>
      <span class="na">crossorigin</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p><strong>Why It Works:</strong> Fonts start downloading immediately, before CSS is parsed. Text renders with correct fonts from the start = zero layout shift.</p>

<p><strong>Reference:</strong> <a href="https://github.com/imagewize/nynaeve/blob/main/CHANGELOG.md#2017---2025-11-24">Nynaeve CHANGELOG v2.0.17</a></p>

<hr />

<h3 id="3-async-load-non-critical-css--impact-300-400ms-render-blocking-reduction">3. <strong>Async-Load Non-Critical CSS</strong> ⚡ Impact: ~300-400ms render-blocking reduction</h3>

<p><strong>The Problem:</strong>
CSS files block rendering. The browser won’t display anything until all CSS is downloaded and parsed.</p>

<p><strong>The Solution:</strong>
Make non-critical CSS non-render-blocking using the <code class="language-plaintext highlighter-rouge">media='print' onload</code> technique:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// WordPress filter to async-load CSS</span>
<span class="nf">add_filter</span><span class="p">(</span><span class="s1">'style_loader_tag'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="nv">$html</span><span class="p">,</span> <span class="nv">$handle</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// List of non-critical CSS handles</span>
    <span class="nv">$async_styles</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s1">'wp-block-library'</span><span class="p">,</span>        <span class="c1">// WordPress core blocks</span>
        <span class="s1">'woocommerce-layout'</span><span class="p">,</span>      <span class="c1">// WooCommerce styles</span>
        <span class="s1">'woocommerce-general'</span><span class="p">,</span>
        <span class="s1">'slick-carousel'</span>           <span class="c1">// Third-party libraries</span>
    <span class="p">];</span>

    <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$handle</span><span class="p">,</span> <span class="nv">$async_styles</span><span class="p">))</span> <span class="p">{</span>
        <span class="c1">// Change media to 'print' then swap to 'all' on load</span>
        <span class="nv">$html</span> <span class="o">=</span> <span class="nb">str_replace</span><span class="p">(</span><span class="s2">"media='all'"</span><span class="p">,</span> <span class="s2">"media='print' onload=</span><span class="se">\"</span><span class="s2">this.media='all'</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span> <span class="nv">$html</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$html</span><span class="p">;</span>
<span class="p">},</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Real Result:</strong> Reduced render-blocking time from 1.2s to 0.8s by async-loading 15+ stylesheet files.</p>

<p><strong>Reference:</strong> <a href="https://github.com/imagewize/nynaeve/blob/main/CHANGELOG.md#2016---2025-11-24">Nynaeve CHANGELOG v2.0.16</a></p>

<p><strong>⚠️ Warning:</strong> Never async-load stylesheets with specific media queries (like <code class="language-plaintext highlighter-rouge">max-width: 768px</code>). It breaks responsive behavior.</p>

<hr />

<h3 id="4-serve-responsive-images--impact-50-70-kib-per-image-saved">4. <strong>Serve Responsive Images</strong> ⚡ Impact: ~50-70 KiB per image saved</h3>

<p><strong>The Problem:</strong>
Serving full-resolution images to mobile devices wastes bandwidth and slows load times.</p>

<p><strong>The Solution:</strong>
Use WordPress’s built-in responsive image system with <code class="language-plaintext highlighter-rouge">srcset</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Register custom image sizes for different viewports</span>
<span class="nf">add_action</span><span class="p">(</span><span class="s1">'after_setup_theme'</span><span class="p">,</span> <span class="k">function</span><span class="p">()</span> <span class="p">{</span>
    <span class="nf">add_image_size</span><span class="p">(</span><span class="s1">'hero-desktop'</span><span class="p">,</span> <span class="mi">600</span><span class="p">,</span> <span class="mi">348</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>      <span class="c1">// 1x</span>
    <span class="nf">add_image_size</span><span class="p">(</span><span class="s1">'hero-desktop-2x'</span><span class="p">,</span> <span class="mi">1200</span><span class="p">,</span> <span class="mi">696</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>  <span class="c1">// 2x retina</span>
    <span class="nf">add_image_size</span><span class="p">(</span><span class="s1">'hero-mobile'</span><span class="p">,</span> <span class="mi">400</span><span class="p">,</span> <span class="mi">232</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>       <span class="c1">// Mobile</span>
<span class="p">});</span>

<span class="c1">// Use wp_get_attachment_image() for automatic srcset</span>
<span class="k">echo</span> <span class="nf">wp_get_attachment_image</span><span class="p">(</span><span class="nv">$image_id</span><span class="p">,</span> <span class="s1">'hero-desktop'</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="p">[</span>
    <span class="s1">'loading'</span> <span class="o">=&gt;</span> <span class="s1">'eager'</span><span class="p">,</span>
    <span class="s1">'fetchpriority'</span> <span class="o">=&gt;</span> <span class="s1">'high'</span>
<span class="p">]);</span>
</code></pre></div></div>

<p><strong>Real Result:</strong> Hero image size dropped from 69.8 KiB to 19.4 KiB on mobile devices.</p>

<p><strong>Reference:</strong> <a href="https://github.com/imagewize/nynaeve/blob/main/CHANGELOG.md#2015---2025-11-24">Nynaeve CHANGELOG v2.0.15</a></p>

<hr />

<h3 id="5-optimize-image-formats--impact-60-80-file-size-reduction">5. <strong>Optimize Image Formats</strong> ⚡ Impact: 60-80% file size reduction</h3>

<p><strong>The Problem:</strong>
JPEGs and PNGs are outdated formats. WebP and AVIF offer far better compression.</p>

<p><strong>The Solution:</strong></p>
<ul>
  <li><strong>Use WebP</strong> for all photos (supported in all modern browsers)</li>
  <li><strong>Use AVIF</strong> for even better compression (when supported)</li>
  <li><strong>Lazy-load below-the-fold images</strong> (but NEVER your hero image!)</li>
</ul>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"image.webp"</span>
     <span class="na">loading=</span><span class="s">"lazy"</span>
     <span class="na">alt=</span><span class="s">"Product photo"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p><strong>Tools:</strong></p>
<ul>
  <li><strong>ImageMagick</strong> for command-line conversions</li>
  <li><strong>ShortPixel</strong> or <strong>Imagify</strong> WordPress plugins for automatic conversion</li>
  <li><strong>Cloudflare</strong> for automatic format delivery</li>
</ul>

<hr />

<h3 id="6-use-a-modern-wordpress-stack--impact-40-faster-server-response">6. <strong>Use a Modern WordPress Stack</strong> ⚡ Impact: ~40% faster server response</h3>

<p><strong>The Problem:</strong>
Traditional shared hosting with Apache and no object caching is slow. Each page request hits the database multiple times.</p>

<p><strong>The Solution:</strong>
Use a modern WordPress stack like <strong>Trellis</strong> (what we use at Imagewize):</p>

<p><strong>Stack Components:</strong></p>
<ul>
  <li><strong>Nginx</strong> instead of Apache (faster static file serving)</li>
  <li><strong>PHP 8.3+</strong> with OPcache (compiled PHP code, not interpreted)</li>
  <li><strong>Redis</strong> or <strong>Memcached</strong> for object caching (reduces database queries)</li>
  <li><strong>HTTP/2</strong> for multiplexed connections</li>
  <li><strong>Micro-caching</strong> at Nginx level (serves cached HTML for 1-5 seconds)</li>
</ul>

<p><strong>Real Result:</strong> Time to First Byte (TTFB) dropped from 800ms to 200ms on a WooCommerce site.</p>

<p><strong>Learn More:</strong> <a href="https://imagewize.com/services/">Imagewize Premium Hosting</a> offers Trellis-based VPS hosting starting at €79/month.</p>

<hr />

<h3 id="7-minimize-plugin-bloat--impact-varies-can-save-500ms">7. <strong>Minimize Plugin Bloat</strong> ⚡ Impact: Varies (can save 500ms+)</h3>

<p><strong>The Problem:</strong>
Every plugin adds CSS, JavaScript, and database queries. More plugins = slower site.</p>

<p><strong>The Solution:</strong></p>
<ul>
  <li><strong>Audit your plugins</strong> - Remove anything you’re not actively using</li>
  <li><strong>Combine functionality</strong> - Find plugins that do multiple things instead of single-purpose ones</li>
  <li><strong>Disable plugin assets on pages where they’re not needed</strong> using <a href="https://wordpress.org/plugins/wp-asset-clean-up/">Asset CleanUp</a> or <a href="https://perfmatters.io/">Perfmatters</a></li>
</ul>

<p><strong>Example:</strong>
If you only use a contact form on <code class="language-plaintext highlighter-rouge">/contact/</code>, disable that plugin’s CSS/JS on all other pages.</p>

<hr />

<h3 id="8-defer-non-critical-javascript--impact-200-500ms-saved">8. <strong>Defer Non-Critical JavaScript</strong> ⚡ Impact: ~200-500ms saved</h3>

<p><strong>The Problem:</strong>
JavaScript blocks HTML parsing. The browser can’t render content until JS is downloaded and executed.</p>

<p><strong>The Solution:</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Defer scripts that don't need to run immediately --&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"analytics.js"</span> <span class="na">defer</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"tracking.js"</span> <span class="na">defer</span><span class="nt">&gt;&lt;/script&gt;</span>

<span class="c">&lt;!-- Async for scripts that can run independently --&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"ads.js"</span> <span class="na">async</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p><strong>WordPress Implementation:</strong></p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">add_filter</span><span class="p">(</span><span class="s1">'script_loader_tag'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="nv">$tag</span><span class="p">,</span> <span class="nv">$handle</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$defer_scripts</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'google-analytics'</span><span class="p">,</span> <span class="s1">'facebook-pixel'</span><span class="p">];</span>

    <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$handle</span><span class="p">,</span> <span class="nv">$defer_scripts</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nb">str_replace</span><span class="p">(</span><span class="s1">' src'</span><span class="p">,</span> <span class="s1">' defer src'</span><span class="p">,</span> <span class="nv">$tag</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$tag</span><span class="p">;</span>
<span class="p">},</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
</code></pre></div></div>

<hr />

<h3 id="9-enable-gzipbrotli-compression--impact-70-90-file-size-reduction">9. <strong>Enable Gzip/Brotli Compression</strong> ⚡ Impact: 70-90% file size reduction</h3>

<p><strong>The Problem:</strong>
Transferring uncompressed HTML, CSS, and JavaScript wastes bandwidth and time.</p>

<p><strong>The Solution:</strong>
Enable server-side compression in your <code class="language-plaintext highlighter-rouge">.htaccess</code> (Apache) or Nginx config:</p>

<p><strong>Nginx (Trellis):</strong></p>
<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gzip compression</span>
<span class="k">gzip</span> <span class="no">on</span><span class="p">;</span>
<span class="k">gzip_vary</span> <span class="no">on</span><span class="p">;</span>
<span class="k">gzip_comp_level</span> <span class="mi">6</span><span class="p">;</span>
<span class="k">gzip_types</span> <span class="nc">text/plain</span> <span class="nc">text/css</span> <span class="nc">text/xml</span> <span class="nc">text/javascript</span>
           <span class="nc">application/json</span> <span class="nc">application/javascript</span> <span class="nc">application/xml</span><span class="s">+rss</span><span class="p">;</span>

<span class="c1"># Brotli compression (even better than gzip)</span>
<span class="k">brotli</span> <span class="no">on</span><span class="p">;</span>
<span class="k">brotli_comp_level</span> <span class="mi">6</span><span class="p">;</span>
<span class="k">brotli_types</span> <span class="nc">text/plain</span> <span class="nc">text/css</span> <span class="nc">text/xml</span> <span class="nc">text/javascript</span>
             <span class="nc">application/json</span> <span class="nc">application/javascript</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Result:</strong> A 200 KiB HTML page compresses to ~30 KiB with Brotli.</p>

<hr />

<h3 id="10-use-a-cdn--impact-100-300ms-faster-global-delivery">10. <strong>Use a CDN</strong> ⚡ Impact: ~100-300ms faster global delivery</h3>

<p><strong>The Problem:</strong>
Serving assets from a single server in one location means slow delivery to distant visitors.</p>

<p><strong>The Solution:</strong>
Use a Content Delivery Network (CDN) to serve static assets (images, CSS, JS) from servers close to your visitors:</p>

<p><strong>Recommended CDNs:</strong></p>
<ul>
  <li><strong>Cloudflare</strong> (free tier available, easy setup)</li>
  <li><strong>Bunny CDN</strong> (cheap, fast, privacy-focused)</li>
  <li><strong>KeyCDN</strong> (WordPress integration)</li>
</ul>

<p><strong>Cloudflare Bonus:</strong> Also provides automatic image optimization, WebP conversion, and DDoS protection.</p>

<hr />

<h2 id="advanced-speed-optimizations-expert-level">Advanced Speed Optimizations (Expert Level)</h2>

<p>The following optimizations require server access and technical expertise. If you’re on shared hosting, you won’t be able to implement these—but they’re worth knowing about for when you upgrade.</p>

<h3 id="11-enable-redis-object-cache--impact-30-50-faster-admin-20-fewer-db-queries">11. <strong>Enable Redis Object Cache</strong> ⚡ Impact: 30-50% faster admin, 20% fewer DB queries</h3>

<p><strong>The Problem:</strong>
WordPress queries the database constantly, loading the same data repeatedly. Autoloaded options (like settings) are fetched on every single page load.</p>

<p><strong>The Solution:</strong>
Redis caches database query results in memory, dramatically reducing database load:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// WordPress makes this query on EVERY page load</span>
<span class="no">SELECT</span> <span class="n">option_value</span> <span class="no">FROM</span> <span class="n">wp_options</span> <span class="no">WHERE</span> <span class="n">autoload</span><span class="o">=</span><span class="s1">'yes'</span>

<span class="c1">// Without Redis: Hits database every time</span>
<span class="c1">// With Redis: Cached in memory after first query</span>
</code></pre></div></div>

<p><strong>Implementation (Trellis/Modern Hosting):</strong></p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># trellis/group_vars/production/wordpress_sites.yml</span>
<span class="na">wordpress_sites</span><span class="pi">:</span>
  <span class="na">example.com</span><span class="pi">:</span>
    <span class="na">cache</span><span class="pi">:</span>
      <span class="na">enabled</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">driver</span><span class="pi">:</span> <span class="s">redis</span>
</code></pre></div></div>

<p><strong>Verification:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp redis status
<span class="c"># Expected: Connected to Redis via PhpRedis (v6.x)</span>
</code></pre></div></div>

<p><strong>Real Impact on imagewize.com:</strong></p>
<ul>
  <li>Admin panel 40% faster</li>
  <li>Database queries reduced from 45 to 28 per page</li>
  <li>Autoloaded options (250KB) cached instead of queried</li>
</ul>

<p><strong>Note:</strong> This requires Redis server installed. Most managed WordPress hosts don’t offer this—you need VPS or dedicated hosting.</p>

<hr />

<h3 id="12-optimize-php-fpm-worker-pool--impact-prevents-site-crashes-under-traffic">12. <strong>Optimize PHP-FPM Worker Pool</strong> ⚡ Impact: Prevents site crashes under traffic</h3>

<p><strong>The Problem:</strong>
PHP-FPM has a limited number of “workers” (processes that handle requests). If all workers are busy when a new request arrives, the request fails with a “critical error.”</p>

<p><strong>How Many Workers Do You Need?</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Available RAM: 4GB
Safe worker memory: ~150MB each
Safe worker count: 4000MB / 150MB = ~26 workers

With memory accumulation:
After 100 requests: ~200MB each
Safe worker count: 4000MB / 200MB = 20 workers
</code></pre></div></div>

<p><strong>The Solution:</strong>
Configure PHP-FPM dynamically with aggressive worker recycling:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">pm</span> <span class="p">=</span> <span class="s">dynamic                  # Spawn workers as needed</span>
<span class="py">pm.max_children</span> <span class="p">=</span> <span class="s">30          # Never more than 30 workers</span>
<span class="py">pm.start_servers</span> <span class="p">=</span> <span class="s">10         # Start with 10 ready</span>
<span class="py">pm.min_spare_servers</span> <span class="p">=</span> <span class="s">8      # Always keep 8 idle</span>
<span class="py">pm.max_spare_servers</span> <span class="p">=</span> <span class="s">15     # Kill extras above 15</span>
<span class="py">pm.max_requests</span> <span class="p">=</span> <span class="s">100         # Recycle after 100 requests</span>
</code></pre></div></div>

<p><strong>Why Recycle Workers?</strong>
PHP workers don’t release memory between requests—they accumulate it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request 1:   150 MB
Request 50:  250 MB
Request 100: 350 MB
Request 200: 500 MB  ← Danger! Kill and respawn
</code></pre></div></div>

<p><strong>Real Result:</strong> On imagewize.com, reducing <code class="language-plaintext highlighter-rouge">pm.max_requests</code> from 500 → 100 prevented workers from ballooning to 800MB+ and causing OOM errors.</p>

<hr />

<h3 id="13-clean-up-wordpress-autoloaded-options--impact-58-reduction-in-database-load">13. <strong>Clean Up WordPress Autoloaded Options</strong> ⚡ Impact: 58% reduction in database load</h3>

<p><strong>The Problem:</strong>
WordPress has a feature called “autoload” that loads certain database options on EVERY page load. Over time, deactivated plugins leave behind bloated autoload data that wastes memory.</p>

<p><strong>Check Your Autoload Size:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp db query <span class="s2">"SELECT
  COUNT(*) as count,
  ROUND(SUM(LENGTH(option_value))/1024) as size_kb
FROM wp_options
WHERE autoload='yes';"</span>
</code></pre></div></div>

<p><strong>Healthy Site:</strong></p>
<ul>
  <li>Options count: &lt; 800</li>
  <li>Total size: &lt; 300 KB</li>
</ul>

<p><strong>Our Cleanup Results (imagewize.com - Nov 2025):</strong></p>
<ul>
  <li><strong>Before:</strong> 844 options, 608 KB total</li>
  <li><strong>After:</strong> 842 options, 251 KB total (58.7% reduction!)</li>
</ul>

<p><strong>What We Deleted:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Old WPML installer tracking (228 KB!)</span>
wp option delete wprc_info_extension

<span class="c"># Old installer settings (138 KB)</span>
wp option delete wp_installer_settings

<span class="c"># Bloated directory size cache (294 KB)</span>
wp option delete _transient_dirsize_cache
</code></pre></div></div>

<p><strong>Impact:</strong> 357 KB freed = faster page loads, lower memory usage, faster deployments.</p>

<p><strong>Find Your Bloated Options:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp db query <span class="s2">"SELECT option_name, LENGTH(option_value) as size_bytes
FROM wp_options
WHERE autoload='yes'
ORDER BY size_bytes DESC
LIMIT 10;"</span>
</code></pre></div></div>

<p>Look for options from plugins you no longer use—they’re safe to delete.</p>

<hr />

<h3 id="14-block-xmlrpc-attacks--impact-prevents-memory-exhaustion-from-bots">14. <strong>Block XMLRPC Attacks</strong> ⚡ Impact: Prevents memory exhaustion from bots</h3>

<p><strong>The Problem:</strong>
WordPress’s <code class="language-plaintext highlighter-rouge">xmlrpc.php</code> is a legacy API endpoint that’s rarely needed but constantly attacked by bots. Even when the endpoint returns an error, WordPress still:</p>
<ol>
  <li>Loads the entire WordPress core</li>
  <li>Bootstraps all plugins</li>
  <li>Allocates 150-300MB of memory</li>
  <li>Then returns 404/403</li>
</ol>

<p><strong>The Attack Pattern:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>103.42.58.162 - POST /xmlrpc.php (500 error)
103.42.58.162 - POST /xmlrpc.php (500 error)
103.42.58.162 - POST /xmlrpc.php (500 error)
... 100+ requests in 10 minutes
</code></pre></div></div>

<p>Result: All PHP-FPM workers exhausted, site crashes.</p>

<p><strong>The Solution:</strong>
Block xmlrpc.php at the <strong>Nginx level</strong> (before PHP is involved):</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">location</span> <span class="p">~</span><span class="sr">*</span> <span class="s">xmlrpc</span><span class="err">\</span><span class="s">.php</span>$ <span class="p">{</span>
  <span class="kn">return</span> <span class="mi">444</span><span class="p">;</span>  <span class="c1"># Drop connection immediately</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>For Trellis Users:</strong></p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># trellis/group_vars/production/wordpress_sites.yml</span>
<span class="na">wordpress_sites</span><span class="pi">:</span>
  <span class="na">example.com</span><span class="pi">:</span>
    <span class="na">xmlrpc</span><span class="pi">:</span>
      <span class="na">enabled</span><span class="pi">:</span> <span class="kc">false</span>    <span class="c1"># Blocks at Nginx level</span>
</code></pre></div></div>

<p><strong>Verification:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-v</span> <span class="nt">--max-time</span> 5 https://yoursite.com/xmlrpc.php
<span class="c"># Should timeout/hang (connection dropped)</span>
</code></pre></div></div>

<p><strong>Real Impact:</strong> After blocking xmlrpc.php on imagewize.com, memory spikes stopped completely. Zero PHP involvement = zero memory waste.</p>

<hr />

<h3 id="15-tune-php-memory-limits-correctly--impact-prevents-crashes">15. <strong>Tune PHP Memory Limits Correctly</strong> ⚡ Impact: Prevents crashes</h3>

<p><strong>The Problem:</strong>
WordPress has <strong>three separate memory limits</strong>—and they all need to be set correctly:</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Purpose</th>
      <th>Default</th>
      <th>Recommended</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">php_memory_limit</code></td>
      <td>PHP’s overall limit</td>
      <td>512M</td>
      <td>768M-1024M</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">WP_MEMORY_LIMIT</code></td>
      <td>WordPress frontend limit</td>
      <td>40M ⚠️</td>
      <td>256M</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">WP_MAX_MEMORY_LIMIT</code></td>
      <td>WordPress admin limit</td>
      <td>256M</td>
      <td>512M</td>
    </tr>
  </tbody>
</table>

<p><strong>The Trap:</strong>
Even if PHP allows 512MB, WordPress will cap itself at <code class="language-plaintext highlighter-rouge">WP_MEMORY_LIMIT</code> (40MB by default). With WooCommerce + modern themes, this causes “allowed memory size exhausted” errors.</p>

<p><strong>The Fix:</strong>
Add to <code class="language-plaintext highlighter-rouge">wp-config.php</code> (or Bedrock’s <code class="language-plaintext highlighter-rouge">config/application.php</code>):</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'256M'</span><span class="p">);</span>
<span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MAX_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'512M'</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Verify It Worked:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wp <span class="nb">eval</span> <span class="s1">'echo "WP_MEMORY_LIMIT: " . WP_MEMORY_LIMIT . "\n";'</span>
wp <span class="nb">eval</span> <span class="s1">'echo "WP_MAX_MEMORY_LIMIT: " . WP_MAX_MEMORY_LIMIT . "\n";'</span>
</code></pre></div></div>

<p><strong>Real Impact:</strong> On imagewize.com, we were hitting the 40M limit constantly with WooCommerce + Acorn/Laravel. Increasing to 256M eliminated all memory errors.</p>

<hr />

<h2 id="real-world-results">Real-World Results</h2>

<p>Here’s what these optimizations achieved on actual client sites:</p>

<h3 id="case-study-e-commerce-store-woocommerce">Case Study: E-commerce Store (WooCommerce)</h3>
<p><strong>Before:</strong></p>
<ul>
  <li>LCP: 3.8 seconds</li>
  <li>CLS: 0.602</li>
  <li>Total Load Time: 4.2 seconds</li>
</ul>

<p><strong>After (10 optimizations):</strong></p>
<ul>
  <li>LCP: 1.4 seconds (↓ 63%)</li>
  <li>CLS: 0.08 (↓ 87%)</li>
  <li>Total Load Time: 1.1 seconds (↓ 74%)</li>
</ul>

<p><strong>Business Impact:</strong></p>
<ul>
  <li>+12% increase in conversion rate</li>
  <li>+23% increase in pages per session</li>
  <li>-18% bounce rate</li>
</ul>

<h3 id="case-study-sme-corporate-site">Case Study: SME Corporate Site</h3>
<p><strong>Before:</strong></p>
<ul>
  <li>LCP: 4.1 seconds</li>
  <li>PageSpeed Score: 42/100</li>
</ul>

<p><strong>After:</strong></p>
<ul>
  <li>LCP: 1.2 seconds (↓ 71%)</li>
  <li>PageSpeed Score: 94/100</li>
</ul>

<p><strong>SEO Impact:</strong> Organic traffic increased 34% within 3 months.</p>

<hr />

<h2 id="tools-for-measuring-speed">Tools for Measuring Speed</h2>

<p>Use these tools to benchmark your site and track improvements:</p>

<h3 id="essential-tools">Essential Tools</h3>
<ol>
  <li><strong><a href="https://pagespeed.web.dev/">Google PageSpeed Insights</a></strong> - Official Core Web Vitals scores</li>
  <li><strong><a href="https://gtmetrix.com/">GTmetrix</a></strong> - Detailed performance analysis</li>
  <li><strong><a href="https://www.webpagetest.org/">WebPageTest</a></strong> - Advanced waterfall analysis</li>
  <li><strong>Chrome DevTools</strong> - Lighthouse audits and performance profiling</li>
</ol>

<h3 id="monitoring">Monitoring</h3>
<ul>
  <li><strong>Google Search Console</strong> - Real Core Web Vitals data from actual users</li>
  <li><strong>Cloudflare Analytics</strong> - Global performance metrics</li>
  <li><strong>New Relic</strong> / <strong>Datadog</strong> - APM for production monitoring</li>
</ul>

<hr />

<h2 id="need-professional-help">Need Professional Help?</h2>

<p>While this guide covers proven optimization techniques, implementing them correctly on your specific setup can be complex. At <a href="https://imagewize.com/speed-optimization/">Imagewize</a>, we specialize in WordPress speed optimization for SMEs.</p>

<h3 id="our-speed-optimization-service-includes">Our Speed Optimization Service Includes:</h3>
<ul>
  <li><strong>Complete performance audit</strong> - Identify all bottlenecks</li>
  <li><strong>Hands-on optimization</strong> - Implement all 10+ techniques</li>
  <li><strong>Before/after testing</strong> - Prove the results</li>
  <li><strong>Ongoing monitoring</strong> - Ensure speeds stay fast</li>
</ul>

<p><strong>Starting at €349</strong> - <a href="https://imagewize.com/contact-us/">Get a free speed audit</a></p>

<p>We also offer <strong><a href="https://imagewize.com/services/">Premium Trellis Hosting</a></strong> (€79/month) with all speed optimizations built-in:</p>
<ul>
  <li>Nginx + PHP 8.3 + Redis</li>
  <li>Micro-caching at server level</li>
  <li>HTTP/2 and Brotli compression</li>
  <li>Automatic backups and security hardening</li>
  <li>Sub-1s load times guaranteed</li>
</ul>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>WordPress speed optimization isn’t magic—it’s about implementing proven techniques systematically:</p>

<h3 id="essential-optimizations-anyone-can-do">Essential Optimizations (Anyone Can Do)</h3>
<ol>
  <li>✅ Eager-load your LCP image</li>
  <li>✅ Preload critical fonts</li>
  <li>✅ Async-load non-critical CSS</li>
  <li>✅ Serve responsive images</li>
  <li>✅ Use modern image formats (WebP/AVIF)</li>
  <li>✅ Minimize plugin bloat</li>
  <li>✅ Defer non-critical JavaScript</li>
  <li>✅ Enable Gzip/Brotli compression</li>
  <li>✅ Use a CDN</li>
</ol>

<h3 id="advanced-optimizations-vpsdedicated-hosting">Advanced Optimizations (VPS/Dedicated Hosting)</h3>
<ol>
  <li>✅ Upgrade to modern hosting stack (Nginx + PHP 8.3 + Redis)</li>
  <li>✅ Enable Redis object cache</li>
  <li>✅ Optimize PHP-FPM worker pool</li>
  <li>✅ Clean up WordPress autoloaded options</li>
  <li>✅ Block XMLRPC attacks</li>
  <li>✅ Tune PHP memory limits correctly</li>
</ol>

<p>Each optimization compounds. Together, they can reduce load times by 70%+ and dramatically improve your Core Web Vitals scores.</p>

<p><strong>The difference between items 1-9 and 10-15?</strong> Items 1-9 you can implement on any hosting. Items 10-15 require server-level access—but they’re the optimizations that separate fast sites from <strong>blazing fast</strong> sites.</p>

<p><strong>Want to see how fast your site can be?</strong> <a href="https://imagewize.com/contact-us/">Contact Imagewize</a> for a free speed audit.</p>

<hr />

<p><strong>About the Author:</strong> Jasper Frumau is the founder of <a href="https://imagewize.com">Imagewize</a>, a WordPress development agency specializing in speed optimization for SMEs. With 15+ years of WordPress experience, he’s optimized 100+ sites to achieve sub-1 second load times.</p>]]></content><author><name></name></author><category term="wordpress" /><category term="performance" /><category term="speed-optimization" /><category term="core-web-vitals" /><category term="lcp" /><category term="cls" /><category term="wordpress-performance" /><summary type="html"><![CDATA[Learn battle-tested WordPress speed optimization techniques that have helped us achieve sub-1 second load times for SME websites. Based on real implementations from 100+ client projects.]]></summary></entry><entry><title type="html">The WordPress Query Filter Trap: How I Caused Infinite Recursion and Crashed Production</title><link href="https://wpvilla.in/wordpress-query-filter-infinite-recursion-trap/" rel="alternate" type="text/html" title="The WordPress Query Filter Trap: How I Caused Infinite Recursion and Crashed Production" /><published>2025-11-24T07:00:00+00:00</published><updated>2025-11-24T07:00:00+00:00</updated><id>https://wpvilla.in/wordpress-query-filter-infinite-recursion-trap</id><content type="html" xml:base="https://wpvilla.in/wordpress-query-filter-infinite-recursion-trap/"><![CDATA[<p>I wrote some code a month ago that finally crashed a production WordPress site today. The bug sat dormant for 4 weeks before exploding. This post documents the error so others (and future me) don’t repeat it.</p>

<h2 id="the-scenario">The Scenario</h2>

<p>During an extensive PHP-FPM memory debugging session, I was trying to suppress some harmless WooCommerce database warnings. These warnings occur when WooCommerce tries to add database indexes that already exist:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WordPress database error Duplicate key name 'session_expiry'
</code></pre></div></div>

<p>The warnings cluttered the debug log but were harmless - WooCommerce handles them gracefully.</p>

<h2 id="the-broken-code">The Broken Code</h2>

<p>I added this filter to <code class="language-plaintext highlighter-rouge">setup.php</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">add_filter</span><span class="p">(</span><span class="s1">'query'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="nv">$query</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">global</span> <span class="nv">$wpdb</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span>
        <span class="nb">strpos</span><span class="p">(</span><span class="nv">$query</span><span class="p">,</span> <span class="s1">'ADD KEY `session_expiry`'</span><span class="p">)</span> <span class="o">!==</span> <span class="kc">false</span> <span class="o">||</span>
        <span class="nb">strpos</span><span class="p">(</span><span class="nv">$query</span><span class="p">,</span> <span class="s1">'ADD INDEX woo_idx_comment_date_type'</span><span class="p">)</span> <span class="o">!==</span> <span class="kc">false</span>
    <span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Suppress errors and execute the query ourselves</span>
        <span class="nv">$suppress_errors</span> <span class="o">=</span> <span class="nv">$wpdb</span><span class="o">-&gt;</span><span class="nf">suppress_errors</span><span class="p">();</span>
        <span class="nv">$wpdb</span><span class="o">-&gt;</span><span class="nf">suppress_errors</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>

        <span class="nv">$result</span> <span class="o">=</span> <span class="nv">$wpdb</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="nv">$query</span><span class="p">);</span>  <span class="c1">// ← THE BUG</span>

        <span class="nv">$wpdb</span><span class="o">-&gt;</span><span class="nf">suppress_errors</span><span class="p">(</span><span class="nv">$suppress_errors</span><span class="p">);</span>

        <span class="k">return</span> <span class="s1">''</span><span class="p">;</span>  <span class="c1">// Prevent original query from running</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$query</span><span class="p">;</span>
<span class="p">},</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>Can you spot the bug?</p>

<h2 id="the-problem-recursive-filter-trigger">The Problem: Recursive Filter Trigger</h2>

<p>The <code class="language-plaintext highlighter-rouge">query</code> filter runs on <strong>every</strong> database query WordPress makes. When you call <code class="language-plaintext highlighter-rouge">$wpdb-&gt;query()</code> inside the <code class="language-plaintext highlighter-rouge">query</code> filter, it triggers the same filter again:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. WordPress runs ALTER TABLE query
2. 'query' filter intercepts it
3. Filter calls $wpdb-&gt;query($query)
4. $wpdb-&gt;query() triggers 'query' filter
5. Filter intercepts the same query again
6. Filter calls $wpdb-&gt;query($query)
7. ... infinite loop ...
8. PHP crashes: "Maximum call stack size reached"
</code></pre></div></div>

<p>The error message was:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PHP Fatal error: Uncaught Error: Maximum call stack size of 8339456 bytes
(zend.max_allowed_stack_size - zend.reserved_stack_size) reached.
Infinite recursion? in /wp-includes/class-wpdb.php:2412
</code></pre></div></div>

<h2 id="why-it-took-4-weeks-to-crash">Why It Took 4 Weeks to Crash</h2>

<p>Here’s the scary part: this code was deployed on <strong>October 27, 2025</strong>. The site ran fine for almost a month before crashing on November 24.</p>

<p>Why? The bug only triggers under specific conditions:</p>

<ol>
  <li>WooCommerce must run an <code class="language-plaintext highlighter-rouge">ALTER TABLE ... ADD KEY</code> query</li>
  <li>These queries don’t run on every page load</li>
  <li>They typically run during:
    <ul>
      <li>WooCommerce database updates</li>
      <li>Session table maintenance</li>
      <li>Certain admin operations</li>
      <li>Plugin updates that trigger dbDelta</li>
    </ul>
  </li>
</ol>

<p>For 4 weeks, those specific queries never ran. Then something triggered them (possibly a cron job, admin action, or WooCommerce maintenance task), and the site immediately crashed.</p>

<h2 id="why-i-missed-it-during-code-review">Why I Missed It During Code Review</h2>

<p>I reviewed this code when I wrote it. Here’s why I missed the bug:</p>

<ol>
  <li><strong>Context blindness</strong>: I was focused on suppressing errors, not recursion</li>
  <li><strong>The logic looked correct</strong>: Intercept query → execute with suppressed errors → return empty</li>
  <li><strong>No immediate failure</strong>: The bug doesn’t manifest until those specific queries run</li>
  <li><strong>Fatigue</strong>: After hours of debugging other issues, attention to detail drops</li>
</ol>

<h2 id="the-fix">The Fix</h2>

<p>The solution is embarrassingly simple. We don’t need to execute the query at all - just skip it:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">add_filter</span><span class="p">(</span><span class="s1">'query'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="nv">$query</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span>
        <span class="nb">strpos</span><span class="p">(</span><span class="nv">$query</span><span class="p">,</span> <span class="s1">'ADD KEY `session_expiry`'</span><span class="p">)</span> <span class="o">!==</span> <span class="kc">false</span> <span class="o">||</span>
        <span class="nb">strpos</span><span class="p">(</span><span class="nv">$query</span><span class="p">,</span> <span class="s1">'ADD INDEX woo_idx_comment_date_type'</span><span class="p">)</span> <span class="o">!==</span> <span class="kc">false</span>
    <span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Just skip this query entirely</span>
        <span class="c1">// WooCommerce handles missing indexes gracefully</span>
        <span class="k">return</span> <span class="s1">''</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$query</span><span class="p">;</span>
<span class="p">},</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>WooCommerce doesn’t actually need these <code class="language-plaintext highlighter-rouge">ADD KEY</code> queries to succeed. The indexes either exist (query fails harmlessly) or don’t exist (WooCommerce works fine without them). By returning an empty string, we simply skip the query.</p>

<h2 id="the-rule">The Rule</h2>

<p><strong>Never call <code class="language-plaintext highlighter-rouge">$wpdb-&gt;query()</code>, <code class="language-plaintext highlighter-rouge">$wpdb-&gt;get_results()</code>, or any database method inside the <code class="language-plaintext highlighter-rouge">query</code> filter.</strong></p>

<p>If you need to run a different query inside the filter, you must:</p>

<ol>
  <li>Remove the filter first</li>
  <li>Run your query</li>
  <li>Re-add the filter</li>
</ol>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">add_filter</span><span class="p">(</span><span class="s1">'query'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="nv">$query</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="o">&amp;</span><span class="nv">$my_filter_callback</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nf">needs_modification</span><span class="p">(</span><span class="nv">$query</span><span class="p">))</span> <span class="p">{</span>
        <span class="c1">// Remove ourselves to prevent recursion</span>
        <span class="nf">remove_filter</span><span class="p">(</span><span class="s1">'query'</span><span class="p">,</span> <span class="nv">$my_filter_callback</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>

        <span class="c1">// Now safe to query</span>
        <span class="nv">$result</span> <span class="o">=</span> <span class="nv">$wpdb</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="nv">$modified_query</span><span class="p">);</span>

        <span class="c1">// Re-add ourselves</span>
        <span class="nf">add_filter</span><span class="p">(</span><span class="s1">'query'</span><span class="p">,</span> <span class="nv">$my_filter_callback</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>

        <span class="k">return</span> <span class="s1">''</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nv">$query</span><span class="p">;</span>
<span class="p">},</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>But honestly, if you find yourself doing this, there’s probably a better approach.</p>

<h2 id="other-dangerous-filter-combinations">Other Dangerous Filter Combinations</h2>

<p>This pattern can bite you with other WordPress filters too:</p>

<table>
  <thead>
    <tr>
      <th>Filter</th>
      <th>Don’t call inside it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">query</code></td>
      <td><code class="language-plaintext highlighter-rouge">$wpdb-&gt;query()</code>, <code class="language-plaintext highlighter-rouge">$wpdb-&gt;get_*()</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">the_content</code></td>
      <td><code class="language-plaintext highlighter-rouge">apply_filters('the_content', ...)</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wp_insert_post_data</code></td>
      <td><code class="language-plaintext highlighter-rouge">wp_insert_post()</code>, <code class="language-plaintext highlighter-rouge">wp_update_post()</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">save_post</code></td>
      <td><code class="language-plaintext highlighter-rouge">wp_update_post()</code> without removing hook</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pre_get_posts</code></td>
      <td><code class="language-plaintext highlighter-rouge">new WP_Query()</code> without precautions</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<ol>
  <li><strong>The <code class="language-plaintext highlighter-rouge">query</code> filter is powerful but dangerous</strong> - it intercepts ALL database queries</li>
  <li><strong>Never call the function that triggers a filter from inside that filter</strong></li>
  <li><strong>Simple solutions are usually better</strong> - returning empty string was safer than trying to execute with error suppression</li>
  <li><strong>Code review fatigue is real</strong> - after hours of debugging, take a break before adding new code</li>
  <li><strong>Test edge cases</strong> - the bug only triggered on specific queries that don’t run often</li>
</ol>

<h2 id="debugging-tip">Debugging Tip</h2>

<p>If you see “Maximum call stack size reached” or “Infinite recursion?” in PHP 8.2+, check your filters. Look for:</p>

<ul>
  <li>Filters that call functions which trigger the same filter</li>
  <li>Recursive action hooks (save_post calling wp_update_post)</li>
  <li>Circular dependencies between filters</li>
</ul>

<p>The stack trace usually shows the same function appearing multiple times - that’s your recursion.</p>

<h2 id="conclusion">Conclusion</h2>

<p>I spent a weekend debugging PHP-FPM memory issues, found and fixed five different root causes, then found a new unrelated bug the next day that crashed the site and could have crashed it for weeks. The irony isn’t lost on me.</p>

<p>The silver lining: this mistake is now documented, and I’ll never make it again. Hopefully you won’t either.</p>

<hr />

<p><em>Have you been bitten by recursive WordPress filters? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a> to share your war stories.</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="php" /><category term="debugging" /><category term="wordpress" /><category term="php" /><category term="debugging" /><category term="filters" /><category term="wpdb" /><category term="recursion" /><category term="woocommerce" /><summary type="html"><![CDATA[I wrote some code a month ago that finally crashed a production WordPress site today. The bug sat dormant for 4 weeks before exploding. This post documents the error so others (and future me) don’t repeat it.]]></summary></entry><entry><title type="html">Debugging PHP-FPM Memory Exhaustion on WordPress with WooCommerce and Trellis</title><link href="https://wpvilla.in/debugging-php-fpm-memory-exhaustion-wordpress-woocommerce-trellis/" rel="alternate" type="text/html" title="Debugging PHP-FPM Memory Exhaustion on WordPress with WooCommerce and Trellis" /><published>2025-11-24T03:00:00+00:00</published><updated>2025-11-24T03:00:00+00:00</updated><id>https://wpvilla.in/debugging-php-fpm-memory-exhaustion-wordpress-woocommerce-trellis</id><content type="html" xml:base="https://wpvilla.in/debugging-php-fpm-memory-exhaustion-wordpress-woocommerce-trellis/"><![CDATA[<p>Over the past weekend, I spent considerable time debugging persistent memory exhaustion errors on a WordPress site running WooCommerce and the Roots stack (Trellis + Bedrock + Sage). What started as simple “critical error” messages turned into a deep investigation that uncovered <strong>five distinct root causes</strong>. This post documents the entire debugging journey, the tools used, and the solutions applied.</p>

<h2 id="the-symptoms">The Symptoms</h2>

<p>The site was experiencing intermittent failures with WordPress’s generic error message:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>There has been a critical error on this website.
</code></pre></div></div>

<p>PHP-FPM logs showed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[pool wordpress] seems busy (you may need to increase pm.start_servers)
[pool wordpress] server reached pm.max_children setting (30)
</code></pre></div></div>

<p>Error logs revealed memory exhaustion:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PHP Fatal error: Allowed memory size of 536870912 bytes exhausted
</code></pre></div></div>

<p>The errors occurred roughly every 10 minutes, often triggered by simple requests like <code class="language-plaintext highlighter-rouge">HEAD /</code> from uptime monitors.</p>

<h2 id="the-investigation">The Investigation</h2>

<h3 id="step-1-initial-php-fpm-analysis">Step 1: Initial PHP-FPM Analysis</h3>

<p>First, I checked the current worker state:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Count active PHP-FPM workers</span>
pgrep <span class="nt">-c</span> php-fpm

<span class="c"># Memory usage by PHP workers</span>
ps aux <span class="nt">--sort</span><span class="o">=</span>-%mem | <span class="nb">grep </span>php-fpm | <span class="nb">head</span> <span class="nt">-10</span>

<span class="c"># Total PHP-FPM memory</span>
ps aux | <span class="nb">grep</span> <span class="s1">'php-fpm: pool'</span> | <span class="nb">awk</span> <span class="s1">'{sum+=$6} END {print sum/1024" MB"}'</span>
</code></pre></div></div>

<p>Workers were showing alarming memory usage:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>817.754 MB - PID 56823
687.953 MB - PID 56841
177.629 MB - PID 56833
</code></pre></div></div>

<p>Some workers had ballooned to 800MB+ while others stayed healthy at ~150MB.</p>

<h3 id="step-2-understanding-the-memory-math">Step 2: Understanding the Memory Math</h3>

<p>On a 4GB server running the full LEMP stack:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Available RAM: ~3.5GB (after system/services)
30 workers × ~200MB each = 6GB theoretical max
Safe worker count: 3500MB / 200MB = ~17-18 workers
</code></pre></div></div>

<p>But the problem wasn’t the worker count—it was <strong>memory accumulation</strong> in long-running workers.</p>

<h2 id="root-cause-1-wordpress-memory-limit-too-low">Root Cause #1: WordPress Memory Limit Too Low</h2>

<p>The first discovery was surprising. WordPress has its <strong>own internal memory limit</strong> separate from PHP’s limit:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check WordPress memory limits</span>
wp <span class="nb">eval</span> <span class="s1">'echo "WP_MEMORY_LIMIT: " . WP_MEMORY_LIMIT . "\n";'</span>
<span class="c"># Output: WP_MEMORY_LIMIT: 40M  ← WAY too low!</span>
</code></pre></div></div>

<p>WordPress was capping itself at 40MB regardless of PHP’s 512MB limit. With WooCommerce + Acorn (Laravel for WordPress), each request needs 150-250MB.</p>

<p><strong>The Fix:</strong></p>

<p>In <code class="language-plaintext highlighter-rouge">site/config/application.php</code> (Bedrock):</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">/**
 * Memory Limits
 * WP_MEMORY_LIMIT: Memory for frontend requests (WordPress default: 40M)
 * WP_MAX_MEMORY_LIMIT: Memory for admin requests (WordPress default: 256M)
 */</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'256M'</span><span class="p">);</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MAX_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'512M'</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="root-cause-2-worker-memory-accumulation">Root Cause #2: Worker Memory Accumulation</h2>

<p>PHP-FPM workers don’t release memory between requests. Over time, they accumulate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request 1:   150 MB
Request 50:  250 MB
Request 100: 350 MB
Request 200: 500+ MB  ← Danger zone!
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">pm.max_requests</code> setting forces workers to recycle after N requests. But on a low-traffic site (~80 requests/hour spread across 15 workers), workers were only handling ~5 requests/hour each. They’d accumulate memory for 20+ hours before recycling.</p>

<p><strong>The Fix:</strong></p>

<p>Reduce <code class="language-plaintext highlighter-rouge">pm.max_requests</code> aggressively:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># trellis/group_vars/production/main.yml</span>
<span class="na">php_fpm_pm_max_requests</span><span class="pi">:</span> <span class="m">25</span>  <span class="c1"># Reduced from 500 → 200 → 100 → 25</span>
</code></pre></div></div>

<p>Apply with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>trellis provision <span class="nt">--tags</span> wordpress-setup production
</code></pre></div></div>

<h2 id="root-cause-3-action-scheduler-async-runner">Root Cause #3: Action Scheduler Async Runner</h2>

<p>WooCommerce uses Action Scheduler for background tasks. I noticed the async runner was constantly spawning AJAX requests:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /wp-admin/admin-ajax.php?action=as_async_request_queue_runner
</code></pre></div></div>

<p>Each request tied up a PHP-FPM worker and accumulated ~2-5MB that was never released.</p>

<p>I had already added the constant to disable it:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'ACTION_SCHEDULER_DISABLE_ASYNC'</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</code></pre></div></div>

<p>But <strong>WooCommerce 10.x ignores this constant!</strong> It now uses a filter instead.</p>

<p><strong>The Fix:</strong></p>

<p>Create an MU-plugin that registers the filter before WooCommerce loads:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>
<span class="cd">/**
 * Plugin Name: Disable Action Scheduler Async Runner
 * Description: Disables WooCommerce Action Scheduler async AJAX runner.
 */</span>

<span class="nf">add_filter</span><span class="p">(</span><span class="s1">'action_scheduler_allow_async_request_runner'</span><span class="p">,</span> <span class="s1">'__return_false'</span><span class="p">);</span>
</code></pre></div></div>

<p>Place in <code class="language-plaintext highlighter-rouge">site/web/app/mu-plugins/disable-action-scheduler-async.php</code>.</p>

<p><strong>Why MU-plugin?</strong> MU-plugins load before regular plugins, ensuring the filter is registered before WooCommerce initializes.</p>

<h2 id="root-cause-4-orphaned-plugin-data">Root Cause #4: Orphaned Plugin Data</h2>

<p>Database analysis revealed bloated data from plugins removed years ago:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check autoloaded options size</span>
wp db query <span class="s2">"SELECT option_name, LENGTH(option_value) as size_bytes
FROM wp_options WHERE autoload='yes'
ORDER BY size_bytes DESC LIMIT 10;"</span>
</code></pre></div></div>

<p>Results:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Size</th>
      <th>Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>rs-templates</td>
      <td>607 KB</td>
      <td>Revolution Slider (removed 2019)</td>
    </tr>
    <tr>
      <td>ptk_patterns</td>
      <td>512 KB</td>
      <td>Starter Patterns (removed)</td>
    </tr>
    <tr>
      <td>wp_installer_settings</td>
      <td>138 KB</td>
      <td>WPML installer (from 2014!)</td>
    </tr>
  </tbody>
</table>

<p><strong>The Fix:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Delete orphaned options</span>
wp db query <span class="s2">"DELETE FROM wp_options WHERE option_name LIKE 'rs-%' OR option_name LIKE 'revslider%';"</span>
wp db query <span class="s2">"DELETE FROM wp_options WHERE option_name = 'ptk_patterns';"</span>
wp db query <span class="s2">"DELETE FROM wp_options WHERE option_name = 'wp_installer_settings';"</span>

<span class="c"># Clean expired transients</span>
wp transient delete <span class="nt">--expired</span>

<span class="c"># Flush object cache</span>
wp cache flush
</code></pre></div></div>

<p><strong>Result:</strong> 58.7% reduction in autoloaded data (608 KB → 251 KB).</p>

<h2 id="root-cause-5-xmlrpc-attacks">Root Cause #5: XMLRPC Attacks</h2>

<p>The final piece of the puzzle came from Xdebug profiling. Error logs showed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[23-Nov-2025 04:43:10] worker 77 exited on signal 9 (SIGKILL)
request: "POST /xmlrpc.php"
IP: 103.42.58.162
</code></pre></div></div>

<p>Even when xmlrpc.php returns 404/403, the request still:</p>

<ol>
  <li>Loads WordPress core (<code class="language-plaintext highlighter-rouge">wp-load.php</code>)</li>
  <li>Bootstraps WooCommerce and Acorn</li>
  <li>Executes <code class="language-plaintext highlighter-rouge">apply_filters()</code> chain</li>
  <li>Consumes significant memory before being blocked</li>
</ol>

<p><strong>The Fix:</strong></p>

<p>Block XMLRPC at the Nginx level (zero PHP involvement):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># trellis/group_vars/production/wordpress_sites.yml</span>
<span class="na">wordpress_sites</span><span class="pi">:</span>
  <span class="na">example.com</span><span class="pi">:</span>
    <span class="na">xmlrpc</span><span class="pi">:</span>
      <span class="na">enabled</span><span class="pi">:</span> <span class="kc">false</span>  <span class="c1"># Generates: location ~* xmlrpc\.php$ { return 444; }</span>
</code></pre></div></div>

<p>Trellis uses HTTP 444 (Nginx-specific “No Response”) which drops the connection immediately. Attackers get a timeout with no feedback.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verify it's working</span>
curl <span class="nt">-v</span> <span class="nt">--max-time</span> 5 https://example.com/xmlrpc.php
<span class="c"># Result: HTTP/2 stream was not closed cleanly: PROTOCOL_ERROR</span>
</code></pre></div></div>

<h2 id="diagnostic-commands-reference">Diagnostic Commands Reference</h2>

<p>Here are the key commands I used throughout this investigation:</p>

<h3 id="memory-status">Memory Status</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Quick health check</span>
ssh root@server <span class="s2">"echo '=== Memory ===' &amp;&amp; free -m | grep Mem &amp;&amp; </span><span class="se">\</span><span class="s2">
echo '' &amp;&amp; echo '=== Workers ===' &amp;&amp; </span><span class="se">\</span><span class="s2">
ps aux --sort=-%mem | grep 'php-fpm: pool' | head -5 | </span><span class="se">\</span><span class="s2">
awk '{print </span><span class="se">\$</span><span class="s2">6/1024 </span><span class="se">\"</span><span class="s2"> MB - PID </span><span class="se">\"</span><span class="s2"> </span><span class="se">\$</span><span class="s2">2}'"</span>
</code></pre></div></div>

<h3 id="php-fpm-logs">PHP-FPM Logs</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Recent warnings</span>
<span class="nb">grep</span> <span class="s1">'seems busy\|max_children'</span> /var/log/php8.3-fpm.log | <span class="nb">tail</span> <span class="nt">-20</span>

<span class="c"># Watch in real-time</span>
<span class="nb">tail</span> <span class="nt">-f</span> /var/log/php8.3-fpm.log
</code></pre></div></div>

<h3 id="database-analysis">Database Analysis</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Autoloaded options size</span>
wp db query <span class="s2">"SELECT COUNT(*) as count, ROUND(SUM(LENGTH(option_value))/1024) as size_kb
FROM wp_options WHERE autoload='yes';"</span>

<span class="c"># Largest options</span>
wp db query <span class="s2">"SELECT option_name, LENGTH(option_value) as size_bytes
FROM wp_options WHERE autoload='yes'
ORDER BY size_bytes DESC LIMIT 10;"</span>
</code></pre></div></div>

<h3 id="memory-spike-detection">Memory Spike Detection</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Watch for workers above 300MB</span>
<span class="k">while </span><span class="nb">true</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">HIGH_MEM</span><span class="o">=</span><span class="si">$(</span>ps aux <span class="nt">--sort</span><span class="o">=</span>-%mem | <span class="nb">grep</span> <span class="s1">'php-fpm: pool'</span> | <span class="se">\</span>
    <span class="nb">awk</span> <span class="s1">'$6 &gt; 307200 {print $6/1024 " MB - PID " $2}'</span> | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span>
  <span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$HIGH_MEM</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"[</span><span class="si">$(</span><span class="nb">date</span> +%H:%M:%S<span class="si">)</span><span class="s2">] SPIKE: </span><span class="nv">$HIGH_MEM</span><span class="s2">"</span>
    <span class="nb">tail</span> <span class="nt">-3</span> /var/log/nginx/access.log
  <span class="k">fi
  </span><span class="nb">sleep </span>10
<span class="k">done</span>
</code></pre></div></div>

<h2 id="final-configuration">Final Configuration</h2>

<p>After all fixes, here’s the stable configuration:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># PHP-FPM Pool (trellis/group_vars/production/main.yml)</span>
<span class="na">php_fpm_pm</span><span class="pi">:</span> <span class="s">dynamic</span>
<span class="na">php_fpm_pm_max_children</span><span class="pi">:</span> <span class="m">30</span>
<span class="na">php_fpm_pm_start_servers</span><span class="pi">:</span> <span class="m">10</span>
<span class="na">php_fpm_pm_min_spare_servers</span><span class="pi">:</span> <span class="m">8</span>
<span class="na">php_fpm_pm_max_spare_servers</span><span class="pi">:</span> <span class="m">15</span>
<span class="na">php_fpm_pm_max_requests</span><span class="pi">:</span> <span class="m">25</span>        <span class="c1"># Aggressive recycling</span>

<span class="c1"># PHP Memory</span>
<span class="na">php_memory_limit</span><span class="pi">:</span> <span class="s">1024M</span>            <span class="c1"># High ceiling for headroom</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// WordPress Memory (site/config/application.php)</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'256M'</span><span class="p">);</span>
<span class="nc">Config</span><span class="o">::</span><span class="nb">define</span><span class="p">(</span><span class="s1">'WP_MAX_MEMORY_LIMIT'</span><span class="p">,</span> <span class="s1">'512M'</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="results">Results</h2>

<p>After implementing all fixes:</p>

<ul>
  <li><strong>Workers stable at 112-120MB</strong> (down from 600-800MB spikes)</li>
  <li><strong>No OOM errors</strong> in 24+ hours</li>
  <li><strong>2.7GB free memory</strong> on 4GB server</li>
  <li><strong>XMLRPC attacks blocked</strong> at Nginx (zero PHP load)</li>
  <li><strong>Redis object cache active</strong> (additional performance boost)</li>
</ul>

<h2 id="key-lessons-learned">Key Lessons Learned</h2>

<ol>
  <li>
    <p><strong>WordPress has its own memory limit</strong> — <code class="language-plaintext highlighter-rouge">WP_MEMORY_LIMIT</code> caps memory regardless of PHP’s <code class="language-plaintext highlighter-rouge">memory_limit</code>. Check this first on WooCommerce sites.</p>
  </li>
  <li>
    <p><strong>Low-traffic sites need aggressive worker recycling</strong> — <code class="language-plaintext highlighter-rouge">pm.max_requests: 500</code> is useless if workers only handle 5 requests/hour. Use 25-50 for low-traffic sites.</p>
  </li>
  <li>
    <p><strong>Constants can become deprecated</strong> — WooCommerce 10.x ignores <code class="language-plaintext highlighter-rouge">ACTION_SCHEDULER_DISABLE_ASYNC</code>. Always verify by checking actual code behavior.</p>
  </li>
  <li>
    <p><strong>Block attacks at Nginx, not PHP</strong> — Even “blocked” PHP requests consume memory during WordPress bootstrap. Use <code class="language-plaintext highlighter-rouge">return 444</code> for zero-PHP blocking.</p>
  </li>
  <li>
    <p><strong>Old plugin data accumulates</strong> — Plugins removed years ago can leave megabytes of orphaned data. Audit <code class="language-plaintext highlighter-rouge">wp_options</code> periodically.</p>
  </li>
  <li>
    <p><strong>Profile production, not just development</strong> — The XMLRPC attack pattern only appeared in production logs. Xdebug profiling on production (temporarily) was essential.</p>
  </li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>What started as a simple “critical error” turned into a multi-day investigation uncovering five distinct issues. Each fix contributed to stability, but the <strong>combination</strong> of all fixes was necessary for complete resolution.</p>

<p>The most important takeaway: memory issues on WordPress/WooCommerce rarely have a single cause. Systematic investigation with proper tooling (logs, profiling, database analysis) is essential for finding all contributing factors.</p>

<p>If you’re experiencing similar issues on your Trellis/Bedrock/Sage stack, I hope this detailed walkthrough helps you identify and fix your root causes faster than I did!</p>

<hr />

<p><em>Have you dealt with PHP-FPM memory issues on WordPress? Find me on Mastodon at <a href="https://mastodon.social/@jfrumau">@jfrumau@mastodon.social</a> to share your experiences and solutions!</em></p>]]></content><author><name></name></author><category term="wordpress" /><category term="php-fpm" /><category term="trellis" /><category term="woocommerce" /><category term="performance" /><category term="wordpress" /><category term="php" /><category term="php-fpm" /><category term="memory" /><category term="woocommerce" /><category term="trellis" /><category term="nginx" /><category term="debugging" /><category term="performance" /><category term="server" /><summary type="html"><![CDATA[Over the past weekend, I spent considerable time debugging persistent memory exhaustion errors on a WordPress site running WooCommerce and the Roots stack (Trellis + Bedrock + Sage). What started as simple “critical error” messages turned into a deep investigation that uncovered five distinct root causes. This post documents the entire debugging journey, the tools used, and the solutions applied.]]></summary></entry><entry><title type="html">Building a Custom WordPress Walker for Multilingual Mobile Navigation with Secondary Menus</title><link href="https://wpvilla.in/building-custom-wordpress-walker-multilingual-mobile-navigation/" rel="alternate" type="text/html" title="Building a Custom WordPress Walker for Multilingual Mobile Navigation with Secondary Menus" /><published>2025-08-03T08:00:00+00:00</published><updated>2025-08-03T08:00:00+00:00</updated><id>https://wpvilla.in/building-custom-wordpress-walker-multilingual-mobile-navigation</id><content type="html" xml:base="https://wpvilla.in/building-custom-wordpress-walker-multilingual-mobile-navigation/"><![CDATA[<p>When building modern WordPress themes, mobile navigation often requires more sophisticated functionality than the standard WordPress menu system provides out of the box. Recently, I worked on a multilingual project for a client that needed a two-level mobile navigation system with language detection, secondary menus, and custom navigation controls.</p>

<p>In this post, I’ll walk you through how I created a custom WordPress Walker class that handles complex mobile navigation requirements while maintaining clean, maintainable code.</p>

<h2 id="the-challenge">The Challenge</h2>

<p>The client needed a mobile navigation system with several specific requirements:</p>

<ul>
  <li><strong>Two-level navigation structure</strong> with main menu and expertise submenu</li>
  <li><strong>Multilingual support</strong> for Dutch, English, and German using Polylang</li>
  <li><strong>Dynamic language detection</strong> from URL structure</li>
  <li><strong>Custom navigation controls</strong> (back button, close button)</li>
  <li><strong>Performance optimization</strong> to minimize database queries</li>
  <li><strong>Fallback content</strong> when WordPress menus aren’t fully configured</li>
</ul>

<h2 id="the-solution-custom-walker-class">The Solution: Custom Walker Class</h2>

<p>Instead of using multiple functions and queries, I created a single <code class="language-plaintext highlighter-rouge">Mobile_Nav_Walker</code> class that extends WordPress’s <code class="language-plaintext highlighter-rouge">Walker_Nav_Menu</code> class. This approach consolidates all the logic into one optimized system.</p>

<h3 id="basic-walker-structure">Basic Walker Structure</h3>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Mobile_Nav_Walker</span> <span class="kd">extends</span> <span class="nc">Walker_Nav_Menu</span> <span class="p">{</span>
    
    <span class="k">private</span> <span class="nv">$current_language</span> <span class="o">=</span> <span class="s1">'nl'</span><span class="p">;</span>
    <span class="k">private</span> <span class="nv">$expertise_parent_id</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
    <span class="k">private</span> <span class="nv">$main_nav_html</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
    <span class="k">private</span> <span class="nv">$categories</span> <span class="o">=</span> <span class="k">array</span><span class="p">();</span>
    <span class="k">private</span> <span class="nv">$category_parents</span> <span class="o">=</span> <span class="k">array</span><span class="p">();</span>
    
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">()</span> <span class="p">{</span>
        <span class="c1">// Detect current language from URL</span>
        <span class="nv">$url</span> <span class="o">=</span> <span class="nv">$_SERVER</span><span class="p">[</span><span class="s1">'REQUEST_URI'</span><span class="p">];</span>
        <span class="k">if</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/\/en\//'</span><span class="p">,</span> <span class="nv">$url</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span> <span class="o">=</span> <span class="s1">'en'</span><span class="p">;</span>
        <span class="p">}</span> <span class="k">elseif</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/\/de\//'</span><span class="p">,</span> <span class="nv">$url</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span> <span class="o">=</span> <span class="s1">'de'</span><span class="p">;</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span> <span class="o">=</span> <span class="s1">'nl'</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="language-detection-system">Language Detection System</h3>

<p>The walker automatically detects the current language by analyzing the URL structure. This works seamlessly with Polylang’s URL-based language switching:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">get_language_button_text</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s1">'en'</span><span class="o">:</span>
            <span class="k">return</span> <span class="k">array</span><span class="p">(</span><span class="s1">'back'</span> <span class="o">=&gt;</span> <span class="s1">'Back'</span><span class="p">,</span> <span class="s1">'close'</span> <span class="o">=&gt;</span> <span class="s1">'Close'</span><span class="p">);</span>
        <span class="k">case</span> <span class="s1">'de'</span><span class="o">:</span>
            <span class="k">return</span> <span class="k">array</span><span class="p">(</span><span class="s1">'back'</span> <span class="o">=&gt;</span> <span class="s1">'Zurück'</span><span class="p">,</span> <span class="s1">'close'</span> <span class="o">=&gt;</span> <span class="s1">'Schließen'</span><span class="p">);</span>
        <span class="k">default</span><span class="o">:</span>
            <span class="k">return</span> <span class="k">array</span><span class="p">(</span><span class="s1">'back'</span> <span class="o">=&gt;</span> <span class="s1">'Terug'</span><span class="p">,</span> <span class="s1">'close'</span> <span class="o">=&gt;</span> <span class="s1">'Sluiten'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="processing-menu-items">Processing Menu Items</h3>

<p>The <code class="language-plaintext highlighter-rouge">start_el</code> method processes each menu item and builds the navigation structure:</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">start_el</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$output</span><span class="p">,</span> <span class="nv">$item</span><span class="p">,</span> <span class="nv">$depth</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="nv">$args</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span> <span class="nv">$id</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    
    <span class="c1">// Process top-level navigation items</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$depth</span> <span class="o">===</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">menu_item_parent</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$classes</span> <span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">classes</span> <span class="o">?</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">classes</span> <span class="o">:</span> <span class="k">array</span><span class="p">();</span>
        <span class="nv">$has_submenu</span> <span class="o">=</span> <span class="nb">in_array</span><span class="p">(</span><span class="s1">'has-mega-menu'</span><span class="p">,</span> <span class="nv">$classes</span><span class="p">)</span> <span class="o">?</span> <span class="s1">'has-submenu'</span> <span class="o">:</span> <span class="s1">''</span><span class="p">;</span>
        <span class="nv">$data_target</span> <span class="o">=</span> <span class="nb">in_array</span><span class="p">(</span><span class="s1">'has-mega-menu'</span><span class="p">,</span> <span class="nv">$classes</span><span class="p">)</span> <span class="o">?</span> <span class="s1">'data-target="expertise"'</span> <span class="o">:</span> <span class="s1">''</span><span class="p">;</span>
        <span class="nv">$arrow</span> <span class="o">=</span> <span class="nv">$has_submenu</span> <span class="o">?</span> <span class="s1">'&lt;span class="nav-arrow"&gt;›&lt;/span&gt;'</span> <span class="o">:</span> <span class="s1">''</span><span class="p">;</span>
        
        <span class="c1">// Store expertise parent ID for submenu processing</span>
        <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="s1">'has-mega-menu'</span><span class="p">,</span> <span class="nv">$classes</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">expertise_parent_id</span> <span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="no">ID</span><span class="p">;</span>
        <span class="p">}</span>
        
        <span class="c1">// Build main navigation HTML</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">main_nav_html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;a href="'</span> <span class="mf">.</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">url</span> <span class="mf">.</span> <span class="s1">'" class="nav-item '</span> <span class="mf">.</span> <span class="nv">$has_submenu</span> <span class="mf">.</span> <span class="s1">'" '</span> <span class="mf">.</span> <span class="nv">$data_target</span> <span class="mf">.</span> <span class="s1">'&gt;'</span><span class="p">;</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">main_nav_html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">title</span> <span class="mf">.</span> <span class="nv">$arrow</span><span class="p">;</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">main_nav_html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/a&gt;'</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="c1">// Process expertise submenu items</span>
    <span class="nv">$classes</span> <span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">classes</span> <span class="o">?</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">classes</span> <span class="o">:</span> <span class="k">array</span><span class="p">();</span>
    
    <span class="c1">// Direct children of Expertise (category headers)</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">menu_item_parent</span> <span class="o">==</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">expertise_parent_id</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="s1">'mega-menu-row-1'</span><span class="p">,</span> <span class="nv">$classes</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">categories</span><span class="p">[</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">title</span><span class="p">]</span> <span class="o">=</span> <span class="k">array</span><span class="p">();</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_category</span> <span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">title</span><span class="p">;</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">category_parents</span><span class="p">[</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="no">ID</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">title</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="c1">// Children of category headers (actual service items)</span>
    <span class="k">elseif</span> <span class="p">(</span><span class="k">isset</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">category_parents</span><span class="p">[</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">menu_item_parent</span><span class="p">]))</span> <span class="p">{</span>
        <span class="nv">$parent_category</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">category_parents</span><span class="p">[</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">menu_item_parent</span><span class="p">];</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">categories</span><span class="p">[</span><span class="nv">$parent_category</span><span class="p">][]</span> <span class="o">=</span> <span class="nv">$item</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="two-level-navigation-structure">Two-Level Navigation Structure</h3>

<p>The walker generates a sophisticated two-level mobile navigation:</p>

<p><strong>Level 1: Main Menu</strong></p>
<ul>
  <li>Logo</li>
  <li>Main navigation items</li>
  <li>Language-aware close button</li>
  <li>CTA button</li>
</ul>

<p><strong>Level 2: Expertise Submenu</strong></p>
<ul>
  <li>Language-aware back button</li>
  <li>Categorized service items</li>
  <li>Language-aware close button</li>
  <li>CTA button</li>
</ul>

<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">get_mobile_navigation_html</span><span class="p">()</span> <span class="p">{</span>
    <span class="nv">$button_text</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">get_language_button_text</span><span class="p">();</span>
    
    <span class="nv">$html</span> <span class="o">=</span> <span class="s1">'&lt;div class="mobile-nav-enhanced"&gt;'</span><span class="p">;</span>
    
    <span class="c1">// Level 1: Main Menu</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="nav-level level-1"&gt;'</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="mobile-nav-header"&gt;'</span><span class="p">;</span>
    
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nf">get_field</span><span class="p">(</span><span class="s1">'hs_logo'</span><span class="p">,</span> <span class="s1">'option'</span><span class="p">)))</span> <span class="p">{</span>
        <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;img src="'</span> <span class="mf">.</span> <span class="nf">get_field</span><span class="p">(</span><span class="s1">'hs_logo'</span><span class="p">,</span> <span class="s1">'option'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'" alt="Allinq Digital" class="mobile-logo"&gt;'</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;button class="mobile-close-btn"&gt;'</span> <span class="mf">.</span> <span class="nv">$button_text</span><span class="p">[</span><span class="s1">'close'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'&lt;/button&gt;'</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/div&gt;'</span><span class="p">;</span>
    
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;nav class="mobile-nav-menu"&gt;'</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">main_nav_html</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/nav&gt;'</span><span class="p">;</span>
    
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="mobile-nav-cta"&gt;'</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">get_mobile_cta_button</span><span class="p">();</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/div&gt;'</span><span class="p">;</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/div&gt;'</span><span class="p">;</span>
    
    <span class="c1">// Level 2: Expertise Submenu</span>
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">generate_expertise_submenu</span><span class="p">(</span><span class="nv">$button_text</span><span class="p">);</span>
    
    <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/div&gt;'</span><span class="p">;</span>
    
    <span class="k">return</span> <span class="nv">$html</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="multilingual-cta-buttons">Multilingual CTA Buttons</h3>

<p>The walker handles different CTA buttons for each language using Advanced Custom Fields:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">get_mobile_cta_button</span><span class="p">()</span> <span class="p">{</span>
    <span class="nv">$header_btn</span> <span class="o">=</span> <span class="nf">get_field</span><span class="p">(</span><span class="s1">'hs_button'</span><span class="p">,</span> <span class="s1">'option'</span><span class="p">);</span>
    <span class="nv">$header_btn_en</span> <span class="o">=</span> <span class="nf">get_field</span><span class="p">(</span><span class="s1">'hs_button_en'</span><span class="p">,</span> <span class="s1">'option'</span><span class="p">);</span>
    <span class="nv">$header_btn_de</span> <span class="o">=</span> <span class="nf">get_field</span><span class="p">(</span><span class="s1">'hs_button_de'</span><span class="p">,</span> <span class="s1">'option'</span><span class="p">);</span>
    
    <span class="nv">$button_html</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
    
    <span class="k">switch</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s1">'en'</span><span class="o">:</span>
            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$header_btn_en</span><span class="p">))</span> <span class="p">{</span>
                <span class="nv">$button_html</span> <span class="o">=</span> <span class="s1">'&lt;a href="'</span> <span class="mf">.</span> <span class="nv">$header_btn_en</span><span class="p">[</span><span class="s1">'url'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'" class="mobile-cta-btn"&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$header_btn_en</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">' &lt;span class="cta-arrow"&gt;→&lt;/span&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/a&gt;'</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="k">break</span><span class="p">;</span>
        <span class="k">case</span> <span class="s1">'de'</span><span class="o">:</span>
            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$header_btn_de</span><span class="p">))</span> <span class="p">{</span>
                <span class="nv">$button_html</span> <span class="o">=</span> <span class="s1">'&lt;a href="'</span> <span class="mf">.</span> <span class="nv">$header_btn_de</span><span class="p">[</span><span class="s1">'url'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'" class="mobile-cta-btn"&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$header_btn_de</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">' &lt;span class="cta-arrow"&gt;→&lt;/span&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/a&gt;'</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="k">break</span><span class="p">;</span>
        <span class="k">default</span><span class="o">:</span>
            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$header_btn</span><span class="p">))</span> <span class="p">{</span>
                <span class="nv">$button_html</span> <span class="o">=</span> <span class="s1">'&lt;a href="'</span> <span class="mf">.</span> <span class="nv">$header_btn</span><span class="p">[</span><span class="s1">'url'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'" class="mobile-cta-btn"&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="nv">$header_btn</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">' &lt;span class="cta-arrow"&gt;→&lt;/span&gt;'</span><span class="p">;</span>
                <span class="nv">$button_html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/a&gt;'</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="k">break</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="k">return</span> <span class="nv">$button_html</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="intelligent-fallback-system">Intelligent Fallback System</h3>

<p>One of the key features is the intelligent fallback system. If the WordPress menu isn’t fully configured or lacks the expected structure, the walker provides language-appropriate fallback content:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">get_fallback_expertise_structure</span><span class="p">()</span> <span class="p">{</span>
    <span class="nv">$html</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
    
    <span class="k">switch</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">current_language</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s1">'en'</span><span class="o">:</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="nav-category"&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;h3&gt;Digitalisation&lt;/h3&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;a href="#" class="nav-item"&gt;Scan to BIM &lt;span class="nav-arrow"&gt;›&lt;/span&gt;&lt;/a&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;a href="#" class="nav-item"&gt;Digital Channel &lt;span class="nav-arrow"&gt;›&lt;/span&gt;&lt;/a&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;/div&gt;'</span><span class="p">;</span>
            <span class="c1">// ... more categories</span>
            <span class="k">break</span><span class="p">;</span>
            
        <span class="k">case</span> <span class="s1">'de'</span><span class="o">:</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="nav-category"&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;h3&gt;Digitalisierung&lt;/h3&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;a href="#" class="nav-item"&gt;Scan to BIM &lt;span class="nav-arrow"&gt;›&lt;/span&gt;&lt;/a&gt;'</span><span class="p">;</span>
            <span class="c1">// ... more items</span>
            <span class="k">break</span><span class="p">;</span>
            
        <span class="k">default</span><span class="o">:</span> <span class="c1">// Dutch</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;div class="nav-category"&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;h3&gt;Digitaliseren&lt;/h3&gt;'</span><span class="p">;</span>
            <span class="nv">$html</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&lt;a href="#" class="nav-item"&gt;Scan to BIM &lt;span class="nav-arrow"&gt;›&lt;/span&gt;&lt;/a&gt;'</span><span class="p">;</span>
            <span class="c1">// ... more items</span>
            <span class="k">break</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="k">return</span> <span class="nv">$html</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="usage-implementation">Usage Implementation</h2>

<p>To use the walker, I created a simple function that handles menu detection and fallbacks:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">get_walker_mobile_navigation</span><span class="p">()</span> <span class="p">{</span>
    <span class="c1">// Try to get menu by theme location first</span>
    <span class="nv">$locations</span> <span class="o">=</span> <span class="nf">get_nav_menu_locations</span><span class="p">();</span>
    <span class="nv">$menu_id</span> <span class="o">=</span> <span class="k">isset</span><span class="p">(</span><span class="nv">$locations</span><span class="p">[</span><span class="s1">'main_menu'</span><span class="p">])</span> <span class="o">?</span> <span class="nv">$locations</span><span class="p">[</span><span class="s1">'main_menu'</span><span class="p">]</span> <span class="o">:</span> <span class="kc">false</span><span class="p">;</span>
    
    <span class="c1">// If no menu assigned, try language-specific menu names</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$menu_id</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$url</span> <span class="o">=</span> <span class="nv">$_SERVER</span><span class="p">[</span><span class="s1">'REQUEST_URI'</span><span class="p">];</span>
        
        <span class="k">if</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/\/en\//'</span><span class="p">,</span> <span class="nv">$url</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$menu_names</span> <span class="o">=</span> <span class="k">array</span><span class="p">(</span><span class="s1">'Main Menu (EN)'</span><span class="p">,</span> <span class="s1">'Main Menu EN'</span><span class="p">);</span>
        <span class="p">}</span> <span class="k">elseif</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/\/de\//'</span><span class="p">,</span> <span class="nv">$url</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$menu_names</span> <span class="o">=</span> <span class="k">array</span><span class="p">(</span><span class="s1">'Main Menu (DE)'</span><span class="p">,</span> <span class="s1">'Main Menu DE'</span><span class="p">);</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="nv">$menu_names</span> <span class="o">=</span> <span class="k">array</span><span class="p">(</span><span class="s1">'Main Menu'</span><span class="p">,</span> <span class="s1">'Main Menu NL'</span><span class="p">);</span>
        <span class="p">}</span>
        
        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$menu_names</span> <span class="k">as</span> <span class="nv">$menu_name</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$menu</span> <span class="o">=</span> <span class="nf">wp_get_nav_menu_object</span><span class="p">(</span><span class="nv">$menu_name</span><span class="p">);</span>
            <span class="k">if</span> <span class="p">(</span><span class="nv">$menu</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$menu_id</span> <span class="o">=</span> <span class="nv">$menu</span><span class="o">-&gt;</span><span class="n">term_id</span><span class="p">;</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="c1">// Generate navigation using walker</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$menu_id</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$walker</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Mobile_Nav_Walker</span><span class="p">();</span>
        <span class="nv">$menu_items</span> <span class="o">=</span> <span class="nf">wp_get_nav_menu_items</span><span class="p">(</span><span class="nv">$menu_id</span><span class="p">);</span>
        
        <span class="k">if</span> <span class="p">(</span><span class="nv">$menu_items</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">foreach</span> <span class="p">(</span><span class="nv">$menu_items</span> <span class="k">as</span> <span class="nv">$item</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$walker</span><span class="o">-&gt;</span><span class="nf">start_el</span><span class="p">(</span><span class="nv">$output</span><span class="p">,</span> <span class="nv">$item</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="kc">null</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="nv">$walker</span><span class="o">-&gt;</span><span class="nf">get_mobile_navigation_html</span><span class="p">();</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="c1">// Fallback if no menu found</span>
    <span class="nv">$walker</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Mobile_Nav_Walker</span><span class="p">();</span>
    <span class="k">return</span> <span class="nv">$walker</span><span class="o">-&gt;</span><span class="nf">get_mobile_navigation_html</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="performance-benefits">Performance Benefits</h2>

<p>This custom walker approach provides several performance advantages:</p>

<ol>
  <li><strong>Single Database Query</strong>: Instead of multiple <code class="language-plaintext highlighter-rouge">wp_get_nav_menu_items()</code> calls, everything is processed in one query</li>
  <li><strong>Efficient Processing</strong>: Menu items are processed once and stored in class properties</li>
  <li><strong>Lazy Generation</strong>: HTML is only generated when requested</li>
  <li><strong>Memory Efficient</strong>: No redundant data structures or processing</li>
</ol>

<h2 id="template-integration">Template Integration</h2>

<p>In your header template, simply call:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">echo</span> <span class="nf">get_walker_mobile_navigation</span><span class="p">();</span>
</code></pre></div></div>

<p>The walker handles all the complexity behind the scenes, providing a clean API for your templates.</p>

<h2 id="css-considerations">CSS Considerations</h2>

<p>The walker generates specific CSS classes for styling:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.mobile-nav-enhanced</span> <span class="p">{</span>
    <span class="c">/* Main container */</span>
<span class="p">}</span>

<span class="nc">.nav-level</span> <span class="p">{</span>
    <span class="c">/* Level containers (level-1, level-2) */</span>
<span class="p">}</span>

<span class="nc">.mobile-nav-header</span> <span class="p">{</span>
    <span class="c">/* Header with logo/buttons */</span>
<span class="p">}</span>

<span class="nc">.nav-item</span> <span class="p">{</span>
    <span class="c">/* Navigation links */</span>
<span class="p">}</span>

<span class="nc">.nav-item.has-submenu</span> <span class="p">{</span>
    <span class="c">/* Items with submenus */</span>
<span class="p">}</span>

<span class="nc">.nav-category</span> <span class="p">{</span>
    <span class="c">/* Category containers in submenu */</span>
<span class="p">}</span>

<span class="nc">.mobile-cta-btn</span> <span class="p">{</span>
    <span class="c">/* CTA buttons */</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="key-takeaways">Key Takeaways</h2>

<p>Building this custom walker taught me several important lessons:</p>

<ol>
  <li><strong>Plan for Flexibility</strong>: Always consider multiple languages and fallback scenarios</li>
  <li><strong>Optimize Early</strong>: Single query approaches scale better than multiple function calls</li>
  <li><strong>Encapsulate Logic</strong>: Walker classes keep complex logic organized and reusable</li>
  <li><strong>Test Edge Cases</strong>: Empty menus, missing translations, and incomplete configurations</li>
  <li><strong>Document Thoroughly</strong>: Complex navigation systems need clear documentation</li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>Creating a custom WordPress Walker for mobile navigation might seem complex, but it provides incredible flexibility and performance benefits. This approach allowed me to create a sophisticated multilingual mobile navigation system that works seamlessly with Polylang while maintaining clean, maintainable code.</p>

<p>The walker pattern is particularly powerful for complex navigation requirements where the standard WordPress menu system falls short. By extending the built-in Walker class, you get all the benefits of WordPress’s menu system while adding your own custom functionality.</p>

<p>Have you built custom walkers for your WordPress projects? I’d love to hear about your experiences and any additional techniques you’ve discovered!</p>]]></content><author><name></name></author><category term="wordpress" /><category term="navigation" /><category term="walker" /><category term="polylang" /><category term="multilingual" /><category term="wordpress" /><category term="php" /><category term="walker" /><category term="navigation" /><category term="multilingual" /><category term="polylang" /><category term="mobile" /><category term="acf" /><summary type="html"><![CDATA[When building modern WordPress themes, mobile navigation often requires more sophisticated functionality than the standard WordPress menu system provides out of the box. Recently, I worked on a multilingual project for a client that needed a two-level mobile navigation system with language detection, secondary menus, and custom navigation controls.]]></summary></entry></feed>