<?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>SDLC Explained for WordPress Developers: The 4wp.dev Umbrella, 7 Phases, 60 Years</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Fri, 06 Nov 2026 21:58:50 +0000</pubDate>
      <link>https://dev.to/adovgun/sdlc-explained-for-wordpress-developers-the-4wpdev-umbrella-7-phases-60-years-4cbf</link>
      <guid>https://dev.to/adovgun/sdlc-explained-for-wordpress-developers-the-4wpdev-umbrella-7-phases-60-years-4cbf</guid>
      <description>&lt;p&gt;&lt;strong&gt;From the &lt;a href="https://4wp.dev/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt; team.&lt;/strong&gt; We build WordPress plugins and write about WordPress engineering. This is how we see SDLC, and the interactive version of this article lives on 4wp.dev: 👉 &lt;strong&gt;&lt;a href="https://4wp.dev/sdlc/" rel="noopener noreferrer"&gt;scroll through sixty years of weather on 4wp.dev/sdlc&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SDLC was born in the 1960s. It is still relevant in the AI era.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;SDLC (Software Development Life Cycle)&lt;/strong&gt; is the process every WordPress project goes through, whether you call it that or not: someone wants something, you clarify it, design it, build it, test it, deploy it and maintain it. Many WordPress developers know this process by heart and have never used the letters. Developers say "task", "flow", "release". Managers, tech leads and hiring teams say SDLC.&lt;/p&gt;

&lt;p&gt;At 4wp.dev we draw SDLC as an umbrella: the phases are its ribs, and every era of software is new weather. We call it the &lt;strong&gt;SDLC Umbrella&lt;/strong&gt;, and the rest of this article walks under it. This article explains the software development life cycle in a WordPress developer's language.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is SDLC?
&lt;/h2&gt;

&lt;p&gt;SDLC stands for &lt;strong&gt;Software Development Life Cycle&lt;/strong&gt;: the phases software goes through from an idea to the day it is switched off. Sources split it into five to eight phases and name them differently. The order stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 Phases of SDLC (the 7 Ribs of the 4wp.dev Umbrella)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan&lt;/strong&gt;: why we build it, what it is worth, what the risks are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requirements&lt;/strong&gt;: what exactly it must do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design&lt;/strong&gt;: how it will be built.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt;: the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test&lt;/strong&gt;: proof that it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: getting it live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintain&lt;/strong&gt;: keeping it alive, then feeding the next Plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  SDLC vs Methodologies: Waterfall, Agile, Scrum, Kanban
&lt;/h2&gt;

&lt;p&gt;SDLC is not a methodology. &lt;a href="https://4wp.dev/methodologies/waterfall/" rel="noopener noreferrer"&gt;Waterfall&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/agile/" rel="noopener noreferrer"&gt;Agile&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/scrum/" rel="noopener noreferrer"&gt;Scrum&lt;/a&gt; and &lt;a href="https://4wp.dev/methodologies/kanban/" rel="noopener noreferrer"&gt;Kanban&lt;/a&gt; are different ways of walking through the same phases. SDLC is what they all walk through.&lt;/p&gt;

&lt;p&gt;Think of the phases as the ribs of an umbrella. Remove one and the canopy fails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Skip &lt;strong&gt;Requirements&lt;/strong&gt; and you build the wrong thing well.&lt;/li&gt;
&lt;li&gt;Skip &lt;strong&gt;Design&lt;/strong&gt; and every new feature fights the old ones.&lt;/li&gt;
&lt;li&gt;Skip &lt;strong&gt;Test&lt;/strong&gt; and your users test for you.&lt;/li&gt;
&lt;li&gt;Skip &lt;strong&gt;Maintain&lt;/strong&gt; and the site slowly rots under plugin, core and PHP updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Short History of SDLC: Sixty Years of Weather
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1968: the software crisis.&lt;/strong&gt; At the NATO Software Engineering Conference in Garmisch, Germany, participants discussed what became known as the "software crisis": projects late, over budget and unreliable. The answer was engineering discipline, and with it a defined cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1970: Waterfall.&lt;/strong&gt; Winston W. Royce published "Managing the Development of Large Software Systems". Its sequential diagram later got the name Waterfall, although Royce himself warned that running it strictly one way was risky.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2001: Agile.&lt;/strong&gt; The Agile Manifesto did not remove the phases. It made the cycle short and repeated it. A sprint is a small SDLC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2009: DevOps.&lt;/strong&gt; The term spread after the first DevOpsDays in Ghent. Deploy and Maintain became continuous: see &lt;a href="https://4wp.dev/methodologies/ci-cd-wordpress/" rel="noopener noreferrer"&gt;CI/CD for WordPress&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2020s: AI.&lt;/strong&gt; Coding tools made Build cheap. The other six phases did not get cheaper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The weather changes. The cycle stays. That line closes our 40-second video on &lt;a href="https://4wp.dev/sdlc/" rel="noopener noreferrer"&gt;4wp.dev/sdlc&lt;/a&gt;, where the umbrella stands through rain, lightning and falling stones.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDLC From Three Sides: Client, Project, Developer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Client asks&lt;/th&gt;
&lt;th&gt;Project asks&lt;/th&gt;
&lt;th&gt;Developer asks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plan&lt;/td&gt;
&lt;td&gt;Why are we doing this, and what is it worth?&lt;/td&gt;
&lt;td&gt;Scope, budget, deadline, risks.&lt;/td&gt;
&lt;td&gt;Is it feasible on WordPress? Existing plugin, custom plugin or theme code?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requirements&lt;/td&gt;
&lt;td&gt;What must the site do for my users?&lt;/td&gt;
&lt;td&gt;Turn wishes into a list that can be accepted or rejected.&lt;/td&gt;
&lt;td&gt;Roles and capabilities, content types, edge cases, integrations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design&lt;/td&gt;
&lt;td&gt;How will it look and feel?&lt;/td&gt;
&lt;td&gt;Is the solution the right size for the budget?&lt;/td&gt;
&lt;td&gt;Data model, hooks, blocks or templates, REST routes, where each piece of code lives.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;When can I see it?&lt;/td&gt;
&lt;td&gt;Progress against the plan.&lt;/td&gt;
&lt;td&gt;Plugin and theme code, migrations, configuration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test&lt;/td&gt;
&lt;td&gt;Does it work on my phone?&lt;/td&gt;
&lt;td&gt;Is it ready to ship?&lt;/td&gt;
&lt;td&gt;Unit and end-to-end tests, coding standards, capabilities, nonces, escaping.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Is it live?&lt;/td&gt;
&lt;td&gt;Release window and rollback plan.&lt;/td&gt;
&lt;td&gt;Staging to production, WP-CLI, database, cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintain&lt;/td&gt;
&lt;td&gt;Will it keep working?&lt;/td&gt;
&lt;td&gt;Support cost and update schedule.&lt;/td&gt;
&lt;td&gt;Core, plugin and PHP updates, security fixes, monitoring.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a small WordPress project one person often wears all three hats. A freelancer is the project manager and the developer, and often helps the client decide what they want. That is where SDLC stops being a manager's word.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDLC in WordPress Development: Phase by Phase
&lt;/h2&gt;

&lt;p&gt;Every rib already has a guide on 4wp.dev:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plan&lt;/strong&gt;: &lt;a href="https://4wp.dev/methodologies/lean-mvp/" rel="noopener noreferrer"&gt;Lean (MVP)&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/design-thinking/" rel="noopener noreferrer"&gt;Design Thinking&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requirements&lt;/strong&gt;: &lt;a href="https://4wp.dev/methodologies/bdd/" rel="noopener noreferrer"&gt;BDD&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/domain-driven-design/ubiquitous-language-wordpress/" rel="noopener noreferrer"&gt;Ubiquitous Language&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design&lt;/strong&gt;: &lt;a href="https://4wp.dev/architectures/" rel="noopener noreferrer"&gt;Architectures&lt;/a&gt;, &lt;a href="https://4wp.dev/architectures/solid/" rel="noopener noreferrer"&gt;SOLID&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/domain-driven-design/" rel="noopener noreferrer"&gt;Domain-Driven Design&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/api-first-rest-first/" rel="noopener noreferrer"&gt;API-first&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/block-first-gutenberg/" rel="noopener noreferrer"&gt;Block-first&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt;: &lt;a href="https://4wp.dev/hooks/" rel="noopener noreferrer"&gt;Hooks&lt;/a&gt;, &lt;a href="https://4wp.dev/development/gutenberg/" rel="noopener noreferrer"&gt;Gutenberg Blocks&lt;/a&gt;, &lt;a href="https://4wp.dev/development/rest-api/" rel="noopener noreferrer"&gt;REST API&lt;/a&gt;, &lt;a href="https://4wp.dev/development/wordpress-coding-standards/" rel="noopener noreferrer"&gt;WordPress Coding Standards&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test&lt;/strong&gt;: &lt;a href="https://4wp.dev/methodologies/tdd/" rel="noopener noreferrer"&gt;TDD&lt;/a&gt;, &lt;a href="https://4wp.dev/development/backend/security/" rel="noopener noreferrer"&gt;Security&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: &lt;a href="https://4wp.dev/methodologies/ci-cd-wordpress/" rel="noopener noreferrer"&gt;CI/CD for WordPress&lt;/a&gt;, &lt;a href="https://4wp.dev/wp-cli/" rel="noopener noreferrer"&gt;WP-CLI&lt;/a&gt;, &lt;a href="https://4wp.dev/methodologies/twelve-factor-app/" rel="noopener noreferrer"&gt;Twelve-Factor App&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintain&lt;/strong&gt;: &lt;a href="https://4wp.dev/methodologies/site-reliability-engineering-wordpress/" rel="noopener noreferrer"&gt;Site Reliability Engineering&lt;/a&gt;, &lt;a href="https://4wp.dev/wp-cli/profiling/" rel="noopener noreferrer"&gt;WP-CLI profiling&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  From WordPress Developer to Tech Lead
&lt;/h2&gt;

&lt;p&gt;Build is one rib out of seven. A developer who only builds competes with every tool that builds.&lt;/p&gt;

&lt;p&gt;A developer who holds all seven asks why before writing code, pins down what "done" means, decides where the code lives, proves it works, ships it safely and keeps it alive. That developer is already doing a tech lead's job, whatever the title says.&lt;/p&gt;

&lt;p&gt;The AI era asks for exactly this: not a developer who types fast, but a fullstack developer who thinks like an architect.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDLC and AI: AI Took One Rib
&lt;/h2&gt;

&lt;p&gt;AI coding tools write code fast. They do not decide what to build, they do not own the trade-offs, and they do not own the rollback.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vague requirements give you vague code, only faster.&lt;/li&gt;
&lt;li&gt;More generated code means more code to review and test.&lt;/li&gt;
&lt;li&gt;Architecture decides whether generated code fits the system or fights it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI took Build. Six ribs are still yours. More on how we split the work with agents at 4wp.dev: &lt;a href="https://4wp.dev/ai/" rel="noopener noreferrer"&gt;AI Workflows for WordPress&lt;/a&gt; and &lt;a href="https://4wp.dev/attention-economics/" rel="noopener noreferrer"&gt;The Price of Attention&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What does SDLC stand for?&lt;/strong&gt;&lt;br&gt;
Software Development Life Cycle: the phases software goes through from an idea to retirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What are the phases of SDLC?&lt;/strong&gt;&lt;br&gt;
Plan, Requirements, Design, Build, Test, Deploy and Maintain. Some sources merge or split them, but the order stays the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is SDLC the same as Waterfall?&lt;/strong&gt;&lt;br&gt;
No. Waterfall is one way to walk through the cycle: one phase after another, once. Agile, Scrum and Kanban walk through the same phases in short, repeated loops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is SDLC still relevant with Agile and AI?&lt;/strong&gt;&lt;br&gt;
Yes. Agile made the cycle shorter, DevOps made Deploy and Maintain continuous, and AI made Build cheaper. None of them removed a phase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a solo WordPress developer need SDLC?&lt;/strong&gt;&lt;br&gt;
Yes, more than anyone. A solo developer is the planner, the architect, the tester and the support team. Knowing the phases helps you see which one you skipped.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;On 4wp.dev this FAQ is wrapped in our own plugin, &lt;a href="https://4wp.dev/plugin/4wp-faq/" rel="noopener noreferrer"&gt;4WP FAQ&lt;/a&gt;: it adds FAQPage JSON-LD to the core Accordion.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Forecast for 2027: Sunny ☀️
&lt;/h2&gt;

&lt;p&gt;Since we are talking about weather: want it on your site today? &lt;strong&gt;&lt;a href="https://wordpress.org/plugins/4wp-weather/" rel="noopener noreferrer"&gt;4WP Weather&lt;/a&gt;&lt;/strong&gt; is our Gutenberg block with full settings. Elementor widget? Coming in 2027. Promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  More From 4wp.dev
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/methodologies/" rel="noopener noreferrer"&gt;Methodologies&lt;/a&gt;: every way to walk under the umbrella.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/architectures/" rel="noopener noreferrer"&gt;Architectures&lt;/a&gt; and &lt;a href="https://4wp.dev/architectures/solid/" rel="noopener noreferrer"&gt;SOLID&lt;/a&gt;: the Design rib.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/wp-cli/" rel="noopener noreferrer"&gt;WP-CLI&lt;/a&gt; and &lt;a href="https://4wp.dev/development/rest-api/" rel="noopener noreferrer"&gt;REST API&lt;/a&gt;: the Build and Deploy ribs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/plugin/" rel="noopener noreferrer"&gt;4WP Plugins&lt;/a&gt;: what we build with this cycle.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;The SDLC Umbrella is a 4wp.dev model. Originally published on &lt;a href="https://4wp.dev/sdlc/" rel="noopener noreferrer"&gt;4wp.dev/sdlc&lt;/a&gt;, where you can scroll through sixty years of weather and open every phase as the client, the project and the developer see it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anatoliy Dovgun, &lt;a href="https://4wp.dev/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;. About WordPress, with love, from developers who love it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The Hook That Fires Twice: Why save_post Isn't as Simple as It Looks (4wp.dev Hooks Series)</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:29:29 +0000</pubDate>
      <link>https://dev.to/adovgun/the-hook-that-fires-twice-why-savepost-isnt-as-simple-as-it-looks-4wpdev-hooks-series-21ld</link>
      <guid>https://dev.to/adovgun/the-hook-that-fires-twice-why-savepost-isnt-as-simple-as-it-looks-4wpdev-hooks-series-21ld</guid>
      <description>&lt;p&gt;A save_post handler that works perfectly in testing and loops infinitely in production — because of one missing remove_action() call. The full contract of all 4 save/CRUD hooks, from the 4wp.dev Hooks series.&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/save-crud/" rel="noopener noreferrer"&gt;https://4wp.dev/hooks/category/save-crud/&lt;/a&gt; on 4wp.dev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A developer hooks &lt;code&gt;save_post&lt;/code&gt; to sync a post to an external service on every save. The code works in testing — one save, one sync call. In production, the same save triggers the sync twice, sometimes three times, and occasionally the site hangs entirely on save. Nothing in the code changed between testing and production; what changed is that &lt;code&gt;save_post&lt;/code&gt; fires more often than most people assume, and one very common pattern inside its handler — calling &lt;code&gt;wp_update_post()&lt;/code&gt; from within it — creates a save loop that keeps calling itself.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;save_post&lt;/code&gt; fires on every post write that goes through &lt;code&gt;wp_insert_post()&lt;/code&gt;, including autosaves, revisions, and any inline quick-edit — not just the deliberate "Publish" click a developer pictures when writing the handler. A handler that isn't guarded for autosaves does its expensive work (external API calls, cache rebuilds) far more often than intended, silently multiplying load. Worse, calling &lt;code&gt;wp_update_post()&lt;/code&gt; inside a &lt;code&gt;save_post&lt;/code&gt; handler without removing the action first re-triggers &lt;code&gt;save_post&lt;/code&gt; again from inside itself — on a real project this has caused an infinite loop that only stopped when PHP's execution time limit killed the request, taking the save with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Solution — The Contract of Each Hook
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;save_post&lt;/code&gt; (action) — guard against autosaves, revisions, and yourself&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;'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;// Autosaves and revisions also fire save_post — skip both.&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="c1"&gt;// Remove our own action before calling wp_update_post(),&lt;/span&gt;
    &lt;span class="c1"&gt;// or this handler re-triggers itself and loops.&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="k"&gt;__FUNCTION__&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_normalize_title&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;post_title&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="k"&gt;__FUNCTION__&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;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 &lt;code&gt;remove_action&lt;/code&gt;/&lt;code&gt;add_action&lt;/code&gt; pair around any internal &lt;code&gt;wp_update_post()&lt;/code&gt; call is the single most important line in this whole category — skip it, and any update from inside the handler re-fires the same handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wp_insert_post&lt;/code&gt; (action) — the broader event underneath save_post&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;'wp_insert_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="k"&gt;if&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="nf"&gt;forwp_log_post_updated&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="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;forwp_log_post_created&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="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;wp_insert_post&lt;/code&gt; fires for the same reasons &lt;code&gt;save_post&lt;/code&gt; does, plus programmatic inserts that don't go through the post editor at all — an import script, a REST API create, a scheduled task. If your logic needs to run regardless of how the post was created, this is the hook, not &lt;code&gt;save_post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;transition_post_status&lt;/code&gt; (action) — logic that depends on the status change itself&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;'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_send_publish_notification&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;save_post&lt;/code&gt; fires on every save regardless of status; &lt;code&gt;transition_post_status&lt;/code&gt; is the correct hook specifically for "this post just became published" (or unpublished, or moved to draft) — without it, a developer usually ends up manually comparing &lt;code&gt;$post-&amp;gt;post_status&lt;/code&gt; against a cached previous value inside &lt;code&gt;save_post&lt;/code&gt;, which is fragile and easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;before_delete_post&lt;/code&gt; (action) — cleanup before the record is gone for good&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;'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_delete_external_record&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_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'forwp_external_id'&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="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;This fires on permanent deletion (bypassing trash, or emptying it), while the post's data — including its meta — is still readable. Waiting until after the fact means the meta you needed to clean up an external reference is already gone.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;remove_action&lt;/code&gt;/&lt;code&gt;add_action&lt;/code&gt; guard, the autosave/revision check, and the distinction between &lt;code&gt;save_post&lt;/code&gt; and &lt;code&gt;wp_insert_post&lt;/code&gt; are exactly the kind of detail that separates a working demo from a handler that survives production traffic — and exactly the kind of thing worth putting on a WordPress developer skills checklist if you're evaluating a candidate or preparing for a technical interview yourself. A developer who has actually debugged a save-loop incident in production explains it in one sentence; one who hasn't usually reaches for &lt;code&gt;wp_update_post()&lt;/code&gt; inside &lt;code&gt;save_post&lt;/code&gt; without a second thought.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does my save_post handler run twice on a single click of "Update"?&lt;/strong&gt;&lt;br&gt;
Most commonly because WordPress also fires &lt;code&gt;save_post&lt;/code&gt; for the autosave that happens shortly before or after a manual save, and the handler has no guard against &lt;code&gt;wp_is_post_autosave()&lt;/code&gt;. Add that check first, before any other logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to call wp_update_post() inside a save_post handler?&lt;/strong&gt;&lt;br&gt;
Only if you remove the action before the call and re-add it after — otherwise &lt;code&gt;wp_update_post()&lt;/code&gt; triggers &lt;code&gt;save_post&lt;/code&gt; again, and if that handler unconditionally calls &lt;code&gt;wp_update_post()&lt;/code&gt; too, the result is an infinite loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the actual difference between save_post and wp_insert_post?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;save_post&lt;/code&gt; fires specifically for posts saved through the standard editor flow (and its autosaves/revisions). &lt;code&gt;wp_insert_post&lt;/code&gt; fires for the same cases plus any programmatic insert — an import, a cron job, a REST API request — so it's the more reliable hook when you need to catch every possible way a post gets created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use transition_post_status instead of checking post_status inside save_post?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;transition_post_status&lt;/code&gt; hands you both the old and new status directly, with no need to cache or compare values yourself. Reimplementing that comparison inside &lt;code&gt;save_post&lt;/code&gt; is a common source of subtle bugs when a post is saved multiple times in quick succession.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does before_delete_post fire when a post is moved to trash?&lt;/strong&gt;&lt;br&gt;
No — moving to trash uses &lt;code&gt;wp_trash_post&lt;/code&gt; and its own hooks. &lt;code&gt;before_delete_post&lt;/code&gt; fires specifically on permanent deletion: bypassing the trash, or emptying it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should be on a WordPress developer's skills checklist for these hooks specifically?&lt;/strong&gt;&lt;br&gt;
Whether they instinctively guard &lt;code&gt;save_post&lt;/code&gt; against autosaves and revisions, whether they know to remove their own action before an internal &lt;code&gt;wp_update_post()&lt;/code&gt; call, and whether they can explain the difference between &lt;code&gt;save_post&lt;/code&gt; and &lt;code&gt;wp_insert_post&lt;/code&gt; without hesitating — all three come up constantly in real production code, and all three are cheap to verify in an interview.&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;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://4wp.dev/wordpress-save-crud-hooks-fires-twice/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The Price of Attention: Fullstack WordPress Developers in the AI Venture (4wp.dev)</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Wed, 28 Oct 2026 21:55:09 +0000</pubDate>
      <link>https://dev.to/adovgun/the-price-of-attention-fullstack-wordpress-developers-in-the-ai-venture-4wpdev-53be</link>
      <guid>https://dev.to/adovgun/the-price-of-attention-fullstack-wordpress-developers-in-the-ai-venture-4wpdev-53be</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Attention is a resource. A person has only so much of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In 1971 Herbert A. Simon wrote that "a wealth of information creates a poverty of attention". He was talking about organizations. Today it describes one person: a fullstack WordPress developer with an IDE, a terminal and a few AI tools open at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fullstack Was Already a Lot
&lt;/h2&gt;

&lt;p&gt;A fullstack WordPress developer moves between PHP, blocks and JavaScript, the theme, the server and the client. Each switch costs something: you reload the context of the task before you can work again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents Multiply Output, Not Attention
&lt;/h2&gt;

&lt;p&gt;AI tools take away typing. They do not take away the need to understand what was typed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; write code, run tests and ask for permissions. Each diff is something you have to review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents&lt;/strong&gt; run in parallel and come back with reports. Five subagents give you five reports to read, not five finished tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chats&lt;/strong&gt; draft roadmaps, texts and replies. Each open chat is one more thread you have to keep in your head.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tools work in parallel, but your attention does not. Every stream ends up in one place: your review.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Spend It at 4wp.dev
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. One main thread.&lt;/strong&gt; Choose the task that matters now. Everything else waits or goes to an agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Delegate with a check.&lt;/strong&gt; Give an agent a clear task and a clear finish: a test that must pass, or a WP-CLI or REST call that must return the expected result. Then you check the result, not every step. Every chapter of the &lt;a href="https://4wp.dev/Abilities-API/" rel="noopener noreferrer"&gt;4wp.dev Abilities API course&lt;/a&gt; ends with exactly this kind of check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Subagents for conclusions.&lt;/strong&gt; Use a subagent when you need an answer ("where is this hook registered?"), not a dump of files to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Close what you are not using.&lt;/strong&gt; A chat that has finished its job goes away. An open tab is a small, constant demand on your attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Notifications in batches.&lt;/strong&gt; Messages, mail and CI can wait until the end of a block of work. The client call can wait five minutes too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;AI made code cheap. Attention stayed expensive. The developer's job is moving from writing to deciding and reviewing, and both take attention. Plan it as carefully as you plan a server's resources.&lt;/p&gt;

&lt;p&gt;More on WordPress development with AI agents: &lt;a href="https://4wp.dev/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>softwaredevelopment</category>
      <category>ai</category>
      <category>productivity</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>False Affordance in WordPress (4wp.dev Methodologies): When UI Promises What Code Doesn't Deliver</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Thu, 22 Oct 2026 19:26:58 +0000</pubDate>
      <link>https://dev.to/adovgun/false-affordance-in-wordpress-4wpdev-methodologies-when-ui-promises-what-code-doesnt-deliver-3lca</link>
      <guid>https://dev.to/adovgun/false-affordance-in-wordpress-4wpdev-methodologies-when-ui-promises-what-code-doesnt-deliver-3lca</guid>
      <description>&lt;p&gt;A "Show more" button on my own portfolio showed nothing, and the console stayed clean. This post is the write-up: what a false affordance is, why it slips into WordPress themes and plugins, and how to fix it in a classic PHP template and in a block theme (FSE) with a Query Loop + JS. It is part of the Methodologies series on 4wp.dev.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Related on 4wp.dev: &lt;a href="https://4wp.dev/methodologies/full-site-editing-theme-json/" rel="noopener noreferrer"&gt;Full Site Editing and theme.json&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/component-driven-development/" rel="noopener noreferrer"&gt;Component-Driven Development&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/atomic-design/" rel="noopener noreferrer"&gt;Atomic Design&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/tdd/" rel="noopener noreferrer"&gt;TDD&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a false affordance is
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;false affordance&lt;/strong&gt; is an interface element that suggests an action is possible, but the action does not actually happen. A button that does nothing. A toggle with no effect. A setting that is saved and never applied.&lt;/p&gt;

&lt;p&gt;The term comes from Don Norman's &lt;em&gt;The Design of Everyday Things&lt;/em&gt;: a door handle you are supposed to push is a false affordance, because the handle invites you to pull.&lt;/p&gt;

&lt;p&gt;It sounds like a design topic, but in WordPress it is mostly an engineering one. The design shows the promise. The code is what breaks it. And it is not limited to the UI: the same thing happens in APIs, hooks and function signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Real Case: The "Show More" That Shows Nothing
&lt;/h2&gt;

&lt;p&gt;I will start with my own site, because it is the most honest example I have.&lt;/p&gt;

&lt;p&gt;On the home page of &lt;a href="https://anatoliy.dovgun.com/" rel="noopener noreferrer"&gt;anatoliy.dovgun.com&lt;/a&gt; there is a Projects block: a list of projects, and under it two controls, &lt;strong&gt;"Show more"&lt;/strong&gt; and &lt;strong&gt;"All projects →"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All projects were already rendered on the page.&lt;/li&gt;
&lt;li&gt;"Show more" was still there.&lt;/li&gt;
&lt;li&gt;Clicking it did one thing: the button hid itself. Nothing new appeared.&lt;/li&gt;
&lt;li&gt;The browser console was clean. No errors, no warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is what makes this kind of bug dangerous. Nothing failed, so nothing reported a failure. The button simply promised an action that did not exist. It is a silent bug, and it lived on my own portfolio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it happened
&lt;/h3&gt;

&lt;p&gt;The block came from the prototype / UI kit as one piece: "list + Show more button". The limit (how many items are visible before "Show more") was taken from the prototype, not from the data. When the static prototype became a real query over real projects, the list changed, but the button stayed exactly as it was drawn.&lt;/p&gt;

&lt;p&gt;So the button rendered always, regardless of the data. Nobody asked the one question that matters for this control: &lt;em&gt;are there any hidden items right now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In other words, nobody clicked through every state. The designed state ("more projects than the limit") worked in the head of whoever drew it. The real state ("all projects already visible") was never clicked. A curious QA, or a developer with open eyes, would have clicked it in the first five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Render the control only when the action is possible.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule is the same everywhere. Where you apply it depends on who renders the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario A: classic PHP template
&lt;/h3&gt;

&lt;p&gt;In a classic theme or a server-rendered template, PHP renders the list and knows the total. So PHP decides. If there are no hidden items, the button is not in the HTML at all:&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="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$visible_limit&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Show more&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"readall"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/projects/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;All projects →&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hidden items, no button. Nothing is promised that cannot be delivered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario B: block theme (FSE), Query Loop + JS
&lt;/h3&gt;

&lt;p&gt;In a block theme it works differently, and this is where people coming from classic templates get caught.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Query Loop&lt;/strong&gt; block renders the items. The "Show more" button usually lives in the template or pattern as static markup, a Custom HTML block or a button block. No PHP condition wraps it. The limit is applied in the browser by JavaScript: show the first N items, hide the rest, reveal more on click.&lt;/p&gt;

&lt;p&gt;That means &lt;strong&gt;JS must own the button state&lt;/strong&gt;, and the state must be derived from the data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default state is "no button".&lt;/strong&gt; Put &lt;code&gt;hidden&lt;/code&gt; on it in the markup. Without JS, or before JS runs, the page promises nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS counts the matching items&lt;/strong&gt; (taking active filters into account) and shows the button only if there is something left to show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every click and every filter change recalculates.&lt;/strong&gt; When nothing is left, the button hides again.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pfoot"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Show more&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"readall"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/projects/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;All projects →&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderProjects&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="nx"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchingItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// respects the active filter&lt;/span&gt;
    &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;li&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="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&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="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&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="nx"&gt;hasMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;more&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nx"&gt;hasMore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;more&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;visibleCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;renderProjects&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;If the button sits at the start of a flex row with &lt;code&gt;justify-content: space-between&lt;/code&gt;, give the link &lt;code&gt;margin-left: auto&lt;/code&gt;. Otherwise "All projects →" jumps to the left when the button disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FSE gotcha.&lt;/strong&gt; Once a template has been saved in the Site Editor, WordPress uses the copy stored in the database, not the file in your theme. Fix the pattern file, reload, and nothing changes. When a fix "doesn't apply" in a block theme, check whether the template was customized in the Site Editor. If it was, fix it there or reset it to the theme version. More on how block themes are put together: &lt;a href="https://4wp.dev/methodologies/full-site-editing-theme-json/" rel="noopener noreferrer"&gt;Full Site Editing and theme.json&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  After
&lt;/h3&gt;

&lt;p&gt;On the fixed version of my site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The home page shows the first 5 projects.&lt;/li&gt;
&lt;li&gt;"Show more" loads the next 5.&lt;/li&gt;
&lt;li&gt;When everything is visible, the button is gone.&lt;/li&gt;
&lt;li&gt;"All projects →" always stays. It is a real action and real navigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Same Pattern Elsewhere
&lt;/h2&gt;

&lt;p&gt;Once you see it, you see it everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Read more / Show less" on cards where the text is already shown in full, for example an "Our story" timeline where the toggle only changes its own label.&lt;/li&gt;
&lt;li&gt;Pagination that shows "Next" on the last page.&lt;/li&gt;
&lt;li&gt;A "Load more" that silently returns zero items.&lt;/li&gt;
&lt;li&gt;Filter chips that produce an empty list with no message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is always the same question: &lt;em&gt;what happens to this control when there is no data, too little data, or an error?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens More Now: the AI Factor
&lt;/h2&gt;

&lt;p&gt;AI-generated code makes this worse, for a simple reason. Give a model a mockup and it reproduces &lt;strong&gt;all&lt;/strong&gt; the UI states that are drawn: the list, the button, the link. What it does not have is the data context: is the list empty, short, missing, filtered?&lt;/p&gt;

&lt;p&gt;The generated code looks plausible, it runs, the console is clean, so the review becomes superficial. Nobody asks what the button does when there is nothing to show, because the code "obviously" handles the button.&lt;/p&gt;

&lt;p&gt;If you work with AI on WordPress code, add one question to every review of UI code: &lt;strong&gt;"What happens if there is no data?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Not Only Design: Two Types of Interfaces
&lt;/h2&gt;

&lt;p&gt;False affordances are not limited to buttons. Any interface makes promises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External interfaces (UI):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Buttons or toggles with no effect.&lt;/li&gt;
&lt;li&gt;Admin settings that are saved but never applied.&lt;/li&gt;
&lt;li&gt;An admin notice "Dismiss" that comes back on the next page load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Internal interfaces (for developers):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hook or filter is documented, but &lt;code&gt;apply_filters()&lt;/code&gt; is never called with that name.&lt;/li&gt;
&lt;li&gt;A shortcode or block attribute is accepted and ignored.&lt;/li&gt;
&lt;li&gt;A function parameter is accepted but never used.&lt;/li&gt;
&lt;li&gt;A REST endpoint returns &lt;code&gt;success&lt;/code&gt; but does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second group is worse in a way: the "user" is another developer, who builds on a feature that does not exist and only finds out in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lost trust.&lt;/strong&gt; A button that does nothing teaches users that your interface lies. They stop clicking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support tickets.&lt;/strong&gt; "I clicked Show more and nothing happened" is a real ticket someone has to answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviews.&lt;/strong&gt; On WordPress.org, a setting that does nothing is a one-star review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source.&lt;/strong&gt; Other developers build on a documented hook that never fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No data → no element.&lt;/strong&gt; Render controls conditionally: on the server in PHP templates, in JS with a &lt;code&gt;hidden&lt;/code&gt; default in block themes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every control has a visible effect.&lt;/strong&gt; If clicking it can do nothing, it should not be there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every documented option or hook actually works.&lt;/strong&gt; Search for the &lt;code&gt;apply_filters()&lt;/code&gt; / &lt;code&gt;do_action()&lt;/code&gt; call before you document it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the edge states:&lt;/strong&gt; empty, short, exactly at the limit, long, filtered, error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the AI-review question:&lt;/strong&gt; "What happens if there is no data?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Testing approaches that make edge states a habit, not an afterthought: &lt;a href="https://4wp.dev/methodologies/tdd/" rel="noopener noreferrer"&gt;TDD&lt;/a&gt; and &lt;a href="https://4wp.dev/methodologies/bdd/" rel="noopener noreferrer"&gt;BDD&lt;/a&gt; on 4wp.dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A small detail with a big effect. One button on my own portfolio kept promising something, and the console never said a word.&lt;/p&gt;

&lt;p&gt;An honest interface promises only what it delivers. Click through every state, including the boring ones, and let the data decide which controls exist.&lt;/p&gt;

&lt;p&gt;More methodologies for WordPress teams on &lt;a href="https://4wp.dev/methodologies/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>ui</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>False Affordance in WordPress (4wp.dev Methodologies): When UI Promises What Code Doesn't Deliver</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:41:44 +0000</pubDate>
      <link>https://dev.to/adovgun/false-affordance-in-wordpress-4wpdev-methodologies-when-ui-promises-what-code-doesnt-deliver-4jh8</link>
      <guid>https://dev.to/adovgun/false-affordance-in-wordpress-4wpdev-methodologies-when-ui-promises-what-code-doesnt-deliver-4jh8</guid>
      <description>&lt;p&gt;A "Show more" button on my own portfolio showed nothing, and the console stayed clean. This post is the write-up: what a false affordance is, why it slips into WordPress themes and plugins, and how to fix it in a classic PHP template and in a block theme (FSE) with a Query Loop + JS. It is part of the Methodologies series on 4wp.dev.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Related on 4wp.dev: &lt;a href="https://4wp.dev/methodologies/full-site-editing-theme-json/" rel="noopener noreferrer"&gt;Full Site Editing and theme.json&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/component-driven-development/" rel="noopener noreferrer"&gt;Component-Driven Development&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/atomic-design/" rel="noopener noreferrer"&gt;Atomic Design&lt;/a&gt; · &lt;a href="https://4wp.dev/methodologies/tdd/" rel="noopener noreferrer"&gt;TDD&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a false affordance is
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;false affordance&lt;/strong&gt; is an interface element that suggests an action is possible, but the action does not actually happen. A button that does nothing. A toggle with no effect. A setting that is saved and never applied.&lt;/p&gt;

&lt;p&gt;The term comes from Don Norman's &lt;em&gt;The Design of Everyday Things&lt;/em&gt;: a door handle you are supposed to push is a false affordance, because the handle invites you to pull.&lt;/p&gt;

&lt;p&gt;It sounds like a design topic, but in WordPress it is mostly an engineering one. The design shows the promise. The code is what breaks it. And it is not limited to the UI: the same thing happens in APIs, hooks and function signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Real Case: The "Show More" That Shows Nothing
&lt;/h2&gt;

&lt;p&gt;I will start with my own site, because it is the most honest example I have.&lt;/p&gt;

&lt;p&gt;On the home page of &lt;a href="https://anatoliy.dovgun.com/" rel="noopener noreferrer"&gt;anatoliy.dovgun.com&lt;/a&gt; there is a Projects block: a list of projects, and under it two controls, &lt;strong&gt;"Show more"&lt;/strong&gt; and &lt;strong&gt;"All projects →"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All projects were already rendered on the page.&lt;/li&gt;
&lt;li&gt;"Show more" was still there.&lt;/li&gt;
&lt;li&gt;Clicking it did one thing: the button hid itself. Nothing new appeared.&lt;/li&gt;
&lt;li&gt;The browser console was clean. No errors, no warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is what makes this kind of bug dangerous. Nothing failed, so nothing reported a failure. The button simply promised an action that did not exist. It is a silent bug, and it lived on my own portfolio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it happened
&lt;/h3&gt;

&lt;p&gt;The block came from the prototype / UI kit as one piece: "list + Show more button". The limit (how many items are visible before "Show more") was taken from the prototype, not from the data. When the static prototype became a real query over real projects, the list changed, but the button stayed exactly as it was drawn.&lt;/p&gt;

&lt;p&gt;So the button rendered always, regardless of the data. Nobody asked the one question that matters for this control: &lt;em&gt;are there any hidden items right now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In other words, nobody clicked through every state. The designed state ("more projects than the limit") worked in the head of whoever drew it. The real state ("all projects already visible") was never clicked. A curious QA, or a developer with open eyes, would have clicked it in the first five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Render the control only when the action is possible.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule is the same everywhere. Where you apply it depends on who renders the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario A: classic PHP template
&lt;/h3&gt;

&lt;p&gt;In a classic theme or a server-rendered template, PHP renders the list and knows the total. So PHP decides. If there are no hidden items, the button is not in the HTML at all:&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="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$visible_limit&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Show more&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"readall"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/projects/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;All projects →&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hidden items, no button. Nothing is promised that cannot be delivered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario B: block theme (FSE), Query Loop + JS
&lt;/h3&gt;

&lt;p&gt;In a block theme it works differently, and this is where people coming from classic templates get caught.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Query Loop&lt;/strong&gt; block renders the items. The "Show more" button usually lives in the template or pattern as static markup, a Custom HTML block or a button block. No PHP condition wraps it. The limit is applied in the browser by JavaScript: show the first N items, hide the rest, reveal more on click.&lt;/p&gt;

&lt;p&gt;That means &lt;strong&gt;JS must own the button state&lt;/strong&gt;, and the state must be derived from the data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default state is "no button".&lt;/strong&gt; Put &lt;code&gt;hidden&lt;/code&gt; on it in the markup. Without JS, or before JS runs, the page promises nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS counts the matching items&lt;/strong&gt; (taking active filters into account) and shows the button only if there is something left to show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every click and every filter change recalculates.&lt;/strong&gt; When nothing is left, the button hides again.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pfoot"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"more"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Show more&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"readall"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/projects/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;All projects →&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderProjects&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="nx"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchingItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// respects the active filter&lt;/span&gt;
    &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;li&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="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&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="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&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="nx"&gt;hasMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;visibleCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;more&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nx"&gt;hasMore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;more&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;visibleCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;renderProjects&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;If the button sits at the start of a flex row with &lt;code&gt;justify-content: space-between&lt;/code&gt;, give the link &lt;code&gt;margin-left: auto&lt;/code&gt;. Otherwise "All projects →" jumps to the left when the button disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FSE gotcha.&lt;/strong&gt; Once a template has been saved in the Site Editor, WordPress uses the copy stored in the database, not the file in your theme. Fix the pattern file, reload, and nothing changes. When a fix "doesn't apply" in a block theme, check whether the template was customized in the Site Editor. If it was, fix it there or reset it to the theme version. More on how block themes are put together: &lt;a href="https://4wp.dev/methodologies/full-site-editing-theme-json/" rel="noopener noreferrer"&gt;Full Site Editing and theme.json&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  After
&lt;/h3&gt;

&lt;p&gt;On the fixed version of my site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The home page shows the first 5 projects.&lt;/li&gt;
&lt;li&gt;"Show more" loads the next 5.&lt;/li&gt;
&lt;li&gt;When everything is visible, the button is gone.&lt;/li&gt;
&lt;li&gt;"All projects →" always stays. It is a real action and real navigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Same Pattern Elsewhere
&lt;/h2&gt;

&lt;p&gt;Once you see it, you see it everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Read more / Show less" on cards where the text is already shown in full, for example an "Our story" timeline where the toggle only changes its own label.&lt;/li&gt;
&lt;li&gt;Pagination that shows "Next" on the last page.&lt;/li&gt;
&lt;li&gt;A "Load more" that silently returns zero items.&lt;/li&gt;
&lt;li&gt;Filter chips that produce an empty list with no message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is always the same question: &lt;em&gt;what happens to this control when there is no data, too little data, or an error?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens More Now: the AI Factor
&lt;/h2&gt;

&lt;p&gt;AI-generated code makes this worse, for a simple reason. Give a model a mockup and it reproduces &lt;strong&gt;all&lt;/strong&gt; the UI states that are drawn: the list, the button, the link. What it does not have is the data context: is the list empty, short, missing, filtered?&lt;/p&gt;

&lt;p&gt;The generated code looks plausible, it runs, the console is clean, so the review becomes superficial. Nobody asks what the button does when there is nothing to show, because the code "obviously" handles the button.&lt;/p&gt;

&lt;p&gt;If you work with AI on WordPress code, add one question to every review of UI code: &lt;strong&gt;"What happens if there is no data?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Not Only Design: Two Types of Interfaces
&lt;/h2&gt;

&lt;p&gt;False affordances are not limited to buttons. Any interface makes promises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External interfaces (UI):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Buttons or toggles with no effect.&lt;/li&gt;
&lt;li&gt;Admin settings that are saved but never applied.&lt;/li&gt;
&lt;li&gt;An admin notice "Dismiss" that comes back on the next page load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Internal interfaces (for developers):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hook or filter is documented, but &lt;code&gt;apply_filters()&lt;/code&gt; is never called with that name.&lt;/li&gt;
&lt;li&gt;A shortcode or block attribute is accepted and ignored.&lt;/li&gt;
&lt;li&gt;A function parameter is accepted but never used.&lt;/li&gt;
&lt;li&gt;A REST endpoint returns &lt;code&gt;success&lt;/code&gt; but does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second group is worse in a way: the "user" is another developer, who builds on a feature that does not exist and only finds out in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lost trust.&lt;/strong&gt; A button that does nothing teaches users that your interface lies. They stop clicking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support tickets.&lt;/strong&gt; "I clicked Show more and nothing happened" is a real ticket someone has to answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviews.&lt;/strong&gt; On WordPress.org, a setting that does nothing is a one-star review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source.&lt;/strong&gt; Other developers build on a documented hook that never fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No data → no element.&lt;/strong&gt; Render controls conditionally: on the server in PHP templates, in JS with a &lt;code&gt;hidden&lt;/code&gt; default in block themes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every control has a visible effect.&lt;/strong&gt; If clicking it can do nothing, it should not be there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every documented option or hook actually works.&lt;/strong&gt; Search for the &lt;code&gt;apply_filters()&lt;/code&gt; / &lt;code&gt;do_action()&lt;/code&gt; call before you document it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the edge states:&lt;/strong&gt; empty, short, exactly at the limit, long, filtered, error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the AI-review question:&lt;/strong&gt; "What happens if there is no data?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Testing approaches that make edge states a habit, not an afterthought: &lt;a href="https://4wp.dev/methodologies/tdd/" rel="noopener noreferrer"&gt;TDD&lt;/a&gt; and &lt;a href="https://4wp.dev/methodologies/bdd/" rel="noopener noreferrer"&gt;BDD&lt;/a&gt; on 4wp.dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A small detail with a big effect. One button on my own portfolio kept promising something, and the console never said a word.&lt;/p&gt;

&lt;p&gt;An honest interface promises only what it delivers. Click through every state, including the boring ones, and let the data decide which controls exist.&lt;/p&gt;

&lt;p&gt;More methodologies for WordPress teams on &lt;a href="https://4wp.dev/methodologies/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>MVC in WordPress: The Pattern Everyone Learns, Few Truly Apply</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Mon, 12 Oct 2026 20:56:31 +0000</pubDate>
      <link>https://dev.to/adovgun/mvc-in-wordpress-the-pattern-everyone-learns-few-truly-apply-1hmo</link>
      <guid>https://dev.to/adovgun/mvc-in-wordpress-the-pattern-everyone-learns-few-truly-apply-1hmo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📌 Originally published on &lt;strong&gt;&lt;a href="https://4wp.dev/architectures/mvc/" rel="noopener noreferrer"&gt;4wp.dev → MVC Pattern in WordPress&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
Long read on LinkedIn: &lt;strong&gt;&lt;a href="https://www.linkedin.com/pulse/mvc-wordpress-pattern-everyone-learns-few-truly-anatoliy-dovgun-3lddf/" rel="noopener noreferrer"&gt;MVC in WordPress: the pattern everyone learns, few truly apply&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every WordPress developer has heard of &lt;strong&gt;MVC&lt;/strong&gt;. Very few plugins actually follow it.&lt;/p&gt;

&lt;p&gt;WordPress isn't an MVC framework — but its building blocks map cleanly onto &lt;strong&gt;Model · View · Controller&lt;/strong&gt; once you know where to look.&lt;/p&gt;

&lt;p&gt;👉 Interactive diagram with all examples: &lt;strong&gt;&lt;a href="https://4wp.dev/architectures/mvc/" rel="noopener noreferrer"&gt;4wp.dev/architectures/mvc&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Controller — the traffic director
&lt;/h2&gt;

&lt;p&gt;Receives the request, validates it, calls the Model, picks the View. &lt;strong&gt;No SQL. No HTML.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In WordPress, Controllers are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/hooks/" rel="noopener noreferrer"&gt;hook&lt;/a&gt; callbacks (&lt;code&gt;init&lt;/code&gt;, &lt;code&gt;save_post&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://4wp.dev/development/rest-api/" rel="noopener noreferrer"&gt;REST API&lt;/a&gt; route handlers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp_ajax_*&lt;/code&gt; handlers
&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="c1"&gt;// Controller — REST endpoint&lt;/span&gt;
&lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'myplugin/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'read'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'callback'&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="kt"&gt;WP_REST_Request&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_all_products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// → Model&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rest_ensure_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$products&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;➡️ More Controller examples (hooks, AJAX): &lt;a href="https://4wp.dev/architectures/mvc/#mvc-detail-controller" rel="noopener noreferrer"&gt;4wp.dev/architectures/mvc/#mvc-detail-controller&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Model — the data layer
&lt;/h2&gt;

&lt;p&gt;Owns data and business rules. Returns clean data. &lt;strong&gt;Never renders HTML.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In WordPress, Models are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom Post Types, taxonomies, registered meta (schema)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$wpdb&lt;/code&gt; data-access functions/classes&lt;/li&gt;
&lt;li&gt;Options API settings classes
&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="c1"&gt;// Model — the only layer that touches options&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PluginSettings&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;const&lt;/span&gt; &lt;span class="no"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'myplugin_settings'&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;static&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="kt"&gt;string&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;$default&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;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;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;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="nv"&gt;$default&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;➡️ CPT schema and &lt;code&gt;$wpdb&lt;/code&gt; examples: &lt;a href="https://4wp.dev/architectures/mvc/#mvc-detail-model" rel="noopener noreferrer"&gt;4wp.dev/architectures/mvc/#mvc-detail-model&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. View — the presentation layer
&lt;/h2&gt;

&lt;p&gt;Renders data it receives. &lt;strong&gt;No queries. No business logic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In WordPress, Views are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;theme templates (&lt;code&gt;single.php&lt;/code&gt;, &lt;code&gt;archive.php&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;shortcode output&lt;/li&gt;
&lt;li&gt;Gutenberg block &lt;code&gt;save()&lt;/code&gt; / render templates
&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="c"&gt;&amp;lt;!-- View — escape, display, nothing else --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"product"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;esc_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;esc_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;➡️ Shortcode and block examples: &lt;a href="https://4wp.dev/architectures/mvc/#mvc-detail-view" rel="noopener noreferrer"&gt;4wp.dev/architectures/mvc/#mvc-detail-view&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Real plugin: 4WP Weather
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/4wp-weather/" rel="noopener noreferrer"&gt;&lt;strong&gt;4WP Weather&lt;/strong&gt;&lt;/a&gt; (&lt;a href="https://github.com/4wpdev/4wp-weather" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;) is built along the same lines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;In the plugin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Controller&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;REST route &lt;code&gt;forwp-weather/v1/weather&lt;/code&gt; — the block calls it, PHP does the remote request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Provider (OpenWeatherMap), API key in Options API, transient cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;View&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gutenberg block with Small / Compact / Advanced templates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Result: the API key never reaches the browser, cache lives in one place, and the block only renders.&lt;/p&gt;

&lt;p&gt;Another example of the 4WP approach: &lt;a href="https://wordpress.org/plugins/4wp-seo-helper/" rel="noopener noreferrer"&gt;&lt;strong&gt;4WP SEO Helper&lt;/strong&gt;&lt;/a&gt; (&lt;a href="https://github.com/4wpdev/4wp-seo-helper" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Controllers are thin: validate → call Model → return/render&lt;/li&gt;
&lt;li&gt;[ ] Only the Model touches &lt;code&gt;$wpdb&lt;/code&gt;, options, meta&lt;/li&gt;
&lt;li&gt;[ ] Views contain only escaping and markup&lt;/li&gt;
&lt;li&gt;[ ] Nonces and capability checks live in the Controller&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - [ ] Templates never call &lt;code&gt;WP_Query&lt;/code&gt; directly
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Go deeper
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🧩 &lt;a href="https://4wp.dev/architectures/mvc/" rel="noopener noreferrer"&gt;MVC &amp;amp; Layers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🧱 &lt;a href="https://4wp.dev/architectures/solid/" rel="noopener noreferrer"&gt;SOLID in WordPress&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎯 &lt;a href="https://4wp.dev/oop/" rel="noopener noreferrer"&gt;OOP&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📐 &lt;a href="https://4wp.dev/architectures/#gof-patterns" rel="noopener noreferrer"&gt;Design Patterns&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🏛️ &lt;a href="https://4wp.dev/architectures/" rel="noopener noreferrer"&gt;All Architectures&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - 🔌 &lt;a href="https://4wp.dev/plugin/" rel="noopener noreferrer"&gt;4WP plugins&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://www.linkedin.com/pulse/mvc-wordpress-pattern-everyone-learns-few-truly-anatoliy-dovgun-3lddf/" rel="noopener noreferrer"&gt;Anatoliy Dovgun&lt;/a&gt; · &lt;a href="https://4wp.dev/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt; — FSE WordPress development.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Follow: &lt;a href="https://github.com/4wpdev" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/adovgun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://dev.to/adovgun/"&gt;DEV&lt;/a&gt; · &lt;a href="https://medium.com/@adovgun" rel="noopener noreferrer"&gt;Medium&lt;/a&gt; · &lt;a href="https://4wp.hashnode.dev/" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt; · &lt;a href="https://www.youtube.com/@4wpdev" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Front Door You Forgot You Built: What These 5 REST API Hooks Actually Gate (4wp.dev Hooks Series)</title>
      <dc:creator>Anatoliy Dovgun</dc:creator>
      <pubDate>Fri, 09 Oct 2026 15:28:24 +0000</pubDate>
      <link>https://dev.to/adovgun/the-front-door-you-forgot-you-built-what-these-5-rest-api-hooks-actually-gate-4wpdev-hooks-47j0</link>
      <guid>https://dev.to/adovgun/the-front-door-you-forgot-you-built-what-these-5-rest-api-hooks-actually-gate-4wpdev-hooks-47j0</guid>
      <description>&lt;p&gt;Every WordPress site answers REST requests at /wp-json/ whether you built an API on purpose or not. The 5 hooks that actually gate it, in the order they really fire (not the order you registered them), from the 4wp.dev Hooks series.&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/rest-api/" rel="noopener noreferrer"&gt;https://4wp.dev/hooks/category/rest-api/&lt;/a&gt; on 4wp.dev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every WordPress install answers REST requests at &lt;code&gt;/wp-json/&lt;/code&gt; whether or not anyone deliberately built an API — and most sites have at least one custom field, integration, or automation hooked into it without a clear picture of which of these five hooks actually runs first, or what happens if two of them disagree about whether a request should even be allowed through. The most common version of this problem: a developer adds an authentication check in the wrong hook, and it either never runs early enough to block anything, or it overwrites an error another check already set — silently reopening a door someone else had just closed.&lt;/p&gt;

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

&lt;p&gt;These hooks don't fire in registration order — they fire in a fixed pipeline order regardless of when your code adds them, and getting that order wrong changes what your check can actually see or prevent. A permission check added to the wrong hook runs after the request has already been routed and partially processed, which means "blocking" it there is really just hiding the result, not preventing the work that already happened. And &lt;code&gt;rest_authentication_errors&lt;/code&gt; specifically is a filter that receives whatever error a previous check already set — overwriting that value unconditionally silently discards someone else's rejection and can open access nobody intended to leave open. For deeper coverage of designing custom endpoints and their permission model, see &lt;a href="https://4wp.dev/development/rest-api/custom-endpoints/" rel="noopener noreferrer"&gt;Custom Endpoints &amp;amp; Routes&lt;/a&gt; and &lt;a href="https://4wp.dev/development/rest-api/authentication/" rel="noopener noreferrer"&gt;Authentication &amp;amp; Permissions&lt;/a&gt; on 4wp.dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Solution — The Pipeline, in Order
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — &lt;code&gt;rest_api_init&lt;/code&gt; (action) — where every custom route gets registered&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;'rest_api_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_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'4wp/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/widgets'&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;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'forwp_get_widgets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fires once, early, before any specific request is being handled — it's registration, not request handling. Nothing here should depend on the current request's data; that comes later in the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — &lt;code&gt;rest_pre_dispatch&lt;/code&gt; (filter) — the earliest point to short-circuit a request entirely&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;'rest_pre_dispatch'&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;$result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_route&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'/4wp/v1/'&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;forwp_is_maintenance_mode&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'forwp_maintenance'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'API temporarily unavailable.'&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;503&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;$result&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;Returning a non-null value here skips routing and the endpoint's own callback entirely — the right tool for a blanket condition (maintenance mode, a global rate limit) that should apply before any specific route's own logic runs, not a substitute for per-route &lt;code&gt;permission_callback&lt;/code&gt; checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — &lt;code&gt;rest_authentication_errors&lt;/code&gt; (filter) — respect what's already there, or you'll undo someone else's rejection&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;'rest_authentication_errors'&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;$result&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// If a previous check already set an error, don't overwrite it —&lt;/span&gt;
    &lt;span class="c1"&gt;// or you silently discard someone else's rejection.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$result&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;$result&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;forwp_request_is_from_blocked_ip&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'forwp_blocked'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Access denied.'&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;403&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;$result&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;$result&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; when authentication hasn't yet failed or succeeded, &lt;code&gt;true&lt;/code&gt;/a user object on success, or a &lt;code&gt;WP_Error&lt;/code&gt; on failure — the same three-state contract that trips people up on the &lt;code&gt;authenticate&lt;/code&gt; hook in the Auth category. Skipping the &lt;code&gt;null !== $result&lt;/code&gt; check here is exactly the mistake that overwrites another plugin's authentication rejection with your own permissive default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — &lt;code&gt;rest_prepare_post&lt;/code&gt; (filter) — shaping the response before it leaves the server&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;'rest_prepare_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;$response&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;$request&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;unset&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="s1"&gt;'internal_notes'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Never let an internal-only field reach the public response.&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set_data&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&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;This is the last checkpoint before a post's REST representation is sent — the correct place both to add computed fields and to strip anything that shouldn't be public, rather than trying to prevent a field from being queryable in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — &lt;code&gt;rest_insert_post&lt;/code&gt; (action) — side effects after a REST-driven write&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;'rest_insert_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&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$creating&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="nv"&gt;$creating&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_new_post_via_api&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fires after a post is created or updated specifically through the REST API — the &lt;code&gt;$creating&lt;/code&gt; boolean tells you which. Logic that should run regardless of how a post was saved (API or editor) belongs on &lt;code&gt;wp_insert_post&lt;/code&gt; instead, covered in the &lt;a href="https://4wp.dev/hooks/category/save-crud/" rel="noopener noreferrer"&gt;Save &amp;amp; CRUD Hooks&lt;/a&gt; category.&lt;/p&gt;

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

&lt;p&gt;Getting the order of these five hooks wrong doesn't usually throw a visible error — it just means a permission check runs too late to matter, or a public response leaks a field nobody meant to expose. A WordPress developer who has built and audited custom REST APIs checks the whole pipeline order as one system, the same discipline covered in more depth in &lt;a href="https://4wp.dev/development/rest-api/" rel="noopener noreferrer"&gt;REST API Development&lt;/a&gt; on 4wp.dev — outsourcing that specific review to someone who has already made and fixed these mistakes elsewhere is usually faster than tracing a silent permission gap through five hooks yourself.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do these five hooks fire in the order they're registered in code?&lt;/strong&gt;&lt;br&gt;
No — they fire in a fixed pipeline order determined by the REST API's own request lifecycle (&lt;code&gt;rest_pre_dispatch&lt;/code&gt; → authentication → routing → &lt;code&gt;rest_prepare_*&lt;/code&gt; → &lt;code&gt;rest_insert_*&lt;/code&gt;), regardless of when your plugin registers its callbacks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between rest_pre_dispatch and rest_authentication_errors for blocking a request?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;rest_pre_dispatch&lt;/code&gt; can short-circuit a request entirely, before routing, for conditions that apply broadly (maintenance mode, global rate limits). &lt;code&gt;rest_authentication_errors&lt;/code&gt; is specifically about identity and access — and it can undo another check's rejection if you don't respect an existing non-null &lt;code&gt;$result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my rest_authentication_errors check stop another plugin's block from working?&lt;/strong&gt;&lt;br&gt;
Almost certainly because the filter didn't check whether &lt;code&gt;$result&lt;/code&gt; was already non-null before returning its own value — overwriting an existing &lt;code&gt;WP_Error&lt;/code&gt; (or a success) with your own default silently replaces the other check's decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use rest_prepare_post or register_rest_field to add a computed field to the response?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;register_rest_field&lt;/code&gt; for a straightforward computed value tied to the schema. &lt;code&gt;rest_prepare_post&lt;/code&gt; when you need to both add and remove fields, or when the logic depends on the full response object rather than a single field's value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does rest_insert_post fire for posts saved through the normal editor, not the REST API?&lt;/strong&gt;&lt;br&gt;
No — it fires specifically for posts created or updated through a REST API request. A normal editor save fires &lt;code&gt;wp_insert_post&lt;/code&gt; and &lt;code&gt;save_post&lt;/code&gt;, not &lt;code&gt;rest_insert_post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should a dedicated WordPress team review a site's REST API hook usage?&lt;/strong&gt;&lt;br&gt;
When multiple plugins or custom code hook into authentication and response-shaping on the same endpoints — at that point, tracing which check actually wins requires understanding the full pipeline order, which is exactly the kind of audit worth outsourcing rather than reconstructing from scratch under time pressure.&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/rest-api/" rel="noopener noreferrer"&gt;REST API Hooks&lt;/a&gt; page, and the whole set is also available as one PDF to keep. For the broader discipline of building custom REST APIs on WordPress, see &lt;a href="https://4wp.dev/development/rest-api/" rel="noopener noreferrer"&gt;REST API Development&lt;/a&gt; on 4wp.dev.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://4wp.dev/wordpress-rest-api-hooks-front-door/" rel="noopener noreferrer"&gt;4wp.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <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>
  </channel>
</rss>
