<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anatoliy Dovgun</title>
    <description>The latest articles on DEV Community by Anatoliy Dovgun (@adovgun).</description>
    <link>https://dev.to/adovgun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F183424%2F7fa95050-bbfd-41df-9fb4-1f273f3efb4e.png</url>
      <title>DEV Community: Anatoliy Dovgun</title>
      <link>https://dev.to/adovgun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adovgun"/>
    <language>en</language>
    <item>
      <title>You're a Tenant, Not the Owner: 6 Doors in Someone Else's wp-admin, and How Not to Be a Bad Neighbor</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:20:55 +0000</pubDate>
      <link>https://dev.to/adovgun/youre-a-tenant-not-the-owner-6-doors-in-someone-elses-wp-admin-and-how-not-to-be-a-bad-neighbor-22fg</link>
      <guid>https://dev.to/adovgun/youre-a-tenant-not-the-owner-6-doors-in-someone-elses-wp-admin-and-how-not-to-be-a-bad-neighbor-22fg</guid>
      <description>&lt;p&gt;wp-admin isn't a blank canvas — it's a shared apartment building, and every active plugin is a tenant with a key. This is the etiquette guide nobody hands you, one rule per hook, from a real 4wp.dev deep dive.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is part of the 4wp.dev Hooks series — one WordPress hook category, explained in production depth. Full catalog: &lt;a href="https://4wp.dev/hooks/category/admin/" rel="noopener noreferrer"&gt;https://4wp.dev/hooks/category/admin/&lt;/a&gt; on 4wp.dev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Premise
&lt;/h2&gt;

&lt;p&gt;wp-admin isn't yours. The owner is WordPress core, and every active plugin is a tenant with its own key to a shared apartment. The six hooks in this category are literally six doors you've been handed: you can walk in and set up your own corner, but dozens of other tenants live there too, and how you behave determines how comfortable it is for everyone — including the person who walks in there every day, the site administrator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With a Bad Tenant
&lt;/h2&gt;

&lt;p&gt;Typical signs of bad neighborly behavior in wp-admin: your plugin's notice shows up on every single admin screen instead of just the relevant one; the footer text gets overwritten without checking whether someone already left something there — and two plugins keep erasing each other's line; a menu item uses a slug that's already taken by another plugin, so one of the two simply doesn't show up; &lt;code&gt;wp_mail_from&lt;/code&gt; gets silently hijacked, and an email that was supposed to come from a support service arrives with the "no-reply@" address of a completely different plugin.&lt;/p&gt;

&lt;p&gt;None of these situations technically break the site. They break trust: the administrator sees a mess in their own dashboard and has no idea which plugin is responsible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules of a Good Neighbor — One Per Hook
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;admin_init&lt;/code&gt; — move in quietly&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'manage_options'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;register_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'forwp_settings_group'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_api_key'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't run heavy code or checks without a reason — this hook fires on EVERY load of every admin screen, for every user, not just administrators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;admin_menu&lt;/code&gt; — don't block someone else's door&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_menu'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A unique, prefixed slug — so you don't collide with another plugin.&lt;/span&gt;
    &lt;span class="nf"&gt;add_menu_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'My Plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'My Plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'manage_options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'forwp-my-plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// not just 'settings' or 'dashboard'&lt;/span&gt;
        &lt;span class="s1"&gt;'forwp_render_settings_page'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A slug like &lt;code&gt;settings&lt;/code&gt; or &lt;code&gt;dashboard&lt;/code&gt; is a guaranteed conflict sooner or later. Prefix it with your plugin's own name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wp_mail_from&lt;/code&gt; — sign with your own name&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_mail_from'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$original_email&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only change the address for emails our own plugin sends,&lt;/span&gt;
    &lt;span class="c1"&gt;// don't hijack everything that goes through wp_mail() globally.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;forwp_is_our_mail_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$original_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'notifications@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Globally overriding the sender address without checking context is a common reason an email from a completely different plugin suddenly arrives from the wrong address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;admin_footer_text&lt;/code&gt; — check if the wall is already taken&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_footer_text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Don't silently overwrite — add your own text while keeping what was already there.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' | Powered by My Plugin'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common reason one plugin's footer text "disappears" is that another plugin simply replaces the whole string instead of appending to what's already there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;admin_notices&lt;/code&gt; — don't shout down the whole hallway&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_notices'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$screen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_screen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$screen&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'toplevel_page_forwp-my-plugin'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$screen&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Show it only on our own screen, not everywhere.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div class="notice notice-success is-dismissible"&amp;gt;&amp;lt;p&amp;gt;Settings saved.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A notice with no screen check and no &lt;code&gt;is-dismissible&lt;/code&gt; class is exactly why administrators dread plugin updates: the banner stays on every page until someone manually cleans up the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;manage_posts_columns&lt;/code&gt; — rearrange the furniture, don't throw out what isn't yours&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'manage_posts_columns'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$columns&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Add our own column without removing what others put there.&lt;/span&gt;
    &lt;span class="nv"&gt;$columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'forwp_status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Status'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$columns&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removing someone else's columns from the array without a clear reason is like throwing a neighbor's furniture out of the hallway just because it was in your way.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It's Better to Bring In a Specialist
&lt;/h2&gt;

&lt;p&gt;These rules become especially critical when wp-admin needs to be "branded" for a client — stripping the WordPress logo, showing only your own footer text, hiding menus that aren't relevant to a given user role. That's classic agency or freelance work, and doing it carefully — without breaking the client's other plugins in the process — is exactly where WordPress development services earn their keep: an experienced WordPress developer knows which hooks are safe to fully override, and where you should only append.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does the admin footer text keep appearing and disappearing?&lt;/strong&gt;&lt;br&gt;
Most often, two or more plugins are filtering &lt;code&gt;admin_footer_text&lt;/code&gt;, and each returns its own string instead of appending to what it received. The last registered filter "wins" and erases the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my admin notice show up on the media library screen when it's only relevant to my plugin's settings?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;admin_notices&lt;/code&gt; fires on every admin screen by default. You need an explicit check of the current screen via &lt;code&gt;get_current_screen()&lt;/code&gt;, otherwise the banner shows up everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to remove someone else's columns via &lt;code&gt;manage_posts_columns&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Technically yes, but without a clear reason it's bad practice — the column might have been added intentionally for a client's needs or another plugin. Only remove what genuinely duplicates your own plugin's functionality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I avoid a slug conflict in &lt;code&gt;admin_menu&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Always prefix the slug with a unique plugin identifier (&lt;code&gt;forwp-settings&lt;/code&gt;, not just &lt;code&gt;settings&lt;/code&gt;). It's the simplest and most reliable way to avoid colliding with other plugins or the theme.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;wp_mail_from&lt;/code&gt; change the address for every email on the site, or just mine?&lt;/strong&gt;&lt;br&gt;
By default, for every email sent through &lt;code&gt;wp_mail()&lt;/code&gt;, including emails from other plugins and WordPress core itself. If you only want to change the address for your own emails, you must check the context inside the filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When is it worth hiring a WordPress developer for a client's white-label wp-admin?&lt;/strong&gt;&lt;br&gt;
When you need to hide or rebrand the dashboard for a client in a way that won't break their other plugins or get in the way of future updates. That's exactly the level of nuance (context checks, prefixing, appending carefully instead of replacing) where experience with these specific hooks saves hours of rework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A full breakdown of each of the 6 hooks in this category — with examples and a hands-on IDE — is on the &lt;a href="https://4wp.dev/hooks/category/admin/" rel="noopener noreferrer"&gt;Admin Hooks&lt;/a&gt; page, and the whole set is also available as one PDF to keep.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://4wp.dev/wp-admin-hooks-tenant-not-owner-2/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>php</category>
      <category>webdev</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Headless WordPress with WPGraphQL and Next.js: From First Query to Production</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:55:27 +0000</pubDate>
      <link>https://dev.to/4wpdev/headless-wordpress-with-wpgraphql-and-nextjs-from-first-query-to-production-m98</link>
      <guid>https://dev.to/4wpdev/headless-wordpress-with-wpgraphql-and-nextjs-from-first-query-to-production-m98</guid>
      <description>&lt;p&gt;Most WPGraphQL tutorials end at the first query. This one goes further: a small Next.js (App Router) blog on top of headless WordPress, taken all the way to a production-ready setup.&lt;/p&gt;

&lt;p&gt;We'll build a paginated list, a single post page, one custom field, shared fragments, then lock the endpoint down and wire cache revalidation to the Publish button.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you want the "why" first — trade-offs of GraphQL vs REST and schema design — read the &lt;a href="https://4wp.dev/graphql/" rel="noopener noreferrer"&gt;GraphQL for Headless WordPress overview&lt;/a&gt; on 4wp.dev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WordPress with &lt;strong&gt;WPGraphQL&lt;/strong&gt; installed. The endpoint is &lt;code&gt;https://cms.example.com/graphql&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WPGraphQL for ACF&lt;/strong&gt; only if you expose ACF fields. Not needed for this walkthrough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WPGraphQL Smart Cache&lt;/strong&gt; for persisted queries and cache invalidation (step 5 and 6).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; with the App Router. No GraphQL client library: server components and &lt;code&gt;fetch&lt;/code&gt; are enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One helper does all requests. It sends queries as &lt;code&gt;GET&lt;/code&gt;, which matters later for caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/wp.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WP_GRAPHQL_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WP_GRAPHQL_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wpQuery&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WP_GRAPHQL_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;variables&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`WPGraphQL responded &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: The First Query — a Paginated Post List
&lt;/h2&gt;

&lt;p&gt;WPGraphQL uses cursor pagination: ask for &lt;code&gt;first&lt;/code&gt; N nodes, then pass &lt;code&gt;endCursor&lt;/code&gt; back as &lt;code&gt;after&lt;/code&gt; for the next page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;pageInfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;hasNextPage&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;endCursor&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;wpQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/wp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;POSTS_QUERY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/queries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;wpQuery&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PostsData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;POSTS_QUERY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasNextPage&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/blog?after=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endCursor&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Older posts
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursors are opaque strings. Don't build page numbers from them; "Older posts / Newer posts" is the natural UI for cursor pagination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The Single Post Page
&lt;/h2&gt;

&lt;p&gt;One query returns the post, its author, categories, and featured image. In REST this would be several calls or a heavy &lt;code&gt;_embed&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PostBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SLUG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;featuredImage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;altText&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;mediaDetails&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;wpQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/wp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;POST_BY_SLUG_QUERY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/queries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;wpQuery&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PostData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POST_BY_SLUG_QUERY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`post:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;content&lt;/code&gt; is rendered HTML from WordPress. That's fine when WordPress is the only trusted author of that HTML. If you need to render blocks as your own components, look at WPGraphQL Content Blocks, which exposes Gutenberg blocks as structured data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: A Custom Field in the Schema
&lt;/h2&gt;

&lt;p&gt;Register it once in WordPress, and every client can query it. Here, reading time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'graphql_register_types'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_graphql_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'readingTime'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Estimated reading time in minutes.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'resolve'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$text&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_post_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post_content'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;databaseId&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;preg_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\s+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PREG_SPLIT_NO_EMPTY&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$words&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preg_split&lt;/code&gt; with the &lt;code&gt;u&lt;/code&gt; flag instead of &lt;code&gt;str_word_count&lt;/code&gt;: the latter doesn't count Cyrillic and other non-Latin words. Add &lt;code&gt;readingTime&lt;/code&gt; to the query from step 2, and it's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Fragments — One Shape, Many Queries
&lt;/h2&gt;

&lt;p&gt;The list, the single page, and a "related posts" block all need a post card. Define that shape once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/queries.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST_CARD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* GraphQL */&lt;/span&gt; &lt;span class="s2"&gt;`
  fragment PostCard on Post {
    slug
    title
    date
    excerpt
    readingTime
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POSTS_QUERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* GraphQL */&lt;/span&gt; &lt;span class="s2"&gt;`
  query Posts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        ...PostCard
      }
    }
  }
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;POST_CARD&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the card design changes, you edit one fragment, not five queries. Keep fragments next to the components that render them, and each component asks for exactly what it displays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Production Limits
&lt;/h2&gt;

&lt;p&gt;The default WPGraphQL setup is developer-friendly, not production-safe. Before launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public introspection off.&lt;/strong&gt; GraphQL → Settings → &lt;em&gt;Enable Public Introspection&lt;/em&gt; stays unchecked. Logged-in developers still get the schema in GraphiQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query depth limit on.&lt;/strong&gt; Same settings page, &lt;em&gt;Enable Query Depth Limiting&lt;/em&gt;, with a max depth your real queries fit into (10–15 is typical).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap page size.&lt;/strong&gt; Nobody should fetch 1,000 posts in one request:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'graphql_connection_max_query_amount'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persisted queries.&lt;/strong&gt; With WPGraphQL Smart Cache, your frontend queries are saved as GraphQL documents on the server, and the allow-list rule makes the server execute only those. The frontend sends a query ID instead of the full query string, so the URL stays short, and arbitrary queries are rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Caching and Revalidation
&lt;/h2&gt;

&lt;p&gt;Because requests go out as &lt;code&gt;GET&lt;/code&gt;, they're cacheable by URL: at the CDN, by Smart Cache's network cache, and by Next.js itself. The &lt;code&gt;revalidate: 300&lt;/code&gt; in the helper is a safety net. The real trigger is publishing.&lt;/p&gt;

&lt;p&gt;In Next.js, a route handler invalidates by tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/revalidate/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-revalidate-secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REVALIDATE_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`post:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;revalidated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In WordPress, call it when a post is published, updated, or unpublished:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'transition_post_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$new_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$old_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$new_status&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$old_status&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="no"&gt;FORWP_FRONTEND_URL&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/api/revalidate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'headers'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'x-revalidate-secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;FORWP_REVALIDATE_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_name&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'blocking'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;publish → publish&lt;/code&gt; fires on updates too, so edits to a live post invalidate the cache as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes in Practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;N+1 in custom resolvers.&lt;/strong&gt; A field that runs its own query per node multiplies database calls by list size. For related objects, return a deferred load through WPGraphQL's loaders so IDs are batched across the whole list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;register_graphql_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'featuredCase'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'resolve'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$case_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;databaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_featured_case'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$case_id&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_loader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load_deferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$case_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No pagination on connections.&lt;/strong&gt; "GraphQL handles it" until one query returns every post with content. Always pass &lt;code&gt;first&lt;/code&gt;, and cap it server-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business logic inside resolvers.&lt;/strong&gt; Resolvers should be thin and call the same service layer your REST endpoints use. Otherwise two data paths drift apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;POST for everything.&lt;/strong&gt; It works in development and makes caching hard in production. Use &lt;code&gt;GET&lt;/code&gt; plus persisted queries for public reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Launch Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Public introspection is off.&lt;/li&gt;
&lt;li&gt;[ ] Query depth limit is on and tested against real frontend queries.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;graphql_connection_max_query_amount&lt;/code&gt; is capped.&lt;/li&gt;
&lt;li&gt;[ ] Frontend queries are persisted, the allow-list is on.&lt;/li&gt;
&lt;li&gt;[ ] Public reads go as &lt;code&gt;GET&lt;/code&gt; and hit a cache.&lt;/li&gt;
&lt;li&gt;[ ] Publish/update/unpublish triggers revalidation.&lt;/li&gt;
&lt;li&gt;[ ] Custom resolvers batch related lookups through loaders.&lt;/li&gt;
&lt;li&gt;[ ] A missing slug returns a real 404, not an empty page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need Apollo Client or urql with Next.js?
&lt;/h3&gt;

&lt;p&gt;Not for server components. Plain &lt;code&gt;fetch&lt;/code&gt; with Next.js caching covers reads. A client library earns its place when you have heavy client-side state, optimistic updates, or subscriptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I send GraphQL requests as GET or POST?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET&lt;/code&gt; for public reads, so responses are cacheable by URL. &lt;code&gt;POST&lt;/code&gt; for mutations and authenticated requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I expose ACF fields?
&lt;/h3&gt;

&lt;p&gt;Install WPGraphQL for ACF and enable &lt;em&gt;Show in GraphQL&lt;/em&gt; on the field group. The fields appear on the post type in the schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do drafts and previews work with this setup?
&lt;/h3&gt;

&lt;p&gt;Previews need authenticated requests and bypass the cache. That flow is covered in the &lt;a href="https://4wp.dev/preview/" rel="noopener noreferrer"&gt;preview&lt;/a&gt; and &lt;a href="https://4wp.dev/auth/" rel="noopener noreferrer"&gt;authentication&lt;/a&gt; parts of the headless series.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I render Gutenberg blocks as React components?
&lt;/h3&gt;

&lt;p&gt;Yes, with WPGraphQL Content Blocks: it returns blocks as typed data instead of one HTML string, and you map each block type to a component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;p&gt;The rest of the headless series on 4wp.dev:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/headless/" rel="noopener noreferrer"&gt;Headless &amp;amp; Decoupled WordPress: Architecture, Trade-Offs, and Production Patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/frontend/" rel="noopener noreferrer"&gt;Frontend Architecture for Headless WordPress: Next.js, SPA, and Rendering Strategy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/rest-backend/" rel="noopener noreferrer"&gt;Building a REST API Backend for Headless WordPress&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/auth/" rel="noopener noreferrer"&gt;Authentication for Headless WordPress: Beyond Cookies and Nonces&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/preview/" rel="noopener noreferrer"&gt;Draft &amp;amp; Preview in a Headless WordPress Setup&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you stay on REST instead, these go deeper on the same production concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/custom-endpoints/" rel="noopener noreferrer"&gt;Custom REST Endpoints &amp;amp; Routes in WordPress&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/performance/" rel="noopener noreferrer"&gt;REST API Performance &amp;amp; Caching for High-Traffic WordPress&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://4wp.dev/authentication/" rel="noopener noreferrer"&gt;WordPress REST API Authentication &amp;amp; Permissions: Capabilities Done Right&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://4wp.dev/practice-graphql-headless-wordpress/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Enqueue Hooks: Four Hooks, Four Different Zones — and How Not to Mix Them Up</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Thu, 24 Sep 2026 05:35:47 +0000</pubDate>
      <link>https://dev.to/adovgun/enqueue-hooks-four-hooks-four-different-zones-and-how-not-to-mix-them-up-7o</link>
      <guid>https://dev.to/adovgun/enqueue-hooks-four-hooks-four-different-zones-and-how-not-to-mix-them-up-7o</guid>
      <description>&lt;p&gt;On the surface, these five hooks all do the same thing — load CSS/JS. But four of them sound almost identical while each one runs in a different zone of the site. Mix them up, and block styles leak onto the front end where nobody asked for them, or the Gutenberg editor is left with no styling at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which zone each hook covers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Front end&lt;/th&gt;
&lt;th&gt;Admin&lt;/th&gt;
&lt;th&gt;Block editor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_enqueue_scripts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;admin_enqueue_scripts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enqueue_block_editor_assets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enqueue_block_assets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Front end&lt;/strong&gt; — the standard hook for anything a site visitor sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_enqueue_scripts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'theme-main'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_stylesheet_uri&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_script&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'theme-main'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_template_directory_uri&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/assets/main.js'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'1.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Admin&lt;/strong&gt; — a separate hook that only runs inside &lt;code&gt;wp-admin&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_enqueue_scripts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$hook_suffix&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-plugin-admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;plugins_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin.css'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Block editor only&lt;/strong&gt; — styles and JS for the editing screen that never reach the front end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'enqueue_block_editor_assets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_script&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-block-editor'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;plugins_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'editor.js'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Editor and front end at once&lt;/strong&gt; — a single hook that fires in both places. This is exactly where CSS belongs when a block needs to look the same in the editor and on the live site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'enqueue_block_assets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-block-style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;plugins_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'block.css'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common mistake is reaching for &lt;code&gt;enqueue_block_assets&lt;/code&gt; when you actually need &lt;code&gt;enqueue_block_editor_assets&lt;/code&gt; (or vice versa): the style either shows up somewhere it shouldn't, or fails to show up where it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;admin_enqueue_scripts&lt;/code&gt; and performance: check &lt;code&gt;$hook_suffix&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The hook passes &lt;code&gt;$hook_suffix&lt;/code&gt; — an identifier for the current admin screen — for exactly this reason: so you don't load files everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: this CSS/JS loads on EVERY wp-admin screen —&lt;/span&gt;
&lt;span class="c1"&gt;// every post, every settings page, the media library.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_enqueue_scripts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-plugin-admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;plugins_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin.css'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Good: only on the plugin's own screen.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_enqueue_scripts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$hook_suffix&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'toplevel_page_my-plugin'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$hook_suffix&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-plugin-admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;plugins_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin.css'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of the most common reasons wp-admin feels slow: every plugin loads its files on every screen "just in case," when they're only needed on one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;script_loader_tag&lt;/code&gt;: a targeted fix, not a blanket filter
&lt;/h2&gt;

&lt;p&gt;When you need to add &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;defer&lt;/code&gt; to a specific script, the worst approach is filtering every tag without checking &lt;code&gt;$handle&lt;/code&gt; — that can break the load order of scripts you don't own (jQuery dependencies, for example). The right way is to target by handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'script_loader_tag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$src&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-plugin-widget'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;' src'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' defer src'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter only applies to scripts registered through &lt;code&gt;wp_enqueue_script()&lt;/code&gt; — one more reason not to print &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags by hand (more on that in the Head &amp;amp; Meta article).&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Before you write &lt;code&gt;add_action&lt;/code&gt;, answer one question first: where exactly does this file need to load — front end, admin, editor, or editor and front end at once. Picking the right hook here solves 80% of problems with missing or leaking styles. A full breakdown of every hook with examples and a hands-on IDE is on the &lt;a href="https://4wp.dev/hooks/category/enqueue/" rel="noopener noreferrer"&gt;Enqueue Hooks&lt;/a&gt; category page, and the whole set is also available as one PDF.&lt;/p&gt;

</description>
      <category>wordpress</category>
    </item>
    <item>
      <title>A Hook That Fires Twice — and How Not to Trigger an Infinite Loop When Saving a Post</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Tue, 22 Sep 2026 20:29:23 +0000</pubDate>
      <link>https://dev.to/adovgun/a-hook-that-fires-twice-and-how-not-to-trigger-an-infinite-loop-when-saving-a-post-446c</link>
      <guid>https://dev.to/adovgun/a-hook-that-fires-twice-and-how-not-to-trigger-an-infinite-loop-when-saving-a-post-446c</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A typical case: a developer hooks logic onto &lt;code&gt;save_post&lt;/code&gt; — update a counter, sync an external service, recalculate something based on the post's fields. The code runs... and immediately runs again. And again. &lt;code&gt;save_post&lt;/code&gt; fires multiple times for a single save — on autosave, on revision creation, and finally on the actual post. If the "quick" fix is to call &lt;code&gt;wp_update_post()&lt;/code&gt; inside that handler to patch something, it triggers &lt;code&gt;save_post&lt;/code&gt; a second time, which calls &lt;code&gt;wp_update_post()&lt;/code&gt; again, which fires &lt;code&gt;save_post&lt;/code&gt; again. The result is recursion that either hangs the save, duplicates the action, or crashes WordPress outright with "Allowed memory size exhausted" — or just an endless spinner in the admin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Dangerous
&lt;/h2&gt;

&lt;p&gt;This isn't some edge case — it's one of the most common reasons a "simple" post save suddenly hangs the admin. The problem is compounded by the fact that the same hook often carries logic from several plugins at once: an SEO plugin, a caching plugin, your own custom code — and recursion in just one of them can bring down the save for all of them. On large sites with thousands of posts it's also a direct load on the database: every extra &lt;code&gt;wp_update_post()&lt;/code&gt; call means another &lt;code&gt;UPDATE&lt;/code&gt; and another full run of the entire save hook chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Solution
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Guard checks: filter out autosaves and revisions immediately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without this, every &lt;code&gt;save_post&lt;/code&gt; handler runs at least twice as often as it needs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$update&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Skip autosaves, revisions, and other post types right away.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_is_post_autosave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;wp_is_post_revision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'edit_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Your code here.&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — If you need to update the post itself inside &lt;code&gt;save_post&lt;/code&gt;: break the recursion manually&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;wp_update_post()&lt;/code&gt; inside &lt;code&gt;save_post&lt;/code&gt; is the classic cause of an infinite loop. The fix is to remove the handler for the duration of the call and add it back right after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad — infinite recursion: save_post calls wp_update_post(),&lt;/span&gt;
&lt;span class="c1"&gt;// which fires save_post again, and so on in a loop.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_update_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'ID'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;forwp_generate_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Good — remove the handler before updating, add it back immediately after.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_normalize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;forwp_normalize_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$update&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_is_post_autosave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;wp_is_post_revision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;remove_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_normalize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;wp_update_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'ID'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;forwp_generate_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_normalize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — The right hook for the right task&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The four hooks in this category look interchangeable, but each is meant for a specific moment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;When it fires&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transition_post_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before the save, exactly when the status changes&lt;/td&gt;
&lt;td&gt;Reacting to a specific transition (draft → publish, publish → trash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_insert_post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Right after the database write, before the cache is cleared&lt;/td&gt;
&lt;td&gt;Quick actions immediately after insertion — logging, notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After &lt;code&gt;wp_insert_post&lt;/code&gt;, more stable, the default choice for most plugins&lt;/td&gt;
&lt;td&gt;General metadata-handling logic on save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;before_delete_post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Right before deletion, while the post still exists&lt;/td&gt;
&lt;td&gt;Cleaning up related data — the last chance before the record is gone for good&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React specifically to publishing, not to every draft save.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'transition_post_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$new_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$old_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$new_status&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$old_status&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;forwp_notify_subscribers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Clean up related data before deletion — while the post still exists in the database.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'before_delete_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;forwp_cleanup_related_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common mistake is reaching for &lt;code&gt;save_post&lt;/code&gt; where &lt;code&gt;transition_post_status&lt;/code&gt; is actually what's needed: then the "on publish" logic runs on every draft save instead of only on the real status transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It's Better to Bring In a Specialist
&lt;/h2&gt;

&lt;p&gt;These three steps cover most typical situations. But there are cases where debugging recursion yourself costs more than bringing in a specialist: production with active traffic, where a looping &lt;code&gt;save_post&lt;/code&gt; blocks editing for the entire editorial team; a site with a dozen plugins already hooked onto the same action, where new code conflicts in ways that are hard to trace; or bulk post imports, where every extra &lt;code&gt;save_post&lt;/code&gt; cycle multiplies across thousands of records into hours of unnecessary database load. In these cases, a WordPress developer with real hands-on experience in save/CRUD hooks usually finds the recursion point in minutes, not hours — this is the case where WordPress development services end up cheaper than self-guided debugging in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;save_post&lt;/code&gt; fire multiple times for one save?&lt;/strong&gt;&lt;br&gt;
WordPress creates a revision and may run an autosave before the final post write — each of these also triggers &lt;code&gt;save_post&lt;/code&gt;. Without &lt;code&gt;wp_is_post_autosave()&lt;/code&gt;/&lt;code&gt;wp_is_post_revision()&lt;/code&gt; checks, your code runs on every one of them, not just on the real save.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know it's actual infinite recursion and not just slow code?&lt;/strong&gt;&lt;br&gt;
The classic sign is a fatal error — "Allowed memory size exhausted" or "Maximum execution time exceeded" — appearing specifically on post save, and it shows up only when a CRUD hook like &lt;code&gt;save_post&lt;/code&gt; calls a function that itself triggers that same hook (&lt;code&gt;wp_update_post()&lt;/code&gt;, &lt;code&gt;wp_insert_post()&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can several of these hooks be used together?&lt;/strong&gt;&lt;br&gt;
Yes, and that's normal — they cover different moments. A typical setup: &lt;code&gt;transition_post_status&lt;/code&gt; reacts to publishing, &lt;code&gt;save_post&lt;/code&gt; updates metadata, &lt;code&gt;before_delete_post&lt;/code&gt; cleans up related records. Conflicts don't come from combining them, but from missing guard checks in each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between &lt;code&gt;wp_insert_post&lt;/code&gt; and &lt;code&gt;save_post&lt;/code&gt; if both fire "on save"?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;wp_insert_post&lt;/code&gt; fires earlier, right after the database write, before the post cache is cleared. &lt;code&gt;save_post&lt;/code&gt; fires slightly later and is considered the more stable default choice for most plugins — which is why it's the most commonly used of the four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;before_delete_post&lt;/code&gt; also fire when a post is moved to trash?&lt;/strong&gt;&lt;br&gt;
No. It only fires on permanent deletion, not on &lt;code&gt;trash&lt;/code&gt;. Reacting to a move to trash needs a separate hook (&lt;code&gt;wp_trash_post&lt;/code&gt;) — this one is specifically the last moment before the record is physically removed from the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I hire a WordPress developer instead of figuring it out myself?&lt;/strong&gt;&lt;br&gt;
When recursion or a hook conflict shows up in production with real users, during bulk imports, or when several plugins are already filtering the same CRUD hooks. The cost of an hour of editorial downtime usually outweighs the cost of a specialist who can pinpoint and break the loop precisely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A full breakdown of each of the 4 hooks in this category — with examples and a hands-on IDE — is on the &lt;a href="https://4wp.dev/hooks/category/save-crud/" rel="noopener noreferrer"&gt;Save &amp;amp; CRUD Hooks&lt;/a&gt; page, and the whole set is also available as one PDF to keep.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Window of Opportunity: When Each WordPress Bootstrap Hook Is Open, and When It’s Already Closed</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:40:00 +0000</pubDate>
      <link>https://dev.to/adovgun/the-window-of-opportunity-when-each-wordpress-bootstrap-hook-is-open-and-when-its-already-closed-500o</link>
      <guid>https://dev.to/adovgun/the-window-of-opportunity-when-each-wordpress-bootstrap-hook-is-open-and-when-its-already-closed-500o</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;register_post_type()&lt;/code&gt; called in &lt;code&gt;plugins_loaded&lt;/code&gt; — and the site crashes with a fatal error, because taxonomies aren't ready yet. Or &lt;code&gt;add_theme_support( 'post-thumbnails' )&lt;/code&gt; called in &lt;code&gt;init&lt;/code&gt; — no error, thumbnails just quietly never show up, because WordPress has already passed the point where a theme declares feature support. Or a plugin's text domain gets loaded after translatable strings have already been output, and the translation simply doesn't take.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Too early: register_post_type() in plugins_loaded — taxonomies and other&lt;/span&gt;
&lt;span class="c1"&gt;// plugins' dependencies aren't ready yet, fatal error or unpredictable behavior.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'plugins_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_post_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'portfolio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Too late: WordPress has already passed the point where a theme&lt;/span&gt;
&lt;span class="c1"&gt;// declares feature support — the call is simply ignored, no error at all.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add_theme_support&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post-thumbnails'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both examples are correct code in the wrong hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Dangerous
&lt;/h2&gt;

&lt;p&gt;This isn't always a reproducible bug. Whether it fires or not often depends on plugin load order, theme version, or whether object caching is active. The classic situation: "works on my local," but on the client's production it silently doesn't — with nothing in the error log. It's hard to debug precisely because the symptom points nowhere near the cause: a missing featured image thumbnail looks like a theme problem, when the real issue is a hook called one step too late.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Solution — 5 Windows, in Order
&lt;/h2&gt;

&lt;p&gt;Each of these five hooks opens a narrow window for a specific class of actions. Miss your window, and the action either fails outright (closed too early) or silently does nothing (closed for good).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — &lt;code&gt;muplugins_loaded&lt;/code&gt;: the earliest window, before any regular plugin&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The earliest point in the entire WordPress lifecycle — before plugins, before the theme.&lt;/span&gt;
&lt;span class="c1"&gt;// Used for network-wide/security settings that can't be disabled from wp-admin&lt;/span&gt;
&lt;span class="c1"&gt;// (mu-plugins don't even have a "Deactivate" button).&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'muplugins_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'FORWP_SECURITY_BASELINE'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'FORWP_SECURITY_BASELINE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — &lt;code&gt;plugins_loaded&lt;/code&gt;: plugins are loaded, the theme isn't yet&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The right place for a text domain and for checking dependencies between&lt;/span&gt;
&lt;span class="c1"&gt;// plugins — the plugins themselves are already loaded, so class_exists() is safe here.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'plugins_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;load_plugin_textdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my-plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;plugin_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/languages'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WooCommerce'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Dependency missing — disable the feature instead of throwing a fatal error.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — &lt;code&gt;after_setup_theme&lt;/code&gt;: the one correct place to declare theme feature support&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'after_setup_theme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add_theme_support&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post-thumbnails'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;add_theme_support&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'title-tag'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;register_nav_menus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'primary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Primary Menu'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'my-theme'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4 — &lt;code&gt;init&lt;/code&gt;: the environment is ready, safe to register content&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_post_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'portfolio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Portfolio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5 — &lt;code&gt;wp_loaded&lt;/code&gt;: everything is loaded, the request hasn't been parsed yet&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The last safe moment before request-specific logic begins — plugins and the&lt;/span&gt;
&lt;span class="c1"&gt;// theme are fully loaded, but WordPress hasn't parsed the actual request yet.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'forwp_debug'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'manage_options'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;forwp_dump_registered_post_types&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Window open for&lt;/th&gt;
&lt;th&gt;Too early (before this hook)&lt;/th&gt;
&lt;th&gt;Too late (after this hook)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;muplugins_loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Network-wide/security settings that can't be disabled from the admin&lt;/td&gt;
&lt;td&gt;No earlier hook exists — this is the start&lt;/td&gt;
&lt;td&gt;Plugins have already begun loading — too late for "non-toggleable" code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plugins_loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text domain loading, checking dependencies between plugins&lt;/td&gt;
&lt;td&gt;No plugins exist yet — &lt;code&gt;class_exists()&lt;/code&gt; is always false&lt;/td&gt;
&lt;td&gt;The theme has already begun loading, some checks lose their point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;after_setup_theme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;add_theme_support()&lt;/code&gt;, &lt;code&gt;register_nav_menus()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The theme isn't hooked up yet&lt;/td&gt;
&lt;td&gt;WordPress has already passed the point of registering theme features — silently ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;init&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;register_post_type()&lt;/code&gt;, &lt;code&gt;register_taxonomy()&lt;/code&gt;, shortcodes&lt;/td&gt;
&lt;td&gt;Taxonomies/dependencies aren't ready — fatal error&lt;/td&gt;
&lt;td&gt;Still possible later, but some systems (permalinks) may have already gone around it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logic that needs a fully ready environment, before the request is parsed&lt;/td&gt;
&lt;td&gt;Plugins or the theme haven't finished loading everything&lt;/td&gt;
&lt;td&gt;The request is already being parsed — too late for environment setup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When It's Better to Bring In a Specialist
&lt;/h2&gt;

&lt;p&gt;Five hooks look like a simple table when you have one plugin and one theme. On a real project with dozens of plugins, each hanging code off its own bootstrap hook, a load-order bug becomes nearly impossible to reproduce on your own: it works in one environment and breaks in another, with no error message anywhere. Auditing load order is exactly the case where a WordPress developer with hands-on experience in the bootstrap sequence finds the cause in minutes, because they've seen this exact picture dozens of times before. This is one of the clearest cases where WordPress development services pay for themselves — the trial-and-error cost of hunting this kind of bug alone is usually far higher than the cost of a specialist's consultation.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;register_post_type()&lt;/code&gt; sometimes throw a fatal error, and other times just "not work"?&lt;/strong&gt;&lt;br&gt;
It depends on how early the hook fires. In &lt;code&gt;plugins_loaded&lt;/code&gt;, taxonomies and other plugins' dependencies aren't ready yet — a fatal error is possible. In &lt;code&gt;init&lt;/code&gt;, everything is in place — it's just too late for some related systems (permalinks, for example, may have already gone around it).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I call &lt;code&gt;add_theme_support()&lt;/code&gt; in &lt;code&gt;init&lt;/code&gt; instead of &lt;code&gt;after_setup_theme&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Technically the call won't throw an error, but part of WordPress has already checked for theme feature support before &lt;code&gt;init&lt;/code&gt; and didn't see it — the feature simply doesn't activate. &lt;code&gt;after_setup_theme&lt;/code&gt; is the one reliable place for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;muplugins_loaded&lt;/code&gt; even exist if there's already &lt;code&gt;plugins_loaded&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;muplugins_loaded&lt;/code&gt; fires earlier and, more importantly, applies to code placed in &lt;code&gt;mu-plugins&lt;/code&gt; — code that can't be disabled from the admin. That's a fundamentally different level of trust and control, which is why it gets its own, earliest hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a hook between &lt;code&gt;plugins_loaded&lt;/code&gt; and &lt;code&gt;after_setup_theme&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Yes, &lt;code&gt;setup_theme&lt;/code&gt; — but it isn't documented in the catalog yet (a known gap). For most tasks this doesn't matter, since &lt;code&gt;after_setup_theme&lt;/code&gt; covers nearly every typical theme-setup scenario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the same code work locally but break in production?&lt;/strong&gt;&lt;br&gt;
Most often — a different plugin load order between environments, different theme versions, or object caching masking the problem on one server while it surfaces on another. The symptom is the same either way: code running in the wrong window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When is it worth hiring a WordPress developer to audit load order?&lt;/strong&gt;&lt;br&gt;
When the project scales up — a new plugin or theme update suddenly "breaks" functionality that used to work, with nothing obvious in the logs. That's a typical sign of a load-order conflict, and an experienced developer checks the bootstrap chain first, before looking anywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A full breakdown of each of the 5 hooks in this category — with examples and a hands-on IDE — is on the &lt;a href="https://4wp.dev/hooks/category/bootstrap/" rel="noopener noreferrer"&gt;Bootstrap Hooks&lt;/a&gt; page, and the whole set is also available as one PDF to keep.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>php</category>
      <category>programming</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>AI Workflow Automation in WordPress: Where the 4WP Plugins Are Headed</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sun, 13 Sep 2026 21:25:04 +0000</pubDate>
      <link>https://dev.to/adovgun/ai-workflow-automation-in-wordpress-where-the-4wp-plugins-are-headed-1la8</link>
      <guid>https://dev.to/adovgun/ai-workflow-automation-in-wordpress-where-the-4wp-plugins-are-headed-1la8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I've probably worn out my welcome by now with this old-school habit of mine — explaining DDD or SDD, or pushing for TDD (which, in practice, almost nobody actually uses, except people with... particular tastes, or the ones who just wanted to try something backwards 😏), and plenty more besides.&lt;/p&gt;

&lt;p&gt;I'm a believer in architectural approaches — OOP, SOLID, the Essential Principles, DRY — but never all of them, all at once. And nothing beats WordPress Hooks for me: &lt;a href="https://dev.to/development/hooks/actions/"&gt;Actions&lt;/a&gt; and &lt;a href="https://dev.to/development/hooks/filters/"&gt;Filters&lt;/a&gt; work small miracles.&lt;/p&gt;

&lt;p&gt;But today's list has a new entry: AI — and it can help enormously, on one condition: that we actually explain things to it, and point it at what's specific to the WordPress ecosystem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole thesis of this post: WordPress specifics, explained once and well — not to a person this time, but to a model.&lt;/p&gt;

&lt;p&gt;"AI workflow automation" gets used for two very different things in WordPress right now: a chatbot bolted onto wp-admin, and a site an agent can actually &lt;em&gt;operate&lt;/em&gt; — reading its content, calling its plugins, taking permissioned actions, the way a logged-in editor would. This post is about the second kind, and specifically about where it's headed across the 4WP plugin ecosystem.&lt;/p&gt;

&lt;p&gt;A note before anything else: not everything below is shipped. Some of it is architecture that already exists — the Abilities API, MCP. Some of it is work we started this week. The rest is direction, written down as a roadmap, not a feature announcement. We'd rather show the plan honestly than dress up intentions as done deals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "AI workflow automation" means for a plugin, not a chatbot
&lt;/h2&gt;

&lt;p&gt;A chatbot answers questions about a site. Workflow automation &lt;em&gt;does things&lt;/em&gt; on it — drafts a FAQ entry from a support transcript, flags a notification rule that no longer matches real traffic, restructures internal links across a content set — with a human approving the consequential steps. The difference is permissions and scope: a well-built automation only touches what it's explicitly allowed to, and only through the same contracts a human editor would use.&lt;/p&gt;

&lt;p&gt;That's why this isn't built as "add an AI tab to each plugin." It's built on two primitives already documented for the 4WP ecosystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Abilities API&lt;/strong&gt; — a plugin declares, with a permission callback, what an AI workflow is allowed to read or do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; — how an agent outside wp-admin (Claude Desktop, Cursor, a CI bot) reaches those same abilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An ability written once works identically whether it's triggered in-editor or by an external agent. See &lt;a href="https://dev.to/ai/wordpress/"&gt;AI &amp;amp; WordPress&lt;/a&gt; for the Connectors/AI Client layer, and &lt;a href="https://dev.to/ai/mcp/"&gt;MCP for WordPress&lt;/a&gt; for the external-agent side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is headed, plugin by plugin
&lt;/h2&gt;

&lt;p&gt;None of these are shipped features today — they're the concrete shape AI workflow automation takes once an ability exists for each plugin's domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4WP SEO Helper&lt;/strong&gt; — first in line. The planned AI-assist layer reuses the existing SEO inventory and Dynamics history: an agent (in-editor or via MCP) proposes a meta/title change, the same permission check that gates a human edit gates the agent's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP FAQ&lt;/strong&gt; — an agent drafts a FAQ entry from a support ticket or transcript; a human reviews before it's published to the FAQ registry, not auto-published.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP Notifications&lt;/strong&gt; — a workflow watches for a pattern (a spike, a repeated failure) and raises a notification rule instead of a human noticing it three days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP Drive&lt;/strong&gt; — extends the existing Google Docs → post pipeline: an agent normalizes front-matter and flags what's ready for editorial review, on top of the import that already works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP Smart Link&lt;/strong&gt; — AI-assisted internal linking suggestions across a Query Loop or content set, surfaced for approval rather than applied blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP MCP Abilities&lt;/strong&gt; — the connective tissue underneath all of the above: this is where abilities get registered and exposed to MCP clients in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4WP AI Assistant&lt;/strong&gt; (built for LMS4WP) — the pattern already proven once, in one product. The next step is generalizing it into the shared &lt;code&gt;4wp-bundle&lt;/code&gt; layer so any 4WP plugin can register abilities the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The no-code path: n8n and external automation
&lt;/h2&gt;

&lt;p&gt;Not every team wants custom code for this. The same abilities a plugin exposes over MCP can, in principle, sit behind an &lt;strong&gt;n8n&lt;/strong&gt; workflow instead of a bespoke agent — a scheduled or webhook-triggered n8n run that calls a WordPress ability is a legitimate AI workflow automation for WordPress, just without a developer writing the calling code.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical: we published a first n8n template — an AI Agent node (LLM + memory + tools) wired to the 4WP FAQ and 4WP Notifications REST endpoints instead of a closed, vendor-specific action list. Same agent shape you'll see in proprietary "AI agent for WordPress" plugins, but open, importable, and pointed at your own site's contracts rather than a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening this week
&lt;/h2&gt;

&lt;p&gt;To be direct about status: AI Connector work starts now, inside the plugins, not just as a content topic. Resources for scaling it across every plugin at once are limited, so the realistic path is incremental — landing first where the abilities are already partly modeled (SEO Helper), then expanding plugin by plugin as capacity allows. If a specific plugin above matters more to your workflow than the order suggests, that's useful signal — say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it on Abilities + MCP instead of one AI feature per plugin
&lt;/h2&gt;

&lt;p&gt;The same rule that governs every 4WP plugin applies here: model the domain, then wire the integration. An AI provider is a detail behind a contract (Connectors), a capability is a permission-checked operation (Abilities), and an external agent — or an n8n workflow, or a CI bot — is just another consumer of that contract (MCP). That's what agentic workflows for WordPress look like in practice once you stop treating "AI" as a bolt-on feature and start treating it as another caller of the same permission-checked contracts a human uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this replace the developer building the plugin?&lt;/strong&gt; No — an ability still has to be modeled and permission-checked by a developer; the agent only calls what's already been deliberately exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need n8n to use AI workflow automation with WordPress?&lt;/strong&gt; No. The native path is Abilities + MCP, no-code or not. n8n is one possible caller on top of that same layer, useful for teams that don't want to write the calling code themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/ai/wordpress/" rel="noopener noreferrer"&gt;AI &amp;amp; WordPress&lt;/a&gt; — Connectors, Abilities API, AI Client in depth.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/ai/mcp/" rel="noopener noreferrer"&gt;MCP for WordPress&lt;/a&gt; — the external-agent side of this.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/" rel="noopener noreferrer"&gt;AI Workflows hub&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>software</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>4WP-Booking: How One Dental Clinic’s Request Became a Domain-Driven WordPress Booking Platform</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:41:13 +0000</pubDate>
      <link>https://dev.to/adovgun/4wp-booking-how-one-dental-clinics-request-became-a-domain-driven-wordpress-booking-platform-2fm6</link>
      <guid>https://dev.to/adovgun/4wp-booking-how-one-dental-clinics-request-became-a-domain-driven-wordpress-booking-platform-2fm6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Let's use a real case — a dental clinic and its appointment booking — to walk through what client-centricity, domain thinking, and scalability actually look like in WordPress development.&lt;/p&gt;

&lt;p&gt;Let's dive into WordPress development — domain first, integration second.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most WordPress booking plugins start the same way: a client needs to connect their site to a specific tool, so a developer wires up that one API and ships it. It works — until the second client shows up with a different calendar system, and the "quick integration" has to be rebuilt from scratch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/4wpdev/4wp-booking" rel="noopener noreferrer"&gt;4WP-Booking&lt;/a&gt; started from exactly that kind of request — a dental practice that needed native, on-site appointment scheduling connected to &lt;a href="https://clinic-cards.com/" rel="noopener noreferrer"&gt;Clinic Cards&lt;/a&gt;, the CRM it already ran on. But instead of shipping a "Clinic Cards plugin," we treated the request as a design problem first and an integration second. This article walks through why, using &lt;a href="https://4wp.dev/methodologies/domain-driven-design/" rel="noopener noreferrer"&gt;Domain-Driven Design&lt;/a&gt; and a lightweight &lt;a href="https://4wp.dev/methodologies/sdd/" rel="noopener noreferrer"&gt;Software Design Document&lt;/a&gt; process — the same methodologies we apply across every plugin built on &lt;a href="https://github.com/4wpdev/4wp-bundle" rel="noopener noreferrer"&gt;4wp-bundle&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request behind the plugin
&lt;/h2&gt;

&lt;p&gt;The trigger for 4WP-Booking was a real, live clinic: &lt;a href="https://hrechkovskyi.com/booking/" rel="noopener noreferrer"&gt;Dr. Hrechkovskyi's dental practice in Lutsk&lt;/a&gt;. Patients needed to pick a service, a doctor, or a date, see real availability, and book directly from the clinic's WordPress site — without a phone call, and without the clinic's staff re-entering anything into Clinic Cards by hand.&lt;/p&gt;

&lt;p&gt;That's a completely reasonable, narrow request. The trap is answering it narrowly. If "booking" is modeled as "talk to the Clinic Cards API," every future requirement — a second CRM, a different industry, a client who uses Google Calendar instead of a clinic system — turns into a rewrite. If "booking" is modeled as its own domain, with Clinic Cards as one way of fulfilling it, the plugin can grow without breaking.&lt;/p&gt;

&lt;p&gt;That distinction is the whole argument for Domain-Driven Design, and it's why we reached for it here instead of just shipping an API wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling booking as a domain, not an API call
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://4wp.dev/methodologies/domain-driven-design/" rel="noopener noreferrer"&gt;DDD&lt;/a&gt; starts from a simple premise: the business model should drive the architecture, not the other way around. Before writing anything, we asked what "booking" actually &lt;em&gt;means&lt;/em&gt;, independent of Clinic Cards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An &lt;strong&gt;appointment&lt;/strong&gt; is an entity — it has an identity, a status, and a lifecycle (requested, confirmed, completed, cancelled) that persists regardless of which calendar system stores it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;time slot&lt;/strong&gt; and a &lt;strong&gt;service&lt;/strong&gt; are value objects — defined entirely by their attributes, not by identity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &lt;strong&gt;provider&lt;/strong&gt; — Clinic Cards today, something else tomorrow — is a detail the domain depends on, not the thing the domain is built around.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Framed this way, Clinic Cards stops being the foundation of the plugin and becomes an implementation of a contract. WordPress itself plays the same supporting role: it renders the calendar, exposes the shortcode and Gutenberg block, and stores configuration — but the booking logic doesn't know it's running inside WordPress, and it doesn't know it's talking to Clinic Cards. That separation is what "domain logic stays independent and portable" means in practice, and it's the reason the same approach scales to every plugin in the 4WP ecosystem, not just this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sketching the SDD before writing a line of integration code
&lt;/h2&gt;

&lt;p&gt;Where DDD answers &lt;em&gt;how the domain is modeled&lt;/em&gt;, a &lt;a href="https://4wp.dev/methodologies/sdd/" rel="noopener noreferrer"&gt;Software Design Document&lt;/a&gt; answers &lt;em&gt;how the system gets built&lt;/em&gt; — architecture, data flow, and build order, captured before implementation rather than discovered during it. For 4WP-Booking, that meant working through five questions up front:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Requirements&lt;/strong&gt; — What's the MVP? Book by service, by doctor, or by date; capture patient name, phone, email; nothing more.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Architecture&lt;/strong&gt; — How do the pieces talk to each other? A WordPress-facing surface (block, shortcode, optional Elementor widget), a booking domain layer, and a provider layer behind an interface.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Data model&lt;/strong&gt; — Where do appointments, providers, and credentials live, and what depends on what?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Data flow&lt;/strong&gt; — How does a click on "9:00 AM, Dr. Kozak" become a confirmed slot in Clinic Cards and a confirmation on screen?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Implementation order&lt;/strong&gt; — Build the provider contract first, the Clinic Cards implementation second, the WordPress surfaces last — so the parts most likely to change (the UI) depend on the parts least likely to change (the domain), and not the reverse.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roughly, the shape that came out of that process looks like this:&lt;/p&gt;

&lt;p&gt;WordPress surface → Shortcode · Gutenberg block · Elementor widget&lt;/p&gt;

&lt;p&gt;(interchangeable, no domain knowledge)&lt;/p&gt;

&lt;p&gt;Booking domain → Appointment · Service · Availability&lt;/p&gt;

&lt;p&gt;(framework-agnostic, provider-agnostic)&lt;/p&gt;

&lt;p&gt;Provider contract → a single interface every calendar&lt;/p&gt;

&lt;p&gt;integration must satisfy&lt;/p&gt;

&lt;p&gt;Providers → Clinic Cards (live)&lt;/p&gt;

&lt;p&gt;Google Calendar (planned)&lt;/p&gt;

&lt;p&gt;Calendly (planned)&lt;/p&gt;

&lt;p&gt;Nothing above this diagram's provider layer needs to change when a new provider is added. That's the point of designing the document before the integration: it turns "add Calendly support" from an architectural risk into a scheduling decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the provider stayed pluggable
&lt;/h2&gt;

&lt;p&gt;Clinic Cards is a strong first provider for a reason — it's purpose-built for clinics, which was exactly what the originating request needed. But the WordPress booking market is much bigger than clinics, and two names dominate general-purpose scheduling: &lt;strong&gt;Google Calendar&lt;/strong&gt;, which almost every business already uses, and &lt;strong&gt;Calendly&lt;/strong&gt;, which has become the default "just send me a link" tool for services and consultations.&lt;/p&gt;

&lt;p&gt;Because the provider sits behind a contract rather than being hard-coded into the domain, both are roadmap items rather than rewrites. The domain doesn't need to know whether "check availability" means calling Clinic Cards' schedule endpoint or reading free/busy blocks from a Google Calendar — it only needs an answer that fits the same shape. That's the practical payoff of doing the domain modeling and the design document first: the second and third integrations get cheaper instead of more expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  API keys, and why the server holds them
&lt;/h2&gt;

&lt;p&gt;One WordPress-specific decision is worth calling out on its own: where credentials live. 4WP-Booking keeps every provider API key — the Clinic Cards token today, a Google or Calendly credential tomorrow — on the server side, inside WordPress. The browser never sees it. Every call to the provider's API is made from WordPress itself, not from the visitor's session, which matters as soon as more than one provider (and more than one client's credentials) is in play. It's a small architectural rule, but it's the kind of rule that's easy to skip when a plugin is built as a one-off integration and much harder to skip when it's designed as a domain with a provider contract from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a "WordPress Methodologies" story, not just a plugin note
&lt;/h2&gt;

&lt;p&gt;DDD explains how to model the domain. SDD explains how to plan the build. Neither replaces the other, and neither is specific to booking — they're the same methodologies we use across the 4WP plugin family, from &lt;a href="https://wordpress.org/plugins/4wp-smart-link/" rel="noopener noreferrer"&gt;4WP Smart Link&lt;/a&gt; to &lt;a href="https://wordpress.org/plugins/4wp-faq/" rel="noopener noreferrer"&gt;4WP FAQ&lt;/a&gt; to &lt;a href="https://github.com/4wpdev/4wp-weather" rel="noopener noreferrer"&gt;4WP Weather&lt;/a&gt;, all built on the shared &lt;a href="https://github.com/4wpdev/4wp-bundle" rel="noopener noreferrer"&gt;4wp-bundle&lt;/a&gt; foundation. The pattern holds regardless of the vertical: identify the domain independent of the first integration, document the structure before building it, and keep the provider — CRM, calendar, weather API, whatever it is — behind a contract instead of woven through the plugin.&lt;/p&gt;

&lt;p&gt;That discipline is also why a client request that starts this specific — "connect my WordPress site to Clinic Cards" — is worth treating as a platform decision rather than a one-off script. It costs more up front than an API wrapper would. It costs a lot less the second time a client asks for something adjacent.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's worth bringing in an architect
&lt;/h2&gt;

&lt;p&gt;Not every booking integration needs this much ceremony. A single site, a single provider, no plans to reuse the plugin elsewhere — that's a legitimate case for a lighter-weight, direct integration, and DDD's own guidance is to skip the heavier patterns for exactly that kind of simple, low-complexity build.&lt;/p&gt;

&lt;p&gt;But the moment a project needs to support more than one provider, more than one client with different requirements, or a booking flow with real business rules attached (cancellation policies, deposits, multi-staff scheduling), it's worth having a developer who thinks in domains and design documents, not just endpoints — someone who can own the provider contract and the data model as the integration list grows, rather than patching a new if branch in for every new calendar service. That's the difference between a plugin that supports Calendly next quarter and one that has to be rebuilt to support it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2F4wp.dev%2Fwp-content%2Fuploads%2F2026%2F09%2Fimage-4wp-booking-ddd-sdd-article-md-1024x581.png%2520align%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2F4wp.dev%2Fwp-content%2Fuploads%2F2026%2F09%2Fimage-4wp-booking-ddd-sdd-article-md-1024x581.png%2520align%3D" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 4WP-Booking stands today
&lt;/h2&gt;

&lt;p&gt;4WP-Booking is live and open source on &lt;a href="https://github.com/4wpdev/4wp-booking" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and its Clinic Cards integration is running in production on &lt;a href="https://hrechkovskyi.com/booking/" rel="noopener noreferrer"&gt;Dr. Hrechkovskyi's clinic site&lt;/a&gt; today. It's currently working through the WordPress.org plugin review process, with submission planned once the current plugin ahead of it in the queue clears verification. Google Calendar and Calendly support are next on the roadmap — and thanks to the provider contract the domain was built around, adding them is a matter of implementing the interface, not redesigning the plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://4wp.dev/plugin/4wp-booking/" rel="noopener noreferrer"&gt;4WP-Booking plugin page&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/4wpdev/4wp-booking" rel="noopener noreferrer"&gt;4WP-Booking on GitHub&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://hrechkovskyi.com/booking/" rel="noopener noreferrer"&gt;Dr. Hrechkovskyi Dental Clinic — live booking example&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://4wp.dev/methodologies/domain-driven-design/" rel="noopener noreferrer"&gt;Domain-Driven Design — 4wp.dev methodologies&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://4wp.dev/methodologies/sdd/" rel="noopener noreferrer"&gt;Software Design Document (SDD) — 4wp.dev methodologies&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/4wpdev/4wp-bundle" rel="noopener noreferrer"&gt;4wp-bundle on GitHub&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>opensource</category>
      <category>software</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>SOLID in the WordPress Ecosystem: What 4wp.dev's Plugins Actually Prove</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sun, 06 Sep 2026 10:03:17 +0000</pubDate>
      <link>https://dev.to/adovgun/solid-in-the-wordpress-ecosystem-what-4wpdevs-plugins-actually-prove-3h5</link>
      <guid>https://dev.to/adovgun/solid-in-the-wordpress-ecosystem-what-4wpdevs-plugins-actually-prove-3h5</guid>
      <description>&lt;p&gt;WordPress makes it easy to write code that works and easy to write code that lasts, and those are not the same skill. Hooks, filters, custom post types, widgets — the platform hands you enough rope to build something maintainable or something that collapses the moment a second developer touches it. SOLID is the discipline that decides which one you get. Not as academic dogma imported from enterprise Java, but as five habits that map cleanly onto things WordPress developers already do every day.&lt;/p&gt;

&lt;p&gt;We’ve written up all five principles in depth on &lt;strong&gt;4wp.dev/architectures/solid&lt;/strong&gt;. This isn’t a repeat of that material — it’s the harder question: does our own plugin catalog actually hold up against it? Where does it, and where are we still watching ourselves?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single Responsibility&lt;/strong&gt; — the one we had to learn from our own mistake&lt;br&gt;
SRP says a class, a module, a plugin should have one reason to change. The honest WordPress version of this failure mode is the “kitchen sink” plugin — the one that does SEO, and a slider, and contact forms, and social icons, because it was easier to bolt features onto something that already existed than to ship something new.&lt;/p&gt;

&lt;p&gt;We named this risk directly on our own SRP page, using 4wp-bundle as the cautionary example: if it ever grew to carry responsive controls, FAQ management, Gutenberg extensions, and settings all in one codebase, that’s exactly the violation. The actual answer wasn’t a paragraph of theory — it’s the catalog itself. 4WP-Booking, 4WP-FAQ, 4WP Smart Link, 4WP Weather, 4WP Drive, 4WP Notifications — each is a separate plugin with a separate reason to change, sharing only infrastructure through 4wp-bundle, not features. That split isn’t a marketing decision. It’s SRP applied at the product level, not just the class level, and it’s the reason a bug in the booking flow can never be a reason to redeploy the FAQ registry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open/Closed&lt;/strong&gt; — the principle WordPress ships for free&lt;br&gt;
OCP — open for extension, closed for modification — is arguably the one principle WordPress gets right by default. do_action() and apply_filters() are OCP in procedural form: WooCommerce alone ships 700+ action hooks and 400+ filter hooks, and its whole extension ecosystem exists without a single third party editing WooCommerce’s own source.&lt;/p&gt;

&lt;p&gt;Our own clearest example is 4WP-Booking. The plugin was built around a provider contract from day one — Clinic Cards today, Google Calendar and Calendly on the roadmap — specifically so that adding a new booking provider is an extension, not a rewrite. Nothing in the booking domain has to change for a new provider to plug in behind the same contract. That’s the same shape as a hook, just at the architecture layer instead of the callback layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liskov Substitution&lt;/strong&gt; — the one that’s easiest to fake and hardest to actually keep&lt;br&gt;
LSP requires that anything substitutable for a base type actually behaves like it — same contract, no surprise exceptions, no broken assumptions. WordPress breaks this constantly: a WP_Widget subclass that throws where the parent returns cached output, a custom post type wrapper that crashes on get_permalink(). The interface looks satisfied. The behavior isn’t.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/4wp-smart-link/" rel="noopener noreferrer"&gt;4WP Smart Link&lt;/a&gt; is our sharpest internal test of this, even though it’s a runtime pattern rather than a class hierarchy — worth being precise about, since LSP is usually framed as inheritance. The plugin decides between anchor mode (wrap the block in tag:&amp;lt; a &amp;gt; ) and host mode (attach the click via data-forwp-smart-link-url) depending on whether inner interactive elements need to keep priority. Whichever mode fires, the outward contract — “this block is now clickable, and its inner links still work” — holds identically. Nothing downstream needs to know which mode ran. That’s the substitutability guarantee LSP is actually protecting, applied to a strategy choice instead of a class swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interface Segregation&lt;/strong&gt; — don’t make every plugin implement methods it’ll never use&lt;br&gt;
ISP says no client should depend on methods it doesn’t use. Our own page frames this well for Gutenberg: a simple Quote block only needs to render, a Cover block needs render, transform, and style variations — forcing both through one bloated block contract is the violation, and forcing a lightweight SEO plugin to implement Has_Cron because some other plugin needs scheduled tasks is the same mistake at the plugin level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/4wp-faq/" rel="noopener noreferrer"&gt;4WP-FAQ&lt;/a&gt; is the clean version of this: the registry (the FAQ entry itself, as a CPT) doesn’t know or care how it’s displayed, and the two display blocks — 4WP FAQ List and 4WP FAQ Card — each consume only what they need from the registry, not a shared monolithic “FAQ manager” interface that both would have to partially ignore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency Inversion&lt;/strong&gt; — the one that makes the roadmap possible&lt;br&gt;
DIP says high-level modules should depend on abstractions, not concrete implementations — a Mailer_Interface instead of a hard-coded wp_mail() call, so you can swap providers without touching business logic.&lt;/p&gt;

&lt;p&gt;This is, again, &lt;strong&gt;4WP-Booking&lt;/strong&gt;‘s core architectural bet. The booking domain doesn’t depend on Clinic Cards — it depends on a provider contract that Clinic Cards happens to implement first. That’s the entire reason Google Calendar and Calendly can be added later as roadmap items instead of rewrites. DIP isn’t an abstraction for its own sake here; it’s the specific mechanism that turns “add a new integration” from a risk into a scheduling decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this actually lands&lt;/strong&gt;&lt;br&gt;
SOLID overlaps with itself constantly — Booking’s provider contract is simultaneously an OCP and a DIP story, and that’s not sloppy analysis, it’s what these principles look like when they’re actually load-bearing instead of decorative. The honest version of this article isn’t “we nailed all five” — it’s that four of the five show up as deliberate architecture in shipped plugins, and the fifth (SRP) shows up as a mistake we named on our own site before we fixed it in the catalog. That’s the difference between citing SOLID and being accountable to it.&lt;/p&gt;

&lt;p&gt;Full breakdowns of each principle, with more WordPress-specific examples and common violations: &lt;strong&gt;4wp.dev/architectures/solid&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Sources&lt;/p&gt;

&lt;p&gt;&lt;a href="https://4wp.dev/architectures/solid/" rel="noopener noreferrer"&gt;SOLID overview — 4wp.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/single-responsibility/" rel="noopener noreferrer"&gt;Single Responsibility Principle — 4wp.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/open-closed-principle/" rel="noopener noreferrer"&gt;Open/Closed Principle — 4wp.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/liskov-substitution/" rel="noopener noreferrer"&gt;Liskov Substitution Principle — 4wp.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/interface-segregation/" rel="noopener noreferrer"&gt;Interface Segregation Principle — 4wp.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/dependency-inversion/" rel="noopener noreferrer"&gt;Dependency Inversion Principle — 4wp.dev&lt;/a&gt;&lt;br&gt;
4WP-Booking · 4WP Smart Link · 4WP-FAQ · 4wp-bundle&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>php</category>
      <category>softwareengineering</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The Whale Metaphor: How OOP's Four Pillars Actually Work in WordPress</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sat, 29 Aug 2026 20:51:45 +0000</pubDate>
      <link>https://dev.to/adovgun/the-whale-metaphor-how-oops-four-pillars-actually-work-in-wordpress-21cd</link>
      <guid>https://dev.to/adovgun/the-whale-metaphor-how-oops-four-pillars-actually-work-in-wordpress-21cd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;In the age of AI engineering and vibe coding, almost nobody mentions OOP anymore. But what if children were never taught prefixes, roots, and suffixes — the architecture of words — or how a sentence is properly built? Will AI agents really be enough for the specialists of tomorrow, if those specialists never learned the grammar underneath?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://4wp.dev/oop/" rel="noopener noreferrer"&gt;Dive into OOP in WordPress development practice →&lt;/a&gt; (original source, featuring the four whales example)&lt;/p&gt;

&lt;p&gt;Most WordPress developers learn Object-Oriented Programming the hard way: by staring at WP_Widget, WP_Query, or WP_Post and reverse-engineering why core is built the way it is. Textbooks explain encapsulation, abstraction, inheritance, and polymorphism with abstract diagrams that rarely survive contact with real code.&lt;/p&gt;

&lt;p&gt;Here's a different way to think about it — using a whale.&lt;/p&gt;

&lt;p&gt;A whale keeps its vital organs protected inside its body, dives into depths where the mechanics of survival are invisible from the surface, passes traits down to its calf, and adapts its behavior differently depending on the environment it's in. Swap "whale" for "class," and you've basically described the four pillars of OOP. Let's walk through each one with WordPress-specific code, then look at how the same principles scale from a five-page brochure site to an enterprise platform.&lt;/p&gt;

&lt;p&gt;Why OOP Matters in WordPress at All&lt;/p&gt;

&lt;p&gt;WordPress was procedural for most of its early life, and plenty of plugins still are. But once your project outgrows a handful of files, procedural code starts fighting you: global state leaks everywhere, the same logic gets copy-pasted into three different hooks, and a single typo in a variable name three files away breaks something unrelated.&lt;/p&gt;

&lt;p&gt;OOP fixes this by grouping data and behavior together into objects instead of scattering functions and passing arrays between them. WordPress core made this bet a long time ago — WP_Widget, WP_Query, and WP_Post are all classes — and the four principles below are the foundation that makes classes trustworthy enough to build on. They're also the on-ramp to SOLID, the next level of discipline for larger codebases.&lt;/p&gt;

&lt;p&gt;Encapsulation: The Protected Whale&lt;/p&gt;

&lt;p&gt;A whale's vital organs sit safely inside its body, reachable only through a small number of controlled openings. Encapsulation applies the same idea to a class: internal data is hidden from the outside world, and only a deliberate, controlled interface is exposed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_display_name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'display_name'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_password_hash&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's public and what isn't. get_display_name() is a safe, read-only window into the object. get_password_hash() stays private — nothing outside the class can reach it, even by accident. That's the entire point: encapsulation isn't about hiding things for secrecy, it's about making it impossible to misuse an object's internals from the outside.&lt;/p&gt;

&lt;p&gt;In a WordPress plugin, this is the difference between a settings class that validates and sanitizes every value it stores, versus a plugin that lets any file in the codebase poke directly into a raw options array. One of those breaks when someone forgets to call sanitize_text_field(). The other can't.&lt;/p&gt;

&lt;p&gt;Abstraction: The Deep Whale&lt;/p&gt;

&lt;p&gt;A whale dives into depths where its physiology does things — pressure regulation, oxygen management — that are irrelevant to anyone watching from a boat. All you need to know is: it dives, it surfaces, it breathes. Abstraction works the same way in code: it hides complexity behind a simple, stable interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Payment_Gateway&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Payment_Gateway&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stripe_api&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything calling process($amount) doesn't need to know that Stripe's implementation involves API authentication, currency conversion, and webhook handling. The abstract class defines the contract — "every payment gateway must be able to process an amount" — and each concrete class fills in the messy details.&lt;/p&gt;

&lt;p&gt;This matters enormously in WordPress e-commerce or membership plugins, where you might support Stripe today and PayPal or a local payment processor tomorrow. If the rest of your plugin talks to Payment_Gateway instead of talking to Stripe directly, swapping or adding a gateway means writing one new class — not hunting through the codebase for every place that assumed Stripe.&lt;/p&gt;

&lt;p&gt;Inheritance: Mother and Calf&lt;/p&gt;

&lt;p&gt;A calf inherits traits from its mother without needing to relearn how to swim from scratch. Inheritance lets a child class receive properties and methods from a parent, then extend or specialize them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Base_Widget&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WP_Widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Popular_Posts&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Base_Widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_posts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'orderby'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$posts&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base_Widget extends WordPress's own WP_Widget and adds one useful shared behavior: caching. Popular_Posts then extends Base_Widget and gets caching for free, without reimplementing it. If you build five widgets this way, you write the caching logic exactly once.&lt;/p&gt;

&lt;p&gt;This is also where a lot of WordPress developers get their first real taste of OOP, because WP_Widget practically forces the pattern on you. But the same idea applies to custom post type controllers, REST API endpoint classes, or any group of components that share a common backbone but differ in specifics.&lt;/p&gt;

&lt;p&gt;A word of caution: inheritance is powerful but easy to overuse. Deep inheritance chains (a class extending a class extending a class extending a class) get brittle fast — a change to a distant parent can silently break every descendant. Use it when the "is-a" relationship is genuinely true (a Popular_Posts widget is a widget), and reach for composition when it isn't.&lt;/p&gt;

&lt;p&gt;Polymorphism: The Many Forms&lt;/p&gt;

&lt;p&gt;Whales adapt their behavior to different environments while remaining recognizably whales. Polymorphism is the OOP version: different objects share the same interface, but each implements it in its own way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Notifiable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Email_Notifier&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Notifiable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Alert'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Notifiable&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The notify() function doesn't know or care whether it's talking to an Email_Notifier, a Slack_Notifier, or an SMS_Notifier — as long as each one implements send(), they're interchangeable. This is what makes plugin architectures extensible: you can add a brand-new notification channel by writing a new class that implements Notifiable, and every place in your codebase that already calls notify() picks it up automatically, with zero changes to existing code.&lt;/p&gt;

&lt;p&gt;The Same Principles, Different Scale&lt;/p&gt;

&lt;p&gt;What makes these four pillars genuinely useful — rather than just academic — is that they scale up and down with the size of the project.&lt;/p&gt;

&lt;p&gt;On a small brochure site or blog, you don't need an elaborate class hierarchy. A single, well-encapsulated class is often enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Theme_Options&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_theme_options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one class handles a contact form's settings or a set of theme options. Encapsulation keeps the stored data safe from careless direct writes, and if you later need a specialized version — say, a widget with extra behavior — inheriting from WP_Widget gets you there without rebuilding anything from scratch.&lt;/p&gt;

&lt;p&gt;At the enterprise end, the same four principles are doing exactly the same job, just at higher stakes: encapsulated data models that can't be corrupted by a stray script, abstracted payment or search providers that can be swapped without a rewrite, inherited base classes that keep dozens of custom post types consistent, and polymorphic interfaces that let a plugin ecosystem grow without every new feature requiring changes to old code.&lt;/p&gt;

&lt;p&gt;Where This Leads: From Four Pillars to SOLID&lt;/p&gt;

&lt;p&gt;Encapsulation, abstraction, inheritance, and polymorphism aren't four unrelated rules to memorize — they're the raw material that a more advanced set of rules is built from: SOLID.&lt;/p&gt;

&lt;p&gt;The connection is direct, not theoretical. A well-encapsulated UserProfile class, one that exposes only what callers actually need, is already halfway to Single Responsibility — a class that guards its own data tends to guard its own job, too. The Payment_Gateway abstraction is a working example of Dependency Inversion: the rest of the plugin depends on the abstract contract, not on Stripe specifically, which is also what makes it Open for extension, closed for modification. Base_Widget extending WP_Widget only holds up as long as every subclass can stand in for its parent without surprising callers — that's Liskov Substitution, and it's exactly where careless inheritance chains start to break. And the Notifiable interface is a small, focused contract rather than a bloated one — the seed of Interface Segregation.&lt;/p&gt;

&lt;p&gt;In other words, once encapsulation, abstraction, inheritance, and polymorphism feel natural, you're not learning SOLID from zero — you're learning the names for things you're already halfway doing. That's the next stop: &lt;a href="https://4wp.dev/architectures/solid/" rel="noopener noreferrer"&gt;SOLID Principles in WordPress →&lt;/a&gt;, five rules that take these four pillars and turn them into architecture that survives years of feature requests without a rewrite.&lt;/p&gt;

&lt;p&gt;But it starts here — with a whale, its calf, and four ideas that WordPress core has been quietly demonstrating in WP_Widget, WP_Query, and WP_Post all along.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>php</category>
      <category>programming</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Open/Closed Principle in WordPress Architecture</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sun, 01 Mar 2026 15:26:58 +0000</pubDate>
      <link>https://dev.to/adovgun/openclosed-principle-in-wordpress-architecture-4f3l</link>
      <guid>https://dev.to/adovgun/openclosed-principle-in-wordpress-architecture-4f3l</guid>
      <description>&lt;p&gt;WordPress plugins rarely collapse because of complexity.&lt;/p&gt;

&lt;p&gt;They collapse because of change.&lt;/p&gt;

&lt;p&gt;New payment gateway.&lt;br&gt;&lt;br&gt;
New notification channel.&lt;br&gt;&lt;br&gt;
New integration.&lt;br&gt;&lt;br&gt;
New business rule.&lt;/p&gt;

&lt;p&gt;And suddenly — you are modifying the same class again.&lt;/p&gt;

&lt;p&gt;That’s exactly what the &lt;strong&gt;Open/Closed Principle (OCP)&lt;/strong&gt; is designed to prevent.&lt;/p&gt;


&lt;h2&gt;
  
  
  What Open/Closed Principle Really Means
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Software entities should be open for extension, but closed for modification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; mean your code never changes.&lt;/p&gt;

&lt;p&gt;It means:&lt;/p&gt;

&lt;p&gt;Core behavior remains stable.&lt;br&gt;&lt;br&gt;
New functionality is added by extension — not by editing existing logic.&lt;/p&gt;

&lt;p&gt;If every new feature requires touching old code, your architecture is fragile.&lt;/p&gt;


&lt;h2&gt;
  
  
  A Typical WordPress Anti-Pattern
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;php
class PaymentProcessor {
    public function process(string $method, float $amount): void {
        if ($method === 'paypal') {
            // PayPal logic
        }

        if ($method === 'stripe') {
            // Stripe logic
        }

        if ($method === 'bank') {
            // Bank transfer logic
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Works fine.&lt;/p&gt;

&lt;p&gt;Until business says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add Apple Pay&lt;/li&gt;
&lt;li&gt;Add crypto&lt;/li&gt;
&lt;li&gt;Add installment payments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each time → modify the same class.&lt;/p&gt;

&lt;p&gt;That’s not closed for modification.&lt;/p&gt;

&lt;p&gt;That’s conditional chaos.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Better Architectural Direction
&lt;/h2&gt;

&lt;p&gt;Introduce abstraction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;interface PaymentMethod {&lt;br&gt;
    public function process(float $amount): void;&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Concrete implementations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PayPalPayment implements PaymentMethod {}
class StripePayment implements PaymentMethod {}
class BankTransferPayment implements PaymentMethod {}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Processor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PaymentProcessor {
    public function __construct(private PaymentMethod $method) {}

    public function process(float $amount): void {
        $this-&amp;gt;method-&amp;gt;process($amount);
    }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now:&lt;/p&gt;

&lt;p&gt;Adding Apple Pay = new class.&lt;br&gt;
No modification of existing logic.&lt;/p&gt;

&lt;p&gt;That’s Open/Closed in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why WordPress Developers Struggle with OCP
&lt;/h2&gt;

&lt;p&gt;WordPress encourages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feature flags inside giant classes&lt;/li&gt;
&lt;li&gt;Conditionals inside hooks&lt;/li&gt;
&lt;li&gt;Direct API calls&lt;/li&gt;
&lt;li&gt;Procedural branching logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But scalable systems require extension-based thinking.&lt;/p&gt;

&lt;p&gt;If your architecture grows by adding if statements — it won’t scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Apply OCP in WordPress
&lt;/h2&gt;

&lt;p&gt;Use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payment gateways&lt;/li&gt;
&lt;li&gt;Notification systems (email, SMS, Slack)&lt;/li&gt;
&lt;li&gt;Export formats (CSV, PDF, JSON)&lt;/li&gt;
&lt;li&gt;Caching strategies&lt;/li&gt;
&lt;li&gt;Storage drivers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any place where variation is expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters
&lt;/h2&gt;

&lt;p&gt;Open/Closed Principle enables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Safer refactoring&lt;/li&gt;
&lt;li&gt;Replaceable infrastructure&lt;/li&gt;
&lt;li&gt;Testable components&lt;/li&gt;
&lt;li&gt;Plugin-style extensibility&lt;/li&gt;
&lt;li&gt;Long-term maintainability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your plugin is constantly rewritten, OCP is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;Most WordPress plugins are built to work.&lt;/p&gt;

&lt;p&gt;Few are built to evolve.&lt;/p&gt;

&lt;p&gt;Open/Closed Principle is what makes the difference.&lt;/p&gt;

&lt;p&gt;Full breakdown:&lt;br&gt;
&lt;a href="https://4wp.dev/architectures/solid/open-closed-principle/" rel="noopener noreferrer"&gt;https://4wp.dev/architectures/solid/open-closed-principle/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>4wpdev</category>
      <category>wordpress</category>
      <category>solidprinciples</category>
    </item>
    <item>
      <title>Single Responsibility Principle for OOP WordPress Developers</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sat, 28 Feb 2026 17:55:21 +0000</pubDate>
      <link>https://dev.to/adovgun/single-responsibility-principle-for-oop-wordpress-developers-1700</link>
      <guid>https://dev.to/adovgun/single-responsibility-principle-for-oop-wordpress-developers-1700</guid>
      <description>&lt;p&gt;If you want to grow as an OOP WordPress developer, understanding the Single Responsibility Principle (SRP) is not optional — it’s foundational.&lt;/p&gt;

&lt;p&gt;SRP is the first principle from the well-known SOLID design principles. It states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A class should have only one reason to change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  That sounds simple. In practice — especially in WordPress — it’s often ignored.
&lt;/h2&gt;

&lt;p&gt;The Real Problem in WordPress Projects&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Many WordPress developers still build:&lt;/li&gt;
&lt;li&gt;Massive utility classes&lt;/li&gt;
&lt;li&gt;God-like service objects&lt;/li&gt;
&lt;li&gt;Theme functions.php monsters&lt;/li&gt;
&lt;li&gt;Plugins that do “everything”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This leads to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tight coupling&lt;/li&gt;
&lt;li&gt;Hard-to-test code&lt;/li&gt;
&lt;li&gt;Fear of refactoring&lt;/li&gt;
&lt;li&gt;Fragile architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an OOP WordPress developer, this becomes a career bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Single Responsibility Really Means
&lt;/h2&gt;

&lt;p&gt;SRP does NOT mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One method per class&lt;/li&gt;
&lt;li&gt;Ultra-micro classes&lt;/li&gt;
&lt;li&gt;Artificial splitting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SRP means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A class should have one clear responsibility in the business domain.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;Bad:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;class OrderManager {&lt;br&gt;
    public function createOrder() {}&lt;br&gt;
    public function sendEmail() {}&lt;br&gt;
    public function generateInvoicePDF() {}&lt;br&gt;
    public function logActivity() {}&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Good:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;class OrderCreator {}&lt;br&gt;
class OrderMailer {}&lt;br&gt;
class InvoiceGenerator {}&lt;br&gt;
class ActivityLogger {}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Each class now has one reason to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying SRP in WordPress Architecture
&lt;/h2&gt;

&lt;p&gt;For a professional OOP WordPress developer, SRP applies to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Plugin Structure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Controllers&lt;/li&gt;
&lt;li&gt;Services&lt;/li&gt;
&lt;li&gt;Repositories&lt;/li&gt;
&lt;li&gt;Integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MyPlugin.php (3000 lines)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Use:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/&lt;br&gt;
 ├── Application/&lt;br&gt;
 ├── Domain/&lt;br&gt;
 ├── Infrastructure/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hooks &amp;amp; WordPress Actions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Avoid this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;add_action('init', function () {&lt;br&gt;
    register_post_type(...);&lt;br&gt;
    sendEmails();&lt;br&gt;
    syncWithAPI();&lt;br&gt;
});&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Instead, delegate:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;add_action('init', [PostTypeRegistrar::class, 'register']);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Theme Development&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In modern block themes and FSE:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Templates define structure&lt;/li&gt;
&lt;li&gt;Patterns define reusable UI&lt;/li&gt;
&lt;li&gt;PHP handles domain logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixing all of this breaks SRP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for an OOP WordPress Developer
&lt;/h2&gt;

&lt;p&gt;If you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Work on enterprise WordPress projects&lt;/li&gt;
&lt;li&gt;Pass technical interviews&lt;/li&gt;
&lt;li&gt;Build scalable products&lt;/li&gt;
&lt;li&gt;Refactor safely&lt;/li&gt;
&lt;li&gt;Collaborate in teams&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SRP is your baseline.&lt;/p&gt;

&lt;p&gt;Without it, you are just writing procedural code wrapped in classes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common SRP Mistakes in WordPress
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fat service classes&lt;/li&gt;
&lt;li&gt;Overloaded helpers&lt;/li&gt;
&lt;li&gt;Static utility dumping grounds&lt;/li&gt;
&lt;li&gt;Mixed domain + infrastructure logic&lt;/li&gt;
&lt;li&gt;Business logic inside template files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this sounds familiar — your architecture needs refactoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the Full Architecture Breakdown
&lt;/h2&gt;

&lt;p&gt;This article is a short overview.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;For a deeper architectural breakdown, examples, and structured explanation, read the original post:&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
👉 &lt;a href="https://4wp.dev/architectures/solid/single-responsibility/" rel="noopener noreferrer"&gt;https://4wp.dev/architectures/solid/single-responsibility/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Thought&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Being an OOP WordPress developer is not about using classes.&lt;br&gt;
It’s about understanding responsibility boundaries.&lt;br&gt;
Master SRP — and the rest of SOLID becomes easier.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>oop</category>
      <category>solidprinciples</category>
      <category>4wpev</category>
    </item>
    <item>
      <title>4WP.dev — Modular WordPress Architecture, Gutenberg Custom Blocks, SEO &amp; Automation</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Sat, 14 Feb 2026 23:05:31 +0000</pubDate>
      <link>https://dev.to/adovgun/4wpdev-modular-wordpress-architecture-gutenberg-custom-blocks-seo-automation-3if2</link>
      <guid>https://dev.to/adovgun/4wpdev-modular-wordpress-architecture-gutenberg-custom-blocks-seo-automation-3if2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;“When you have one plugin — it’s great. When you have several — even better. When they are actively used — perfect. But then the real pain begins…”&lt;br&gt;
— Full-stack WordPress developer with open two eyes&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have 12+ plugins and over 30+ projects where use it and know how management it from one place:&lt;/p&gt;

&lt;p&gt;Site: &lt;a href="https://4wp.dev/plugin/" rel="noopener noreferrer"&gt;https://4wp.dev/plugin/&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/4wpdev/4wpdev" rel="noopener noreferrer"&gt;https://github.com/4wpdev/4wpdev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A simple example: one WordPress project uses multiple plugins. Then more projects reuse them. Over time the ecosystem grows — modules increase, dependencies expand, logic overlaps, responsibilities blur, performance fluctuates, updates introduce risks, and maintenance becomes unpredictable.&lt;/p&gt;

&lt;p&gt;4WP.dev is a modular WordPress development framework designed to solve this problem and scale complex Gutenberg-based projects efficiently.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>wordpress</category>
    </item>
  </channel>
</rss>
