<?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: Kushang Tailor</title>
    <description>The latest articles on DEV Community by Kushang Tailor (@kushang_tailor).</description>
    <link>https://dev.to/kushang_tailor</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%2F3900414%2F0d2c9537-2b52-4907-b8ab-96018be8376a.png</url>
      <title>DEV Community: Kushang Tailor</title>
      <link>https://dev.to/kushang_tailor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kushang_tailor"/>
    <language>en</language>
    <item>
      <title>WordPress REST API Deep Dive: Custom Routes, Multiple Methods on One Endpoint, and Full CRUD</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:19:57 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/wordpress-rest-api-deep-dive-custom-routes-multiple-methods-on-one-endpoint-and-full-crud-41bk</link>
      <guid>https://dev.to/kushang_tailor/wordpress-rest-api-deep-dive-custom-routes-multiple-methods-on-one-endpoint-and-full-crud-41bk</guid>
      <description>&lt;h2&gt;
  
  
  🚪 Why the WP REST API Deserves Your Attention
&lt;/h2&gt;

&lt;p&gt;Every WordPress site already ships with a front door for data. Open &lt;code&gt;yourdomain.com/wp-json/&lt;/code&gt; and you'll see a big JSON map of everything the site can do. That's the &lt;strong&gt;WordPress REST API&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It lets &lt;em&gt;anything&lt;/em&gt; that speaks HTTP talk to WordPress: a React app, a mobile app, a Zapier flow, or another website. No admin screens, no page reloads. Just JSON in, JSON out.&lt;/p&gt;

&lt;p&gt;In this article we'll go from "what is this thing?" to a complete, secure CRUD API for a custom &lt;strong&gt;Bookshelf&lt;/strong&gt;, step by step.&lt;/p&gt;

&lt;p&gt;By the end you'll know how to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register your own routes&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Share &lt;strong&gt;one URL&lt;/strong&gt; across several HTTP methods (the part that confuses almost everyone!)&lt;/li&gt;
&lt;li&gt;Validate, sanitize and protect every request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's go. 🚀&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Part 1: The WP REST API in Plain English
&lt;/h2&gt;

&lt;p&gt;Think of the REST API as a restaurant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespace&lt;/strong&gt; → the restaurant name (&lt;code&gt;bookshelf/v1&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route&lt;/strong&gt; → the menu item (&lt;code&gt;/books&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Method&lt;/strong&gt; → what you want to do with it (order, change, cancel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Callback&lt;/strong&gt; → the kitchen that does the work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission callback&lt;/strong&gt; → the bouncer at the door 🕴️&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full URL looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/wp-json/bookshelf/v1/books/42
                    └─ base ─┘└namespace┘└route┘└id┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WordPress already gives you built-in endpoints under the &lt;code&gt;wp/v2&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Latest 5 posts&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://example.com/wp-json/wp/v2/posts?per_page=5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are great for posts, pages and users. But the moment you have your own data (books, bookings, tickets, whatever), you'll want &lt;strong&gt;custom routes&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔤 HTTP methods at a glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Typical meaning&lt;/th&gt;
&lt;th&gt;WP constant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WP_REST_Server::READABLE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WP_REST_Server::CREATABLE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PUT&lt;/code&gt; / &lt;code&gt;PATCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Update data&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WP_REST_Server::EDITABLE&lt;/code&gt; (it also includes &lt;code&gt;POST&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WP_REST_Server::DELETABLE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALLMETHODS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Everything above&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WP_REST_Server::ALLMETHODS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two methods you didn't ask about but should know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PATCH&lt;/code&gt;&lt;/strong&gt; → a &lt;em&gt;partial&lt;/em&gt; update ("only change the rating").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OPTIONS&lt;/code&gt;&lt;/strong&gt; → WordPress answers automatically with your route's schema and allowed methods. You get it for free. 🎁&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧩 The four pieces of every endpoint
&lt;/h3&gt;

&lt;p&gt;When you register a route, each handler has these keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;methods&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which HTTP verbs this handler accepts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The function that does the work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission_callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Who is allowed in (&lt;strong&gt;required&lt;/strong&gt;, WordPress warns you if it's missing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;args&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The parameters, with &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;maximum&lt;/code&gt;, &lt;code&gt;sanitize_callback&lt;/code&gt;, &lt;code&gt;validate_callback&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your callback receives a &lt;code&gt;WP_REST_Request&lt;/code&gt; and should return a &lt;code&gt;WP_REST_Response&lt;/code&gt; (or a &lt;code&gt;WP_Error&lt;/code&gt; when something goes wrong).&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Part 2: Creating Your First Custom Route
&lt;/h2&gt;

&lt;p&gt;Routes are registered on the &lt;code&gt;rest_api_init&lt;/code&gt; hook. Here's the smallest useful example:&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;'bookshelf/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'/ping'&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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;READABLE&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="p"&gt;{&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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pong'&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="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="c1"&gt;// Public on purpose.&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;Visit &lt;code&gt;/wp-json/bookshelf/v1/ping&lt;/code&gt; and you'll get &lt;code&gt;{"message":"pong"}&lt;/code&gt;. 🎉&lt;/p&gt;

&lt;h3&gt;
  
  
  🔗 Dynamic routes with URL parameters
&lt;/h3&gt;

&lt;p&gt;Need an ID in the URL? Use a regex capture group:&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_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'bookshelf/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'/books/(?P&amp;lt;id&amp;gt;\d+)'&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="cm"&gt;/* handlers go here */&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;Inside the callback, read it with &lt;code&gt;$request-&amp;gt;get_param( 'id' )&lt;/code&gt;. The &lt;code&gt;\d+&lt;/code&gt; also means &lt;code&gt;/books/abc&lt;/code&gt; never reaches your code, so WordPress returns a 404 for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  🏗️ A clean OOP structure
&lt;/h3&gt;

&lt;p&gt;Since we're building a real API, let's keep it tidy. One class per job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bookshelf/
├── bookshelf.php               ← Plugin bootstrap
└── includes/
    ├── class-plugin.php        ← Wires everything up
    ├── class-book-post-type.php ← Registers the "book" post type
    └── class-books-controller.php ← All REST logic lives here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For storage we'll use a private custom post type called &lt;code&gt;book&lt;/code&gt;. This way we get trash, revisions and capabilities for free:&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="cd"&gt;/**
 * Registers the Book post type.
 *
 * @package Bookshelf
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Bookshelf&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Book_Post_Type
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Book_Post_Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Hook into WordPress.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register_hooks&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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;'init'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'register'&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="cd"&gt;/**
     * Register the post type (hidden from the default REST output).
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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;'book'&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;'label'&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;'Books'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bookshelf'&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;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'show_ui'&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;'show_in_rest'&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="c1"&gt;// We expose it with our own API.&lt;/span&gt;
                &lt;span class="s1"&gt;'supports'&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;'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="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;
  
  
  🔀 Part 3: Same Route, Different Methods
&lt;/h2&gt;

&lt;p&gt;Here's the question I hear the most:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"If &lt;code&gt;/books&lt;/code&gt; is used for both listing and creating, how does WordPress know which function to run?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;It looks at the HTTP method.&lt;/strong&gt; The route is the &lt;em&gt;address&lt;/em&gt;. The method is the &lt;em&gt;intent&lt;/em&gt;. 📬&lt;/p&gt;

&lt;p&gt;The trick is to pass an &lt;strong&gt;array of handlers&lt;/strong&gt; to a single &lt;code&gt;register_rest_route()&lt;/code&gt; call. Each handler declares its own methods, callback, permissions and args:&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_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'bookshelf/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'/books'&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="c1"&gt;// Handler 1: GET /books → list books.&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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;READABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get_items'&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="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_collection_args&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;// Handler 2: POST /books → create a book.&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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CREATABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'create_item'&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'can_create'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_write_args&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;Same URL, two different jobs, two different bouncers. Notice that listing is public while creating needs a capability.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔍 What WordPress does on each request
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Matches the URL against every registered route (the regex).&lt;/li&gt;
&lt;li&gt;Finds the handler whose &lt;code&gt;methods&lt;/code&gt; contains the request's method.&lt;/li&gt;
&lt;li&gt;Runs the &lt;code&gt;permission_callback&lt;/code&gt; (fail → &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Validates and sanitizes &lt;code&gt;args&lt;/code&gt; (fail → &lt;code&gt;400&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Finally runs your &lt;code&gt;callback&lt;/code&gt;. ✅&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  ⚠️ Gotchas worth remembering
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Method not registered?&lt;/strong&gt; If you send &lt;code&gt;DELETE /books&lt;/code&gt; and no handler accepts it, you get a &lt;code&gt;404 rest_no_route&lt;/code&gt;. WordPress treats "route + method" as the unit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't mix up the args.&lt;/strong&gt; Each handler has its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;args&lt;/code&gt;. Creating a book needs a required &lt;code&gt;title&lt;/code&gt;, but listing a book needs optional &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;per_page&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EDITABLE&lt;/code&gt; includes &lt;code&gt;POST&lt;/code&gt;.&lt;/strong&gt; If you use &lt;code&gt;WP_REST_Server::EDITABLE&lt;/code&gt; on &lt;code&gt;/books/{id}&lt;/code&gt;, then &lt;code&gt;POST /books/5&lt;/code&gt; will also update. It's handy, but it can surprise you. In this article I'll list &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;PATCH&lt;/code&gt; explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One call is cleaner.&lt;/strong&gt; Calling &lt;code&gt;register_rest_route()&lt;/code&gt; twice for the same route merges the handlers, but keeping them together in one call makes the code much easier to read.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📚 Part 4: The Complete Bookshelf API
&lt;/h2&gt;

&lt;p&gt;Here's the endpoint map we're building. Keep this table handy, we'll go through it top to bottom:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wp-json/bookshelf/v1/books&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a book&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wp-json/bookshelf/v1/books&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List books with pagination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wp-json/bookshelf/v1/books/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retrieve one book&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PUT&lt;/code&gt; / &lt;code&gt;PATCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wp-json/bookshelf/v1/books/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Update a book&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/wp-json/bookshelf/v1/books/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete a book&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  🧱 The controller skeleton
&lt;/h3&gt;

&lt;p&gt;All five handlers live in one class. First, the registration and the shared helpers:&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="cd"&gt;/**
 * Books REST controller.
 *
 * @package Bookshelf
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Bookshelf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_REST_Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Books_Controller
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Books_Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;REST_NAMESPACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bookshelf/v1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Hook into WordPress.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register_hooks&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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;'rest_api_init'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'register_routes'&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="cd"&gt;/**
     * Register the collection and single-item routes.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register_routes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Collection route: /books.&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REST_NAMESPACE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'/books'&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="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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;READABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get_items'&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="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_collection_args&lt;/span&gt;&lt;span class="p"&gt;(),&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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CREATABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'create_item'&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'can_create'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_write_args&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;span class="c1"&gt;// Single-item route: /books/{id}.&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REST_NAMESPACE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'/books/(?P&amp;lt;id&amp;gt;\d+)'&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="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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;READABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get_item'&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="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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'PUT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PATCH'&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'update_item'&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'can_modify'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_write_args&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="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="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DELETABLE&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delete_item'&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'can_modify'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="s1"&gt;'args'&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;'force'&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;'type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'default'&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="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Permission: can the current user create books?
     *
     * @return bool
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;can_create&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&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_posts'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Permission: can the current user edit or delete this specific book?
     *
     * @param WP_REST_Request $request Request object.
     * @return bool
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;can_modify&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;$request&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&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="nf"&gt;absint&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="s1"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the argument definitions. This is where &lt;strong&gt;validation and sanitization&lt;/strong&gt; happen &lt;em&gt;before&lt;/em&gt; your callback even runs:&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="cd"&gt;/**
     * Args for the list endpoint.
     *
     * @return array
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_collection_args&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'page'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'default'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'minimum'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'per_page'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'default'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'minimum'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'maximum'&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;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'search'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&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="cd"&gt;/**
     * Args for create and update.
     *
     * @param bool $is_create Title is required only when creating.
     * @return array
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_write_args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$is_create&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'required'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$is_create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'minLength'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'author'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'rating'&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;'type'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'minimum'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'maximum'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&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="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;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'enum'&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;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'draft'&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;Now the fun part. Each method gets its own mini-section: the code, a &lt;code&gt;curl&lt;/code&gt; call, and the response.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Testing tip:&lt;/strong&gt; for write requests, create an &lt;em&gt;Application Password&lt;/em&gt; (Users → Profile) and send it with &lt;code&gt;curl -u "username:app-password"&lt;/code&gt;. If you call the API from inside the browser, send the &lt;code&gt;X-WP-Nonce&lt;/code&gt; header instead.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  1️⃣ POST: Create a Book
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="cd"&gt;/**
     * POST /books
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response|WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create_item&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;$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;$post_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_insert_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;wp_slash&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;'post_type'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'book'&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="nv"&gt;$request&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="s1"&gt;'post_status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'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;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'meta_input'&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;'_bookshelf_author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'_bookshelf_rating'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rating'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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="kc"&gt;true&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;is_wp_error&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="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="nv"&gt;$response&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;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_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="mi"&gt;201&lt;/span&gt; &lt;span class="p"&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="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;rest_url&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;REST_NAMESPACE&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/books/'&lt;/span&gt; &lt;span class="mf"&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;201 Created&lt;/code&gt; status and the &lt;code&gt;Location&lt;/code&gt; header. It's the polite REST way to say "here's where your new thing lives."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"kushang:xxxx xxxx xxxx xxxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"title":"Atomic Habits","author":"James Clear","rating":5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt; (&lt;code&gt;201&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Atomic Habits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"James Clear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publish"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"link"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42"&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;p&gt;Forget the title? You'll automatically get a &lt;code&gt;400&lt;/code&gt; with &lt;code&gt;rest_missing_callback_param&lt;/code&gt;. You didn't write a single &lt;code&gt;if&lt;/code&gt; for that. 😎&lt;/p&gt;




&lt;h3&gt;
  
  
  2️⃣ GET: List Books (with Pagination)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="cd"&gt;/**
     * GET /books
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_items&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;$request&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WP_REST_Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&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;WP_Query&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;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'book'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'post_status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'paged'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'per_page'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'s'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'search'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&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;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prepare_item'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;$response&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;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&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="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'X-WP-Total'&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="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;found_posts&lt;/span&gt; &lt;span class="p"&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="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'X-WP-TotalPages'&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="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;max_num_pages&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination info goes in the &lt;strong&gt;headers&lt;/strong&gt;, just like core endpoints do. Your JSON body stays a clean array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books?page=1&amp;amp;per_page=2&amp;amp;search=habits"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt; (&lt;code&gt;200&lt;/code&gt;, headers: &lt;code&gt;X-WP-Total: 1&lt;/code&gt;, &lt;code&gt;X-WP-TotalPages: 1&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Atomic Habits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"James Clear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publish"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"link"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42"&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;p&gt;Try &lt;code&gt;per_page=500&lt;/code&gt; and you'll get a &lt;code&gt;400&lt;/code&gt; back, thanks to &lt;code&gt;maximum =&amp;gt; 50&lt;/code&gt;. Always cap your list endpoints! 🛡️&lt;/p&gt;




&lt;h3&gt;
  
  
  3️⃣ GET: Retrieve One Book
&lt;/h3&gt;

&lt;p&gt;We need a small helper that finds the book &lt;em&gt;or&lt;/em&gt; returns a proper 404:&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="cd"&gt;/**
     * Fetch a book or return a 404 error.
     *
     * @param int $id Book ID.
     * @return WP_Post|WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="p"&gt;)&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;=&lt;/span&gt; &lt;span class="nf"&gt;get_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&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;$post&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="n"&gt;WP_Post&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'book'&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="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;'bookshelf_not_found'&lt;/span&gt;&lt;span class="p"&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;'Book not found.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bookshelf'&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;404&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;$post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * GET /books/{id}
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response|WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_item&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;$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;$book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;absint&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="s1"&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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;$book&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_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt; &lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt; (&lt;code&gt;200&lt;/code&gt;) → the same single book object you saw above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing book&lt;/strong&gt; → &lt;code&gt;404&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bookshelf_not_found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Book not found."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&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;h3&gt;
  
  
  4️⃣ PUT / PATCH: Update a Book
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="cd"&gt;/**
     * PUT|PATCH /books/{id}
     *
     * Only the fields that were sent get updated.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response|WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update_item&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;$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;$book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;absint&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="s1"&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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;$book&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$update&lt;/span&gt; &lt;span class="o"&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;$book&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="k"&gt;if&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;has_param&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="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="s1"&gt;'post_title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&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="k"&gt;if&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;has_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'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="nv"&gt;$update&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post_status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&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="nf"&gt;wp_slash&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="kc"&gt;true&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;is_wp_error&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="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="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;has_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'author'&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;update_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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="s1"&gt;'_bookshelf_author'&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="s1"&gt;'author'&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;if&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;has_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rating'&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;update_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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="s1"&gt;'_bookshelf_rating'&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="s1"&gt;'rating'&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_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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="mi"&gt;200&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;blockquote&gt;
&lt;p&gt;🧠 &lt;strong&gt;PUT vs PATCH:&lt;/strong&gt; by the book, &lt;code&gt;PUT&lt;/code&gt; replaces the whole resource and &lt;code&gt;PATCH&lt;/code&gt; changes a few fields. WordPress core is relaxed here: both are treated as "update what I sent". I'm following core's habit, since it's friendlier for clients.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH &lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"kushang:xxxx xxxx xxxx xxxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"rating":4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt; (&lt;code&gt;200&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Atomic Habits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"James Clear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publish"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"link"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42"&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;h3&gt;
  
  
  5️⃣ DELETE: Remove a Book
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="cd"&gt;/**
     * DELETE /books/{id}
     *
     * Moves to trash by default. Pass force=true to delete permanently.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response|WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;delete_item&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;$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;$book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;absint&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="s1"&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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;$book&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$deleted&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_delete_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$book&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="n"&gt;bool&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="s1"&gt;'force'&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="nv"&gt;$deleted&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;'bookshelf_delete_failed'&lt;/span&gt;&lt;span class="p"&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;'The book could not be deleted.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bookshelf'&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;500&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_REST_Response&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;'deleted'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bool&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="s1"&gt;'force'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'previous'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="s2"&gt;"https://example.com/wp-json/bookshelf/v1/books/42?force=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"kushang:xxxx xxxx xxxx xxxx"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt; (&lt;code&gt;200&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deleted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous"&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Atomic Habits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;h3&gt;
  
  
  🎁 The Shared Formatter
&lt;/h3&gt;

&lt;p&gt;One last helper so every endpoint returns the &lt;strong&gt;same shape&lt;/strong&gt;. This is where we also escape the output we send out:&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="cd"&gt;/**
     * Turn a book post into a clean API array.
     *
     * @param WP_Post $post Book post.
     * @return array
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;prepare_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;WP_Post&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&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;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&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="s1"&gt;'title'&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_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&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="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_bookshelf_author'&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="s1"&gt;'rating'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_bookshelf_rating'&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="s1"&gt;'status'&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_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'link'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;esc_url_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rest_url&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;REST_NAMESPACE&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/books/'&lt;/span&gt; &lt;span class="mf"&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="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 quick note on escaping: JSON isn't HTML, so running &lt;code&gt;esc_html()&lt;/code&gt; on API values would give clients double-encoded text like &lt;code&gt;&amp;amp;amp;&lt;/code&gt;. The rule of thumb is &lt;strong&gt;sanitize on the way in, escape at the place you render&lt;/strong&gt;. Use &lt;code&gt;esc_url_raw()&lt;/code&gt; for URLs, and let your front-end escape titles when it prints them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔐 Security Checklist (Pin This One)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Always&lt;/strong&gt; set &lt;code&gt;permission_callback&lt;/code&gt;. Use &lt;code&gt;__return_true&lt;/code&gt; only for data that's truly public, and say so in a comment.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Validate&lt;/strong&gt; with &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;maximum&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Sanitize&lt;/strong&gt; with &lt;code&gt;sanitize_callback&lt;/code&gt; before saving.&lt;/li&gt;
&lt;li&gt;✅ Check &lt;strong&gt;capabilities per item&lt;/strong&gt; (&lt;code&gt;edit_post&lt;/code&gt;) and not only globally.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Cap&lt;/strong&gt; &lt;code&gt;per_page&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;✅ Return a &lt;code&gt;WP_Error&lt;/code&gt; with a proper &lt;code&gt;status&lt;/code&gt; code and never &lt;code&gt;die()&lt;/code&gt; or &lt;code&gt;echo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;✅ Use &lt;code&gt;wp_slash()&lt;/code&gt; when passing REST data to &lt;code&gt;wp_insert_post()&lt;/code&gt; and &lt;code&gt;wp_update_post()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏁 Final Verdict
&lt;/h2&gt;

&lt;p&gt;The WP REST API looks big at first, but once you see the pattern it's actually tiny:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One namespace → routes → an array of handlers → each handler = methods + callback + permissions + args.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The thing to take with you is Part 3. &lt;strong&gt;A route is just an address, and the HTTP method decides which handler answers.&lt;/strong&gt; Once that clicks, &lt;code&gt;GET /books&lt;/code&gt; and &lt;code&gt;POST /books&lt;/code&gt; living side by side stops feeling like magic.&lt;/p&gt;

&lt;p&gt;A few honest opinions after building this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧱 &lt;strong&gt;Use a controller class.&lt;/strong&gt; A pile of closures works for demos, but a class keeps your routes testable and readable.&lt;/li&gt;
&lt;li&gt;🛡️ &lt;strong&gt;Let &lt;code&gt;args&lt;/code&gt; do the heavy lifting.&lt;/strong&gt; Declarative validation means fewer &lt;code&gt;if&lt;/code&gt; blocks and fewer bugs.&lt;/li&gt;
&lt;li&gt;🔑 &lt;strong&gt;Treat permissions as a feature, not an afterthought.&lt;/strong&gt; Most REST API security issues come from a lazy &lt;code&gt;permission_callback&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Version your namespace&lt;/strong&gt; (&lt;code&gt;/v1&lt;/code&gt;). Your future self will thank you when &lt;code&gt;/v2&lt;/code&gt; arrives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now it's your turn: take the Bookshelf, swap books for whatever your project needs (tasks, events, orders), and ship something. Got questions or a cooler example? Drop them in the comments, I read every one! 💬&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>WordPress 7.1 'Mary Lou': The Full Album, Track by Track 🎷</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Sat, 03 Oct 2026 03:32:37 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/wordpress-71-mary-lou-the-full-album-track-by-track-1k30</link>
      <guid>https://dev.to/kushang_tailor/wordpress-71-mary-lou-the-full-album-track-by-track-1k30</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;It's late, but here is the full breakdown of all the features of WordPress 7.1, and it's worth exploring.&lt;/strong&gt; 🙌&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yes, I know. WordPress 7.1 landed on &lt;strong&gt;August 19, 2026&lt;/strong&gt;, and I'm showing up with the party snacks when everyone has already gone home. 😅&lt;/p&gt;

&lt;p&gt;But here's the thing: most "what's new" posts were written &lt;em&gt;before&lt;/em&gt; the release, from beta notes and roadmaps. Now that the dust has settled, we can look at what &lt;strong&gt;actually shipped&lt;/strong&gt;, what quietly got cut, and what's worth your time.&lt;/p&gt;

&lt;p&gt;And since I wanted to do this differently, I'm doing it the way WordPress itself named this release.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎷 Why an album?
&lt;/h2&gt;

&lt;p&gt;Every WordPress release is named after a jazz artist. This one is &lt;strong&gt;"Mary Lou"&lt;/strong&gt;, after the pianist, arranger and composer Mary Lou Williams. She started performing at six and moved through swing, bebop and sacred jazz. In the 1940s she turned her Harlem apartment into a nightly workshop where Monk, Dizzy Gillespie and Bud Powell traded ideas. The release team picked her for two words: &lt;strong&gt;reinvention and collaboration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's a surprisingly accurate summary of 7.1. So I'm treating it like a vinyl record: &lt;strong&gt;Side A, Side B, Side C, and a bonus track&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Drop the needle. 🎧&lt;/p&gt;




&lt;h2&gt;
  
  
  📀 The Tracklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Track&lt;/th&gt;
&lt;th&gt;What it is, in one line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Responsive Styles&lt;/td&gt;
&lt;td&gt;Style blocks per screen size, no custom CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Interactive States&lt;/td&gt;
&lt;td&gt;Hover, focus and active styles for buttons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Tabs&lt;/td&gt;
&lt;td&gt;A new core Tabs block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Playlist&lt;/td&gt;
&lt;td&gt;A new core audio Playlist block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;The Small Design Stuff&lt;/td&gt;
&lt;td&gt;Gradients, min-width, text shadow and more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Icon Block&lt;/td&gt;
&lt;td&gt;Custom icon collections in the editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;The Omnibar&lt;/td&gt;
&lt;td&gt;The admin bar now follows you everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;New Media Editor&lt;/td&gt;
&lt;td&gt;One modal for crop, flip, rotate and metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Browser-Side Media&lt;/td&gt;
&lt;td&gt;Image processing moves out of PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Media Library Upgrades&lt;/td&gt;
&lt;td&gt;Infinite scroll, better galleries, resilient uploads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Notes Grew Up&lt;/td&gt;
&lt;td&gt;Mentions, rich text, inline notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Daily-Life Admin Fixes&lt;/td&gt;
&lt;td&gt;Command palette, Identity, comment parents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Accessibility&lt;/td&gt;
&lt;td&gt;46 accessibility tickets and new tooltip helpers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;The Iframed Editor&lt;/td&gt;
&lt;td&gt;The post editor is always iframed now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Developer Toolbox&lt;/td&gt;
&lt;td&gt;Icon API, Abilities API, Design System, 20 new hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎁&lt;/td&gt;
&lt;td&gt;Bonus: Cut Tracks&lt;/td&gt;
&lt;td&gt;What didn't make the album&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🅰️ Side A: The Design Side
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where "I need custom CSS for that" starts to disappear.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 1: Responsive Styles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; You can now define how a block looks at different screen sizes from inside the editor. You can do it in &lt;strong&gt;Global Styles&lt;/strong&gt; (applies to every instance of a block) or in an &lt;strong&gt;individual block's settings&lt;/strong&gt;. You can also preview each viewport as you work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Making the heading smaller on mobile used to mean a media query in a stylesheet, or a page builder. Now it's a setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For theme authors:&lt;/strong&gt; Block themes can define their &lt;strong&gt;own mobile and tablet breakpoints in &lt;code&gt;theme.json&lt;/code&gt;&lt;/strong&gt;, overriding the defaults for both responsive styles and block visibility. Your design system, your breakpoints.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 7.0 already had show/hide per device. 7.1 adds the actual &lt;em&gt;styling&lt;/em&gt; per device on top of it. Responsive support started in 7.0 and is expanded here, so don't expect it to be 100% new.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  🎵 Track 2: Interactive States
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; Button blocks can now have distinct styles for &lt;strong&gt;hover, focus and active&lt;/strong&gt; states, straight from the editor's style controls. You can set them for &lt;strong&gt;one button&lt;/strong&gt; or for &lt;strong&gt;all buttons&lt;/strong&gt; via Global Styles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; "Make the button change color on hover" used to be one of the most common reasons to open a stylesheet. It's now a color picker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For theme authors:&lt;/strong&gt; &lt;code&gt;theme.json&lt;/code&gt; gets pseudo-state styling too: &lt;strong&gt;hover, focus, focus-visible and active&lt;/strong&gt;. Block-level support still varies by block, so test the blocks you rely on.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 3: Tabs Block (new!)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; A core &lt;strong&gt;Tabs&lt;/strong&gt; block that organizes content into tabbed panels. Tab titles are edited in place, and each panel can hold any blocks you like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Tabs were always one of those "install a plugin for it" things. Now FAQ sections, pricing comparisons and "Overview / Specs / Reviews" layouts work with core only.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 4: Playlist Block (new!)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; A core &lt;strong&gt;Playlist&lt;/strong&gt; block bundles multiple audio tracks into a single player with a track list. There's an optional &lt;strong&gt;waveform&lt;/strong&gt; that gives listeners a visual sense of each track while it plays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Podcasters, musicians, course creators and anyone with a "listen to these 5 audio clips" page no longer needs a plugin. It also fits this release's jazz theme nicely. 🎶&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 5: The Small Design Stuff
&lt;/h3&gt;

&lt;p&gt;Short tracks, but you'll use them daily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background gradients + background images&lt;/strong&gt; no longer fight each other. This now works across Group, Verse, Accordion, Pullquote, Post Content and Quote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum width&lt;/strong&gt; joins the dimension controls, so elements stop collapsing on narrow screens without a media query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text shadow&lt;/strong&gt; is available in Global Styles, so you set it once per block type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image block → "Mark as decorative."&lt;/strong&gt; Purely ornamental images can be hidden from screen readers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom HTML block&lt;/strong&gt; supports editable nested blocks, which is handy for HTML pasted from AI tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed block:&lt;/strong&gt; pasting a shortcode now produces a real Embed block instead of raw text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎵 Track 6: Icon Block + Custom Icon Collections
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; 7.0 started the SVG icon work. 7.1 makes it usable. There's a core &lt;strong&gt;Icon&lt;/strong&gt; block backed by an icon library, and developers can &lt;strong&gt;register their own collections&lt;/strong&gt; so custom icons appear throughout the editor next to the built-ins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; No more shipping an icon font or inlining a pile of SVGs just to get icons in the editor. (Developer details in Track 15.)&lt;/p&gt;




&lt;h2&gt;
  
  
  🅱️ Side B: The Workflow Side
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where the "collaboration" half of Mary Lou lives.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 7: The Omnibar
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; The WordPress &lt;strong&gt;admin bar now stays with you&lt;/strong&gt; across the dashboard, the post editor and the Site Editor, plus some small visual polish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Jumping from writing a post to editing your site design no longer feels like switching apps. You always know where you are, and the familiar tools stay in reach.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 8: The New Media Editor
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; A dedicated &lt;strong&gt;media editor modal&lt;/strong&gt; replaces the old inline cropper. The familiar &lt;strong&gt;Crop&lt;/strong&gt; button still opens it, but inside you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Freeform and &lt;strong&gt;aspect-ratio cropping&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Horizontal and vertical flipping&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fine-grained and snap rotation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata editing&lt;/strong&gt; in the same workflow&lt;/li&gt;
&lt;li&gt;Undo/redo while you edit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; One focused place for "fix this image," instead of a cramped inline tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 9: Browser-Side Media Processing (the real headline) ⭐
&lt;/h3&gt;

&lt;p&gt;If I had to pick one track from this album as the hit single, it's this one. And it's the quietest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The old way:&lt;/strong&gt; You upload a photo, your server (via GD or ImageMagick) decodes it and generates every thumbnail size while you wait. That's why "increase your PHP memory limit" is the most common advice in WordPress land, and why big phone photos sometimes just fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The new way:&lt;/strong&gt; The &lt;strong&gt;browser&lt;/strong&gt; does the heavy lifting. Compression, resizing and thumbnail generation now happen client-side, using a WebAssembly build of libvips, before the file reaches your server.&lt;/p&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Less server load.&lt;/strong&gt; No more hitting PHP memory limits or timeouts on big uploads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smaller files.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modern formats:&lt;/strong&gt; native support for &lt;strong&gt;AVIF, HEIC and HDR gain maps&lt;/strong&gt;, which means the photos modern phones actually produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opt-in GIF-to-video conversion&lt;/strong&gt;, which usually gives much smaller files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilient uploads:&lt;/strong&gt; progress indicators and automatic retries when the connection drops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your existing settings still apply (like the big-image threshold and your output format preference), and the old server-side editor stays as a fallback for anything the browser can't handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bonus I like:&lt;/strong&gt; untrusted uploaded images get decoded inside the browser's sandbox first, instead of always being parsed by image libraries on your host. It doesn't remove the server path entirely, but the common case gets safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 10: Media Library Upgrades
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infinite scrolling&lt;/strong&gt; in the Media Library grid by default. There's a per-user option to go back to pagination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better galleries.&lt;/strong&gt; Galleries can pull in media already attached to a post, and the inserter shows an &lt;strong&gt;Attached images&lt;/strong&gt; section for the current post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speculative loading defaults&lt;/strong&gt; can now be set through environment variables or constants, so hosts and site owners can tune prefetching without writing a plugin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎵 Track 11: Notes Grew Up
&lt;/h3&gt;

&lt;p&gt;Notes (the editorial commenting feature) got most of the collaboration love in this release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rich text:&lt;/strong&gt; bold, italic, code and links inside notes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;@mentions&lt;/strong&gt; to loop in the right person (with email notifications in the final stretch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline notes:&lt;/strong&gt; leave a note on a &lt;strong&gt;specific piece of text&lt;/strong&gt;, not just a whole block&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple conversations&lt;/strong&gt; on the same block&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collapsible long notes&lt;/strong&gt; to keep the margin tidy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shareable revisions:&lt;/strong&gt; hand someone a link to a specific revision for a second pair of eyes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;⚠️ &lt;strong&gt;One honest note:&lt;/strong&gt; real-time co-editing (the Google-Docs-style dream) is &lt;strong&gt;not&lt;/strong&gt; in 7.1. More on that in the bonus track.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 12: Daily-Life Admin Fixes
&lt;/h3&gt;

&lt;p&gt;Small things you'll notice by Tuesday:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Command palette&lt;/strong&gt; (&lt;code&gt;Ctrl/Cmd + K&lt;/code&gt;) groups results into Recent, matching and Suggestions, and remembers what you used.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Site Editor respects your admin color scheme&lt;/strong&gt; instead of a fixed dark background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity section:&lt;/strong&gt; site title, tagline and icon now live together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change a comment's parent&lt;/strong&gt; from the Edit Comment screen. This was a long-standing annoyance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"On This Day" dashboard widget&lt;/strong&gt; (mentioned in several release roundups) surfaces old posts and comments from the same date in previous years.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎵 Track 13: Accessibility
&lt;/h3&gt;

&lt;p&gt;7.1 includes &lt;strong&gt;46 accessibility-focused tickets&lt;/strong&gt;. Highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New &lt;strong&gt;&lt;code&gt;wp_get_tooltip()&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;wp_get_toggletip()&lt;/code&gt;&lt;/strong&gt; functions bring accessible tooltips to more of wp-admin, including post meta boxes and the login screen.&lt;/li&gt;
&lt;li&gt;Better &lt;strong&gt;screen reader support&lt;/strong&gt; in admin screens, with clearer labels and more predictable navigation in post list tables.&lt;/li&gt;
&lt;li&gt;The new Tabs and Playlist blocks were built with accessibility in mind.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🅲 Side C: The Studio Notes (for developers)
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where we look at the mixing board.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🎵 Track 14: The Iframed Editor ⚠️
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; The &lt;strong&gt;post editor is now fully iframed for all themes&lt;/strong&gt;, matching the Site Editor. The editing canvas runs separately from the admin UI, so admin styles stop leaking into your content, and viewport units and media queries target the canvas instead of the browser window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; It's mostly invisible, and layouts become more predictable. But it's also &lt;strong&gt;the change most likely to break something&lt;/strong&gt;. Blocks still on &lt;strong&gt;Block API version 2 or lower&lt;/strong&gt; need to move to &lt;strong&gt;version 3&lt;/strong&gt;. WordPress publishes a block migration guide for this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧪 If you maintain custom blocks or older third-party ones, &lt;strong&gt;test on staging first&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  🎵 Track 15: The Developer Toolbox
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SVG Icon API is public.&lt;/strong&gt; Register your own collections and icons with &lt;code&gt;wp_register_icon_collection()&lt;/code&gt;, &lt;code&gt;wp_register_icon()&lt;/code&gt; and &lt;code&gt;wp_get_icon()&lt;/code&gt;, and use them across the editor. There's also a REST endpoint, so icon collections are queryable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abilities API&lt;/strong&gt; (introduced in 6.9) gets a &lt;strong&gt;filterable execution lifecycle&lt;/strong&gt;, custom validation and shared discovery, which helps with integrations and AI tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New Design System for admin UIs:&lt;/strong&gt; semantic design tokens (color, roundness, cursor styles) and a &lt;strong&gt;&lt;code&gt;ThemeProvider&lt;/code&gt;&lt;/strong&gt; React component, so custom admin screens feel at home in WordPress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;theme.json&lt;/code&gt;:&lt;/strong&gt; responsive styling, custom breakpoints and pseudo-state styling (hover, focus, focus-visible, active).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataViews / DataForm filters&lt;/strong&gt; for the screens behind Pages, Templates, Parts and Patterns in the Site Editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectors screen&lt;/strong&gt; adds username + application password authentication next to API keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block Bindings&lt;/strong&gt; extend to List Item blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 new hooks&lt;/strong&gt; (19 filters and 1 action). Check the Field Guide for a cleaner extension point than your current workaround.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;jQuery UI moves to 1.14.2.&lt;/strong&gt; Dependency bumps like this break plugins quietly, so test anything that leans on jQuery UI widgets.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🎁 Bonus Track: The Cut Tracks (what didn't make it)
&lt;/h2&gt;

&lt;p&gt;Every album has songs that got left on the studio floor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time collaboration:&lt;/strong&gt; still not enabled. The Field Guide says it got extensive testing during the cycle, but it isn't in the final release while conflict handling is worked out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table of Contents block:&lt;/strong&gt; it was on the roadmap, but it was pushed to a later release (7.2 per some coverage).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guidelines&lt;/strong&gt; (editorial rules and brand voice for AI tooling): not in 7.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React 19 upgrade:&lt;/strong&gt; lands in the Gutenberg plugin first, Core later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hiding the Classic block:&lt;/strong&gt; this was merged, then &lt;strong&gt;reverted&lt;/strong&gt; in July. The Classic block stays in the inserter, so nothing changes if you still use it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🎧 Liner Notes: Should You Update?
&lt;/h2&gt;

&lt;p&gt;My honest take: &lt;strong&gt;7.1 is a "finish what we started" release&lt;/strong&gt;, not a reinvention. 7.0 brought the big AI foundation and the dashboard redesign. 7.1 polishes it and finally turns a lot of "I need custom CSS" moments into plain settings.&lt;/p&gt;

&lt;p&gt;Some practical advice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Back up first&lt;/strong&gt;, and test that you can actually restore it. 💾&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update a staging copy&lt;/strong&gt; and check your custom blocks (iframed editor!) and anything using jQuery UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the update&lt;/strong&gt; from Dashboard → Updates, or with WP-CLI:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core update
wp core update-db   &lt;span class="c"&gt;# especially on multisite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Go straight to the latest 7.1.x.&lt;/strong&gt; Point releases have already shipped since August, including a security release, so don't stop at 7.1.0.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The release was a team effort: &lt;strong&gt;800+ contributors&lt;/strong&gt;, &lt;strong&gt;170+ first-timers&lt;/strong&gt; and &lt;strong&gt;1,500+ enhancements and fixes&lt;/strong&gt;, led by Anne McCarthy. Thank you to everyone who made it happen. 👏&lt;/p&gt;




&lt;h2&gt;
  
  
  🎤 Encore
&lt;/h2&gt;

&lt;p&gt;If I had to pick my top three for everyday work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;🥇 &lt;strong&gt;Browser-side media&lt;/strong&gt;, which fixes a 15-year-old pain quietly&lt;/li&gt;
&lt;li&gt;🥈 &lt;strong&gt;Responsive + interactive styles&lt;/strong&gt;, which means way less custom CSS&lt;/li&gt;
&lt;li&gt;🥉 &lt;strong&gt;Notes with @mentions&lt;/strong&gt;, a real step toward team editing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's &lt;em&gt;your&lt;/em&gt; favorite track on the album? Let me know in the comments. And if you've already moved to 7.1, tell me what broke (or what didn't). 👇&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources and further reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Official announcement: &lt;a href="https://wordpress.org/news/2026/08/mary-lou/" rel="noopener noreferrer"&gt;WordPress 7.1 "Mary Lou"&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Developer notes: &lt;a href="https://make.wordpress.org/core/2026/08/05/wordpress-7-1-field-guide/" rel="noopener noreferrer"&gt;WordPress 7.1 Field Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Release notes: &lt;a href="https://wordpress.org/documentation/wordpress-version/version-7-1/" rel="noopener noreferrer"&gt;Version 7.1 documentation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>gutenberg</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Copper CRM + WordPress: When Your Contact Form Finally Gets a Real Job 💼</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:34:21 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/copper-crm-wordpress-when-your-contact-form-finally-gets-a-real-job-59b7</link>
      <guid>https://dev.to/kushang_tailor/copper-crm-wordpress-when-your-contact-form-finally-gets-a-real-job-59b7</guid>
      <description>&lt;h2&gt;
  
  
  👋 Meet Copper CRM
&lt;/h2&gt;

&lt;p&gt;Most CRMs feel like you need a weekend course before you can send your first email. &lt;strong&gt;Copper&lt;/strong&gt; is different. It's built to live inside &lt;strong&gt;Google Workspace&lt;/strong&gt;, so if your team already spends the day in Gmail, Calendar and Drive, Copper feels like a natural extension instead of yet another tab.&lt;/p&gt;

&lt;p&gt;For WordPress developers, the interesting part is that Copper has a clean &lt;strong&gt;REST + JSON Developer API&lt;/strong&gt;. You can create records, search, update, and subscribe to events. That means your WordPress forms don't have to end their life in an admin email inbox. They can land straight in a CRM, ready for follow-up.&lt;/p&gt;

&lt;p&gt;In this article, we'll go through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What Prospects and Contacts really are (and the naming trap 🪤)&lt;/li&gt;
&lt;li&gt;Where tags, custom fields and webhooks live&lt;/li&gt;
&lt;li&gt;A duplicate-entry gotcha I ran into&lt;/li&gt;
&lt;li&gt;How to push &lt;strong&gt;Contact Us&lt;/strong&gt; and &lt;strong&gt;Newsletter&lt;/strong&gt; form entries to Copper using PHP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's go! 🚀&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 Contacts vs Prospects
&lt;/h2&gt;

&lt;p&gt;Copper has two main "people-like" record types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prospects&lt;/strong&gt;: someone who showed interest but isn't a real business relationship yet. Think "just filled the contact form."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contacts&lt;/strong&gt;: someone you're actually doing business with, or are ready to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copper's own help docs describe this flow nicely. You can start someone as a prospect while you check if they're a good fit, or skip that step and add them straight as a contact. When a prospect looks promising, you &lt;strong&gt;convert&lt;/strong&gt; them. (&lt;a href="https://support.copper.com/en/articles/8823252-working-with-people" rel="noopener noreferrer"&gt;Working with People&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚠️ The Naming Trap (Read This Before You Write Code!)
&lt;/h3&gt;

&lt;p&gt;Here's something that confused me at first. The Copper dashboard talks about &lt;em&gt;Prospects&lt;/em&gt; and &lt;em&gt;Contacts&lt;/em&gt;, but the &lt;strong&gt;developer documentation uses different words&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔑 &lt;strong&gt;Prospects = &lt;code&gt;leads&lt;/code&gt;&lt;/strong&gt; in the developer API&lt;br&gt;
🔑 &lt;strong&gt;Contacts = &lt;code&gt;people&lt;/code&gt;&lt;/strong&gt; in the developer API&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So when you read the docs and see &lt;code&gt;/leads&lt;/code&gt;, think "Prospects". When you see &lt;code&gt;/people&lt;/code&gt;, think "Contacts". Copper also lets admins rename record types in settings, so your own account might use yet another label. Always map by &lt;em&gt;endpoint&lt;/em&gt;, not by what the sidebar says.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/leads/overview.html" rel="noopener noreferrer"&gt;Leads Overview&lt;/a&gt; · &lt;a href="https://developer.copper.com/people/overview.html" rel="noopener noreferrer"&gt;People Overview&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  📊 Quick Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Prospects (API: &lt;code&gt;leads&lt;/code&gt;)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Contacts (API: &lt;code&gt;people&lt;/code&gt;)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Meaning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Early-stage interest, not yet qualified&lt;/td&gt;
&lt;td&gt;Real business relationship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical source&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Contact form, enquiry, cold outreach&lt;/td&gt;
&lt;td&gt;Newsletter signup, customers, qualified people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Duplicates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Can store duplicates&lt;/td&gt;
&lt;td&gt;✅ Doesn't store duplicate entries (more below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Extra fields&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Status, Customer Source&lt;/td&gt;
&lt;td&gt;Contact Type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Company / pipeline link&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can't be related to Companies or Pipelines&lt;/td&gt;
&lt;td&gt;Can be related to Companies and Opportunities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Moving forward&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can be &lt;strong&gt;converted&lt;/strong&gt; to a Contact&lt;/td&gt;
&lt;td&gt;Already the "final" form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Upsert endpoint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;PUT /leads/upsert&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not listed in the docs, so check first, then create or update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enabled by default?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No, you toggle Prospects on in settings&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://support.copper.com/en/articles/10324537-chapter-2-customizing-and-optimizing-copper" rel="noopener noreferrer"&gt;Copper: Customizing your account&lt;/a&gt;, &lt;a href="https://support.copper.com/en/articles/9303241-creating-a-lead-form" rel="noopener noreferrer"&gt;Creating a Lead form&lt;/a&gt;, &lt;a href="https://developer.copper.com/leads/convert-a-lead.html" rel="noopener noreferrer"&gt;Convert a Lead API&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🚨 The Duplicate Gotcha (My Real-World Finding)
&lt;/h2&gt;

&lt;p&gt;This one cost me a bit of debugging time, so let me save you the trouble.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  🔥 Highlight
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;While working with Copper CRM, I noticed that the Contacts (&lt;code&gt;people&lt;/code&gt;) section does &lt;em&gt;not&lt;/em&gt; store duplicate entries, but the Prospects (&lt;code&gt;leads&lt;/code&gt;) section &lt;em&gt;does&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What that means in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Submit the same email to &lt;strong&gt;Contacts&lt;/strong&gt; twice → you won't get two clean records. The second attempt is rejected or needs a different approach.&lt;/li&gt;
&lt;li&gt;Submit the same email to &lt;strong&gt;Prospects&lt;/strong&gt; twice → you can end up with &lt;strong&gt;two (or ten!) identical prospects&lt;/strong&gt;. 😅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your "Contact Us" form creates prospects with a plain &lt;code&gt;POST /leads&lt;/code&gt;, every repeat visitor adds a new record. Your sales team will not thank you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; use the &lt;strong&gt;Upsert Lead&lt;/strong&gt; endpoint. It checks for a match (name, email, or custom field), updates the record if it exists, and creates one if it doesn't. The docs say it's particularly useful to avoid duplicate leads. We'll use it in the code below.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/leads/upsert-a-lead.html" rel="noopener noreferrer"&gt;UPSERT a Lead&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Tags, Custom Fields &amp;amp; Webhooks from the Dashboard
&lt;/h2&gt;

&lt;p&gt;You can do a lot without writing any code. Here's where to look.&lt;/p&gt;

&lt;h3&gt;
  
  
  🏷️ Tags
&lt;/h3&gt;

&lt;p&gt;Tags are flexible labels, like &lt;code&gt;newsletter&lt;/code&gt;, &lt;code&gt;website-lead&lt;/code&gt;, or &lt;code&gt;wordpress&lt;/code&gt;. They're handy for things that don't deserve a full custom field. Tags are specific to each record type, and you can add, edit or delete them in settings. Good news for devs: you can also attach tags through the API when creating records.&lt;/p&gt;

&lt;p&gt;⚠️ Heads up: if a tag is renamed or deleted in Settings, automations (and form actions) that referenced it won't apply it anymore.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/tags/overview.html" rel="noopener noreferrer"&gt;Tags API&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🧩 Custom Fields
&lt;/h3&gt;

&lt;p&gt;Need to track "Budget", "Service Interest" or "Preferred Language"? That's what custom fields are for. In the dashboard, go to &lt;strong&gt;Settings → Manage Fields on Records&lt;/strong&gt;, pick the record type (leads, people, companies, etc.) and add your field. Admins can also reorder, deactivate or reactivate fields from the same place. (&lt;a href="https://support.copper.com/en/articles/8823326-manage-field-settings" rel="noopener noreferrer"&gt;Manage Field Settings&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;For API usage, every custom field has a numeric &lt;strong&gt;&lt;code&gt;custom_field_definition_id&lt;/code&gt;&lt;/strong&gt;. You send values like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"custom_fields"&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="nl"&gt;"custom_field_definition_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100764&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello"&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;p&gt;You can list your definitions via the API to find the IDs: &lt;a href="https://developer.copper.com/custom-fields/general/list-custom-field-definitions.html" rel="noopener noreferrer"&gt;List Custom Field Definitions&lt;/a&gt; · &lt;a href="https://developer.copper.com/custom-fields/general/overview.html" rel="noopener noreferrer"&gt;Custom Fields Overview&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🔔 Webhooks
&lt;/h3&gt;

&lt;p&gt;Webhooks flip the direction. Instead of &lt;em&gt;you&lt;/em&gt; sending data to Copper, &lt;strong&gt;Copper pings your WordPress site&lt;/strong&gt; when something happens (new lead, updated person, etc.). Great for syncing data back, notifying Slack, or kicking off a custom workflow.&lt;/p&gt;

&lt;p&gt;On the developer side, you create a subscription with a target URL and the event you care about. Your endpoint receives a notification and processes it. (Pro tip: make your endpoint respond fast and handle repeated deliveries safely.)&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/webhooks/overview.html" rel="noopener noreferrer"&gt;Webhooks Overview&lt;/a&gt; · &lt;a href="https://developer.copper.com/webhooks/create-new-subscription.html" rel="noopener noreferrer"&gt;Create new subscription&lt;/a&gt; · &lt;a href="https://developer.copper.com/webhooks/notification-example.html" rel="noopener noreferrer"&gt;Notification example&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🔌 WordPress Forms → Copper (PHP Example)
&lt;/h2&gt;

&lt;p&gt;Now the fun part. Imagine two forms on a WordPress site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Form&lt;/th&gt;
&lt;th&gt;Where it should go&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Contact Us&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Prospects&lt;/strong&gt; (&lt;code&gt;/leads&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Unqualified enquiry, so it needs a sales check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subscribe Newsletter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Contacts&lt;/strong&gt; (&lt;code&gt;/people&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Just an email and name, no sales follow-up needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(This split is just an example. You can route either form to either place.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  🔐 Step 1: API Credentials
&lt;/h3&gt;

&lt;p&gt;In Copper, generate an API key from your account settings. Every request needs three headers: &lt;code&gt;X-PW-AccessToken&lt;/code&gt;, &lt;code&gt;X-PW-Application&lt;/code&gt; (always &lt;code&gt;developer_api&lt;/code&gt;) and &lt;code&gt;X-PW-UserEmail&lt;/code&gt;. (&lt;a href="https://developer.copper.com/introduction/authentication.html" rel="noopener noreferrer"&gt;Authentication docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Never hard-code secrets in plugin files. Put them in &lt;code&gt;wp-config.php&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="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'COPPER_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'your-api-key'&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;'COPPER_USER_EMAIL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'you@yourcompany.com'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🗂️ Step 2: File Structure (OOP style)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;copper-forms/
├── copper-forms.php
└── includes/
    ├── class-client.php          // Talks to Copper
    ├── class-prospect-service.php // Leads
    ├── class-contact-service.php  // People
    └── class-form-handler.php     // Reads and sanitizes form data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🌐 Step 3: The API Client
&lt;/h3&gt;

&lt;p&gt;One small class that knows how to talk to Copper. Everything else reuses it.&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="cd"&gt;/**
 * Copper API client.
 *
 * @package CopperForms
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;CopperForms&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Client
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://api.copper.com/developer_api/v1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Send a request to Copper.
     *
     * @param string $method   HTTP method.
     * @param string $endpoint Endpoint, e.g. '/leads/upsert'.
     * @param array  $body     Request body.
     *
     * @return array|\WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&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;$args&lt;/span&gt; &lt;span class="o"&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;'method'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&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;'X-PW-AccessToken'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;COPPER_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'X-PW-Application'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'developer_api'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'X-PW-UserEmail'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;COPPER_USER_EMAIL&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="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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&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;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&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_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_request&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;BASE_URL&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&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="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;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$code&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;wp_remote_retrieve_response_code&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;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_retrieve_body&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="kc"&gt;true&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;$code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&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;'copper_api_error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'Copper API request failed.'&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="nv"&gt;$code&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="nb"&gt;is_array&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="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;array&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;h3&gt;
  
  
  🎯 Step 4: Contact Us → Prospect (Lead)
&lt;/h3&gt;

&lt;p&gt;We use &lt;strong&gt;upsert&lt;/strong&gt; matched on email, so repeat submissions update the same prospect instead of creating duplicates.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/leads/upsert-a-lead.html" rel="noopener noreferrer"&gt;UPSERT a Lead&lt;/a&gt; · &lt;a href="https://developer.copper.com/leads/create-a-new-lead.html" rel="noopener noreferrer"&gt;Create a New Lead&lt;/a&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Prospect (Lead) service.
 *
 * @package CopperForms
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;CopperForms&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Prospect_Service
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Prospect_Service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Client instance.
     *
     * @var Client
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Constructor.
     *
     * @param Client $client API client.
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;Client&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Create or update a prospect from Contact Us data.
     *
     * @param array $data Sanitized form data.
     *
     * @return array|\WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&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;'properties'&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;'name'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'email'&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;'email'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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;'email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'details'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'message'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'tags'&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;'website-contact-form'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'match'&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;'field_name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'field_value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'email'&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'PUT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/leads/upsert'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&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;h3&gt;
  
  
  💌 Step 5: Newsletter → Contact (Person)
&lt;/h3&gt;

&lt;p&gt;People has no upsert endpoint in the docs, so we do it manually: &lt;strong&gt;look up by email → update if found → create if not&lt;/strong&gt;. This also protects us from the duplicate behaviour we talked about.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.copper.com/people/fetch-a-person-by-email.html" rel="noopener noreferrer"&gt;Fetch a Person by Email&lt;/a&gt; · &lt;a href="https://developer.copper.com/people/create-a-new-person.html" rel="noopener noreferrer"&gt;Create a New Person&lt;/a&gt; · &lt;a href="https://developer.copper.com/people/update-a-person.html" rel="noopener noreferrer"&gt;Update a Person&lt;/a&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Contact (Person) service.
 *
 * @package CopperForms
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;CopperForms&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Contact_Service
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Contact_Service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Client instance.
     *
     * @var Client
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Constructor.
     *
     * @param Client $client API client.
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;Client&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Create or update a contact from newsletter data.
     *
     * @param array $data Sanitized form data.
     *
     * @return array|\WP_Error
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&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;'/people/fetch_by_email'&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;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'email'&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;// Found: update the existing person, keeping their current tags.&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;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$existing&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="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$old_tags&lt;/span&gt; &lt;span class="o"&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;$existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$tags&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$old_tags&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;'newsletter'&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'PUT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'/people/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;absint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'tags'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tags&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;// Not found (404) or lookup failed: try creating a new person.&lt;/span&gt;
        &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&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;'name'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;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;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'emails'&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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s1"&gt;'email'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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;'email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'work'&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="s1"&gt;'tags'&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;'newsletter'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&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;'/people'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&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;💡 Why merge tags manually? Sending &lt;code&gt;tags&lt;/code&gt; on an update can replace the existing list, so we merge first and nothing gets wiped by accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧼 Step 6: Form Handler (Validate, Sanitize, Escape)
&lt;/h3&gt;

&lt;p&gt;This class receives both forms, checks the nonce, cleans the input and hands the data to the right service.&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="cd"&gt;/**
 * Form handler.
 *
 * @package CopperForms
 */&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;CopperForms&lt;/span&gt;&lt;span class="p"&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Class Form_Handler
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Form_Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Prospect service.
     *
     * @var Prospect_Service
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$prospects&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Contact service.
     *
     * @var Contact_Service
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$contacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Constructor.
     *
     * @param Prospect_Service $prospects Prospect service.
     * @param Contact_Service  $contacts  Contact service.
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;Prospect_Service&lt;/span&gt; &lt;span class="nv"&gt;$prospects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Contact_Service&lt;/span&gt; &lt;span class="nv"&gt;$contacts&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prospects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$prospects&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contacts&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$contacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Register hooks.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register&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;'admin_post_nopriv_cf_contact_us'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_contact_us'&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;'admin_post_cf_contact_us'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_contact_us'&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;'admin_post_nopriv_cf_newsletter'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_newsletter'&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;'admin_post_cf_newsletter'&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="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_newsletter'&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="cd"&gt;/**
     * Handle the Contact Us form.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle_contact_us&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'cf_contact_us'&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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'name'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'email'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_textarea_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'message'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_email&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;'email'&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'invalid'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prospects&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsert&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&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="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Handle the Newsletter form.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle_newsletter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'cf_newsletter'&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="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'name'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'email'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_email&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;'email'&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'invalid'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&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="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Verify the nonce or stop.
     *
     * @param string $action Nonce action.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&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;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'_wpnonce'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&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="s1"&gt;'_wpnonce'&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="o"&gt;:&lt;/span&gt; &lt;span class="s1"&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;wp_verify_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$action&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;wp_die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Security check failed.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'copper-forms'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&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;'response'&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="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Redirect back with a status.
     *
     * @param string $status Status key.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_referer&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_get_referer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nf"&gt;wp_safe_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;add_query_arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'cf_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;rawurlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;esc_url_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$back&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;exit&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;h3&gt;
  
  
  🚦 Step 7: Bootstrap
&lt;/h3&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="cd"&gt;/**
 * Plugin Name: Copper Forms
 * Description: Sends WordPress form entries to Copper CRM.
 * Version:     1.0.0
 * Text Domain: copper-forms
 *
 * @package CopperForms
 */&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;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/includes/class-client.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/includes/class-prospect-service.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/includes/class-contact-service.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/includes/class-form-handler.php'&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;'plugins_loaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;static&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;$client&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;\CopperForms\Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\CopperForms\Form_Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\CopperForms\Prospect_Service&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\CopperForms\Contact_Service&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$client&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;register&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;On the front-end, each form posts to &lt;code&gt;admin-post.php&lt;/code&gt; with a hidden &lt;code&gt;action&lt;/code&gt; field (&lt;code&gt;cf_contact_us&lt;/code&gt; or &lt;code&gt;cf_newsletter&lt;/code&gt;) and a nonce from &lt;code&gt;wp_nonce_field()&lt;/code&gt;. Since the form's output goes to the browser, remember to escape with &lt;code&gt;esc_url()&lt;/code&gt; and &lt;code&gt;esc_attr()&lt;/code&gt; there too. ✅&lt;/p&gt;

&lt;h3&gt;
  
  
  📝 A Few Notes Before Production
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't block the visitor.&lt;/strong&gt; For busy sites, push the Copper call to a background job (like WP-Cron or Action Scheduler) so a slow API call doesn't slow down the form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log errors,&lt;/strong&gt; but never log your API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom fields:&lt;/strong&gt; look up your &lt;code&gt;custom_field_definition_id&lt;/code&gt; values first, then add them to the payloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits and bulk:&lt;/strong&gt; if you import in bulk, check the &lt;a href="https://developer.copper.com/people/bulk-create-people.html" rel="noopener noreferrer"&gt;Bulk Create People&lt;/a&gt; and &lt;a href="https://developer.copper.com/leads/bulk-create-leads.html" rel="noopener noreferrer"&gt;Bulk Create Leads&lt;/a&gt; docs. Bulk requests are capped at 10 records.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Copper CRM is a friendly, Google-Workspace-first CRM, and its API makes it very pleasant to connect with WordPress. Quick recap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prospects = &lt;code&gt;leads&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;Contacts = &lt;code&gt;people&lt;/code&gt;&lt;/strong&gt; in the developer docs. Map by endpoint, not by label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contacts don't store duplicates, Prospects do.&lt;/strong&gt; Use the &lt;strong&gt;upsert&lt;/strong&gt; endpoint for leads, and a "fetch by email first" check for people.&lt;/li&gt;
&lt;li&gt;Tags, custom fields and webhooks give you a lot of power straight from the dashboard.&lt;/li&gt;
&lt;li&gt;A small OOP structure (client + services + handler) keeps your integration clean, secure and easy to extend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the basics work, you've got a solid pipeline from "someone typed in a form" to "sales team knows about it." 🎉&lt;/p&gt;




&lt;h2&gt;
  
  
  🔜 What's Next?
&lt;/h2&gt;

&lt;p&gt;In the &lt;strong&gt;next article&lt;/strong&gt;, we'll do the same thing with &lt;strong&gt;Elementor Forms&lt;/strong&gt;: hooking into the form submission action and sending those entries to Copper as Prospects and Contacts. Stay tuned! 👀&lt;/p&gt;

&lt;p&gt;If this helped, drop a ❤️ or a comment. I'd love to hear how you're using Copper with WordPress!&lt;/p&gt;




&lt;h3&gt;
  
  
  📚 Reference Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.copper.com/" rel="noopener noreferrer"&gt;Copper Developer API (Home)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.copper.com/introduction/authentication.html" rel="noopener noreferrer"&gt;Authentication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.copper.com/leads/overview.html" rel="noopener noreferrer"&gt;Leads Overview&lt;/a&gt; · &lt;a href="https://developer.copper.com/leads/upsert-a-lead.html" rel="noopener noreferrer"&gt;UPSERT a Lead&lt;/a&gt; · &lt;a href="https://developer.copper.com/leads/convert-a-lead.html" rel="noopener noreferrer"&gt;Convert a Lead&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.copper.com/people/overview.html" rel="noopener noreferrer"&gt;People Overview&lt;/a&gt; · &lt;a href="https://developer.copper.com/people/fetch-a-person-by-email.html" rel="noopener noreferrer"&gt;Fetch by Email&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.copper.com/tags/overview.html" rel="noopener noreferrer"&gt;Tags&lt;/a&gt; · &lt;a href="https://developer.copper.com/custom-fields/general/overview.html" rel="noopener noreferrer"&gt;Custom Fields&lt;/a&gt; · &lt;a href="https://developer.copper.com/webhooks/overview.html" rel="noopener noreferrer"&gt;Webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.copper.com/en/articles/8823326-manage-field-settings" rel="noopener noreferrer"&gt;Copper Help: Manage Field Settings&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>copper</category>
      <category>api</category>
    </item>
    <item>
      <title>WordPress Performance From The Code Side — No Plugins Needed</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:11:31 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/wordpress-performance-from-the-code-side-no-plugins-needed-2d0e</link>
      <guid>https://dev.to/kushang_tailor/wordpress-performance-from-the-code-side-no-plugins-needed-2d0e</guid>
      <description>&lt;h1&gt;
  
  
  WordPress Performance From The Code Side — No Plugins Needed
&lt;/h1&gt;

&lt;p&gt;Every WordPress site eventually hits the same wall: it gets slow, and the first instinct is to install a performance plugin. Then another one. Then a caching plugin to make the first two play nice. Before you know it, you've got five plugins fighting over the same &lt;code&gt;wp_head&lt;/code&gt; hook and your site somehow got &lt;em&gt;slower&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This article is about the other path — fixing performance from the code side. No plugin marketplace, no toggling forty checkboxes hoping one of them fixes your LCP score. Just a clear audit process, the right diagnostic tools, and code (yours or AI-assisted) that fixes exactly what's broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  😎 Become a WordPress Core, JS, and CSS Expert
&lt;/h2&gt;

&lt;p&gt;Just kidding.&lt;/p&gt;

&lt;p&gt;A few years ago, that sentence would've been genuine advice — "go learn &lt;code&gt;wp-includes&lt;/code&gt; inside out." Today, you don't need to be the person who's memorized every hook in &lt;code&gt;class-wp-scripts.php&lt;/code&gt;. AI coding tools (Claude, Copilot, Codex — pick your favorite) already know that file better than most of us do. What you actually need is the ability to &lt;em&gt;describe a performance problem accurately&lt;/em&gt;. That's the real skill now, and it's what the rest of this article is built around.&lt;/p&gt;




&lt;h2&gt;
  
  
  📋 Important Step: Audit Your Site First — Set Your Priorities
&lt;/h2&gt;

&lt;p&gt;Before touching any code, know what's actually slow and which pages matter most. Don't try to fix everything at once — work top-down by priority.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List your page types and rank them by traffic/business value.&lt;/strong&gt;
Homepage and top landing pages = High priority. Category/archive pages = Medium. Low-traffic static pages = Low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run PageSpeed Insights on your Homepage first.&lt;/strong&gt; This is almost always your highest-traffic entry point, so it sets the baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it again on 2–3 of your top single post/product pages.&lt;/strong&gt; These usually reveal template-specific issues (heavy featured images, embedded widgets, etc.) that the homepage doesn't show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note every red and orange metric&lt;/strong&gt; (LCP, CLS, TBT, FCP) per page — don't just look at the overall score number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group the issues by type&lt;/strong&gt;, not by page. You'll usually find the same 3–4 root causes (unused JS, oversized images, render-blocking CSS) repeating across pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix root causes, starting with whatever affects your High priority pages the most.&lt;/strong&gt; One fix on a shared theme file often solves the same issue across ten pages at once.
This turns "my site feels slow" into a short, ordered list of real problems — which is exactly what you need before writing (or prompting) any fix.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  ⚖️ Custom Code vs Plugins: Which One Should You Actually Use?
&lt;/h2&gt;

&lt;p&gt;If you're not deeply technical, this table should help you decide where to start.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Performance Plugins&lt;/th&gt;
&lt;th&gt;Custom Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Fast — install and configure&lt;/td&gt;
&lt;td&gt;Slower — needs writing/testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site speed impact&lt;/td&gt;
&lt;td&gt;Adds its own JS/CSS/DB overhead&lt;/td&gt;
&lt;td&gt;Zero extra overhead, only does what you need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flexibility&lt;/td&gt;
&lt;td&gt;Limited to plugin's settings&lt;/td&gt;
&lt;td&gt;Fully tailored to your exact bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;Depends on plugin updates&lt;/td&gt;
&lt;td&gt;You control the lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk of conflicts&lt;/td&gt;
&lt;td&gt;High with multiple plugins&lt;/td&gt;
&lt;td&gt;Low — no third-party hooks colliding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Quick wins, non-technical users, small sites&lt;/td&gt;
&lt;td&gt;Long-term performance, custom themes, larger sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical requirement&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low-to-medium &lt;em&gt;(thanks to AI, this gap is shrinking fast)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your site is small and you need a quick win today, a well-chosen single plugin is fine. But if performance is an ongoing priority — especially on a custom theme or a client project — writing targeted code almost always wins, because it doesn't drag in features you'll never use.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤖 Not Technical? Let AI Write the Fix — But Be Precise About the Problem
&lt;/h2&gt;

&lt;p&gt;Here's the part that changes everything: you don't need to hand-write PHP or JS to get custom-code-level performance anymore. What you &lt;em&gt;do&lt;/em&gt; need is to know exactly what's slow before you open that AI chat window. A vague prompt gets you a vague, possibly-wrong fix. A precise prompt gets you production-ready code. A few examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ Vague:&lt;/strong&gt; "Make my WordPress site load faster"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ Precise — deferring a third-party widget:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I have a third-party chat widget script loaded on my WordPress site via &lt;code&gt;wp_enqueue_script()&lt;/code&gt;. It's render-blocking and hurting my LCP. I want it to load only when the user scrolls or clicks anywhere on the page — not on initial page load. Give me a PHP snippet to dequeue it from the normal flow, plus vanilla JS to load it on first user interaction (click, scroll, or touchstart), following WordPress coding standards."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Precise — fixing layout shift from a hero image:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My homepage hero image is causing a CLS score of 0.28 in PageSpeed Insights because it has no reserved space and loads late. It's set via &lt;code&gt;get_the_post_thumbnail()&lt;/code&gt; in &lt;code&gt;front-page.php&lt;/code&gt;. Give me the code to add explicit &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; attributes, mark it &lt;code&gt;fetchpriority='high'&lt;/code&gt;, and preload it in &lt;code&gt;wp_head&lt;/code&gt; so it doesn't shift layout on load."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Precise — trimming unused CSS on one template:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Chrome DevTools Coverage tab shows my &lt;code&gt;single-product.php&lt;/code&gt; template loads a 180KB CSS file but only uses about 20% of it. The file is enqueued theme-wide via &lt;code&gt;functions.php&lt;/code&gt;. Give me a PHP approach using &lt;code&gt;is_singular('product')&lt;/code&gt; to only load a smaller, page-specific stylesheet on that template instead of the global one."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Precise — reducing database load from a widget:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I have a 'Recent Reviews' widget on my WordPress sidebar running a custom &lt;code&gt;WP_Query&lt;/code&gt; on every page load, and it's showing up as a slow query in Query Monitor. Give me a PHP snippet using the Transients API to cache the query result for one hour instead of hitting the database on every request."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice the pattern — every prompt names the exact element, the exact metric or tool reading, and the exact file or hook involved. That's the difference between code you can paste into production and code you'll spend an hour debugging.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 Diagnose Before You Fix: PageSpeed Insights vs Lighthouse
&lt;/h2&gt;

&lt;p&gt;Two tools, two different jobs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google PageSpeed Insights (PSI)&lt;/strong&gt;&lt;br&gt;
Runs both a lab test &lt;em&gt;and&lt;/em&gt; pulls real-world field data from Chrome UX Report (CrUX) — meaning it shows how actual visitors experienced your site, not just a simulated run. Use this to see your real Core Web Vitals scores over the last 28 days, straight from Google's own dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse (Chrome DevTools)&lt;/strong&gt;&lt;br&gt;
A local, on-demand lab test that runs right in your browser. No real user data — just a controlled simulation under fixed network/CPU throttling. Use this while you're actively debugging, since you get instant before/after comparisons every time you make a change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick rule of thumb:&lt;/strong&gt; Use PSI to know &lt;em&gt;if&lt;/em&gt; you have a problem and how real users are affected. Use Lighthouse to &lt;em&gt;iterate&lt;/em&gt; while you fix it, since it's instant and doesn't wait on field data.&lt;/p&gt;


&lt;h2&gt;
  
  
  🕵️ Find Exactly What's Slow: Unused Code and Real Layout Shift
&lt;/h2&gt;

&lt;p&gt;Before jumping into fixes, two Chrome DevTools tricks will point you straight at the problem instead of guessing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Check unused JS and CSS per page
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open your page in Chrome, then open &lt;strong&gt;DevTools → More Tools → Coverage&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click the reload button inside the Coverage panel to record a fresh page load.&lt;/li&gt;
&lt;li&gt;You'll get a list of every JS and CSS file, with a red bar showing the &lt;strong&gt;percentage unused&lt;/strong&gt; on that specific page.&lt;/li&gt;
&lt;li&gt;Anything showing 70–90% unused is a strong candidate for either page-specific loading (&lt;code&gt;is_page()&lt;/code&gt;/&lt;code&gt;is_singular()&lt;/code&gt;) or removing entirely.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  See real CLS and FCP under a slow connection
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;DevTools → Network tab&lt;/strong&gt;, and set throttling to &lt;strong&gt;Slow 3G&lt;/strong&gt; or &lt;strong&gt;Fast 4G&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Switch to the &lt;strong&gt;Performance tab&lt;/strong&gt;, hit record, and reload the page with throttling still active.&lt;/li&gt;
&lt;li&gt;Once the recording finishes, expand the &lt;strong&gt;Experience&lt;/strong&gt; section — it shows the exact elements causing layout shift (CLS) and when your First Contentful Paint (FCP) actually happened.&lt;/li&gt;
&lt;li&gt;This matters because on a fast office Wi-Fi, shifts often happen too quickly to notice. Slow network mimics real mobile users and exposes the &lt;em&gt;actual&lt;/em&gt; elements causing jank — usually late-loading images, web fonts, or injected ads/widgets.
Both of these give you a concrete list of file names and DOM elements — exactly what you need for the precise AI prompts above, or to fix by hand.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  🛠️ Custom Ways to Improve WordPress Performance (No Plugin Required)
&lt;/h2&gt;

&lt;p&gt;Here's the actual toolbox, ordered roughly by impact. Each one includes a minimal example — pick what applies to what your audit and DevTools checks turned up.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Defer or Async Non-Critical JavaScript
&lt;/h3&gt;

&lt;p&gt;WordPress 6.3+ lets you set a loading strategy directly when registering a script, instead of relying on a plugin to inject &lt;code&gt;defer&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;wp_enqueue_script&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'my-theme-slider'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;get_theme_file_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/assets/js/slider.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.0'&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;'strategy'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'defer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in_footer'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Lazy-Load Third-Party Scripts on User Interaction
&lt;/h3&gt;

&lt;p&gt;For things like chat widgets, review embeds, or social share scripts — don't load them until the user actually interacts with the page.&lt;br&gt;
&lt;/p&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;loadOnInteraction&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/widget.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;events&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;evt&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadOnInteraction&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;events&lt;/span&gt; &lt;span class="o"&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;click&lt;/span&gt;&lt;span class="dl"&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;scroll&lt;/span&gt;&lt;span class="dl"&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;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;events&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;evt&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&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="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadOnInteraction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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="na"&gt;passive&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;h3&gt;
  
  
  3. Prioritize Above-the-Fold Images, Lazy-Load the Rest
&lt;/h3&gt;

&lt;p&gt;Not every image should be lazy-loaded — your hero/LCP image should load as fast as possible, while everything below the fold should wait.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Above the fold: preload + high priority + eager --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"image"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"hero.jpg"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero.jpg"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"eager"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Hero image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Below the fold --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"footer-banner.jpg"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Footer banner"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"video.html"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Lazy-Load Videos on User Interaction
&lt;/h3&gt;

&lt;p&gt;Autoplaying or eagerly-embedded videos (YouTube embeds especially) are notorious for tanking LCP. Instead, show a lightweight thumbnail and only load the real player on click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.video-placeholder&lt;/span&gt;&lt;span class="dl"&gt;'&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;el&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;el&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoSrc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;iframe&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="na"&gt;once&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;This is exactly the pattern plugins like &lt;a href="https://wordpress.org/plugins/viddefer/" rel="noopener noreferrer"&gt;Viddefer&lt;/a&gt; automate — worth a look if you'd rather not maintain the logic yourself, but the core idea is simple enough to drop straight into your theme.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Extract and Enqueue Critical CSS &lt;em&gt;(Medium Priority)&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;You don't have to hand-write critical CSS — tools like &lt;a href="https://github.com/addyosmani/critical" rel="noopener noreferrer"&gt;Critical&lt;/a&gt; or online generators (e.g., criticalcss.com) can extract it for you per page. Save the output as a small CSS file per template, then load it conditionally.&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_front_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;span class="nf"&gt;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'critical-home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_theme_file_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/assets/css/critical-home.css'&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.0'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_singular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'product'&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;wp_enqueue_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'critical-product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_theme_file_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/assets/css/critical-product.css'&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.0'&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;h3&gt;
  
  
  6. Load Page-Specific CSS Instead of One Global Stylesheet
&lt;/h3&gt;

&lt;p&gt;If Coverage showed a large stylesheet with mostly unused rules on a given template, split it and load only what that page needs, using the same &lt;code&gt;is_page()&lt;/code&gt;/&lt;code&gt;is_singular()&lt;/code&gt; pattern as above.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Turn Off What You're Not Using
&lt;/h3&gt;

&lt;p&gt;Emojis, embeds, RSD links, and the Windows Live Writer manifest all add head requests most sites never need.&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;remove_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'print_emoji_detection_script'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&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;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rsd_link'&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;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wlwmanifest_link'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'embed_oembed_discover'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8. Reduce Heartbeat API Frequency
&lt;/h3&gt;

&lt;p&gt;The Heartbeat API polls the server every 15–60 seconds on admin screens (and sometimes the frontend). Slow it down instead of killing it entirely.&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;'heartbeat_settings'&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;$settings&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'interval'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&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;$settings&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;h3&gt;
  
  
  9. Cache Expensive Queries With Transients
&lt;/h3&gt;

&lt;p&gt;If you're running a costly query (custom &lt;code&gt;WP_Query&lt;/code&gt;, external API call, etc.), cache the result instead of hitting the database on every load. You can spot these slow queries using a debugging tool like Query Monitor — install it temporarily just to inspect, not as a permanent performance plugin.&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="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my_expensive_query'&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="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&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;WP_Query&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;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my_expensive_query'&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="no"&gt;HOUR_IN_SECONDS&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;h3&gt;
  
  
  10. Trim Autoloaded Bloat (the Easy Way)
&lt;/h3&gt;

&lt;p&gt;Plugins often set &lt;code&gt;autoload =&amp;gt; yes&lt;/code&gt; on options nobody reads on every page load, and over time this quietly slows down &lt;em&gt;every&lt;/em&gt; request. You don't need raw SQL to find the offenders — Query Monitor (again, just as a diagnostic) has a dedicated panel showing your largest autoloaded options by size. Once you've spotted one that doesn't need to load on every request:&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;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'my_plugin_settings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// false = don't autoload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  11. Enable Object Caching
&lt;/h3&gt;

&lt;p&gt;If your host supports Redis or Memcached, a persistent object cache means repeated queries hit memory instead of MySQL — one of the biggest wins for dynamic sites, and it only requires a drop-in &lt;code&gt;object-cache.php&lt;/code&gt;, not a bloated plugin.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Enable OPcache and Server-Level Compression
&lt;/h3&gt;

&lt;p&gt;This one sits outside &lt;code&gt;wp-content&lt;/code&gt; entirely, but it matters more than most plugin settings ever will: PHP OPcache (ask your host to confirm it's on) plus Gzip/Brotli compression at the server level compounds with everything above.&lt;/p&gt;




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

&lt;p&gt;Performance plugins aren't evil — they're just a shortcut, and shortcuts come with trade-offs: extra weight, generic solutions, and settings you'll never fully understand. Writing (or AI-prompting) targeted code gets you the opposite: exactly the fix you need, nothing you don't.&lt;/p&gt;

&lt;p&gt;The real shift isn't that coding got easier — it's that &lt;em&gt;auditing and describing the problem precisely&lt;/em&gt; became the actual skill. Start with a priority-ordered audit, confirm the real bottleneck with PageSpeed Insights, Lighthouse, and the Coverage/Network tabs, then let code — written by you or generated with AI — do the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A few related directions worth exploring next&lt;/strong&gt;, if you want to go deeper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database query optimization and slow query logging&lt;/li&gt;
&lt;li&gt;CDN + HTTP/2 (or HTTP/3) setup for static assets&lt;/li&gt;
&lt;li&gt;Font-display strategies to kill layout shift from web fonts&lt;/li&gt;
&lt;li&gt;Auditing and trimming unused core block CSS/JS in WordPress 6.x+&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>performance</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Donut Panic 🍩 — Building an Interactive CSS-Only Donut</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Tue, 11 Aug 2026 12:13:01 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/donut-panic-building-an-interactive-css-only-donut-586d</link>
      <guid>https://dev.to/kushang_tailor/donut-panic-building-an-interactive-css-only-donut-586d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🍩 Inspiration
&lt;/h2&gt;

&lt;p&gt;I write about WordPress plugins and PHP standards for a living, so when DEV dropped a "Comfort Food" theme for their Frontend Challenge, I didn't need to think twice about what to build. Not ramen, not pancakes — a donut. Specifically, the kind of donut that shows up on your desk right when a deploy breaks and somehow fixes everything.&lt;/p&gt;

&lt;p&gt;The twist I gave myself: don't just draw a static donut. Let people &lt;em&gt;build&lt;/em&gt; one — pick a glaze, pile on toppings, then serve it — and do almost all of it in CSS, with JavaScript kept firmly in the back seat where the challenge rules ask for it to stay.&lt;/p&gt;

&lt;p&gt;That's how &lt;strong&gt;Donut Panic&lt;/strong&gt; was born.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎬 Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/editor/Kushang-Tailor/embed/019ff068-c9ea-7c20-bce7-e1ef07a47d9a?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Pick a glaze, load it up with sprinkles, drizzle, or powdered sugar, then hit &lt;strong&gt;Serve&lt;/strong&gt; and watch it animate off the plate.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Journey
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No JavaScript is driving the donut — &lt;code&gt;:has()&lt;/code&gt; is
&lt;/h3&gt;

&lt;p&gt;Here's the part I'm most excited to talk about: every visual change in Donut Panic — the glaze swap, the toppings appearing, the donut lifting off the prep station and landing on the plate — is driven by plain checkbox/radio inputs and the &lt;code&gt;:has()&lt;/code&gt; selector. Something like &lt;code&gt;.kitchen:has(#serve:checked) .donut&lt;/code&gt; lets a parent element react to the checked state of an input buried somewhere inside it, which means the "Serve" button, the topping toggles, and the glaze picker are all just styled &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;s wired to hidden inputs. No click handlers, no state management — the checkbox &lt;em&gt;is&lt;/em&gt; the state.&lt;/p&gt;

&lt;p&gt;JavaScript only shows up once, and it's not touching the art at all: it smooth-scrolls the stage into view on mobile after you hit Serve, because on a stacked mobile layout the donut can animate off-screen. That's the "sprinkle" of JS the challenge rules allow, used exactly the way it's meant to be — a UX nicety, not a rendering engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the donut from the inside out
&lt;/h3&gt;

&lt;p&gt;The donut itself is layered rings, not a single flat shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;base dough circle&lt;/strong&gt; with a radial gradient doing double duty as both color and simulated top-down lighting (highlight near one edge, shadow fading toward the rim).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;glaze layer&lt;/strong&gt; sitting on top, clipped to a perfect circle via &lt;code&gt;overflow: hidden&lt;/code&gt; on a wrapping &lt;code&gt;.ring-clip&lt;/code&gt;, so drizzle and sprinkles can never spill past the donut's edge no matter how they're positioned.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;hole&lt;/strong&gt;, punched through last with its own inset shadow so it always reads as depth rather than a flat cutout, even while the whole donut is mid-animation.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;shine layer&lt;/strong&gt;, a diagonal white gradient that sweeps across the glaze on an infinite loop, which is what sells the "glossy glaze" feeling more than any single color choice does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Making the glaze a real material change
&lt;/h3&gt;

&lt;p&gt;Picking a glaze isn't a color swap — it's a full custom-property swap. Each glaze option (strawberry, chocolate, vanilla) redefines a whole cluster of variables — &lt;code&gt;--glaze&lt;/code&gt;, &lt;code&gt;--glaze-dark&lt;/code&gt;, &lt;code&gt;--glaze-darker&lt;/code&gt;, &lt;code&gt;--glaze-light&lt;/code&gt; — the moment its radio button is checked, using &lt;code&gt;:has()&lt;/code&gt; again to scope the override to that one selected state. Because the gradient, the shine, and the shadow all reference those same variables, switching glazes updates lighting and depth together instead of just flattening the donut into a different hue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Toppings as independently toggleable layers
&lt;/h3&gt;

&lt;p&gt;Sprinkles, extra drizzle, and powdered sugar are each their own absolutely-positioned layer, faded in or out purely with opacity and scale transitions tied to their own checkbox. The sprinkles alone are two dozen individually placed spans, each carrying its own &lt;code&gt;--t&lt;/code&gt;, &lt;code&gt;--l&lt;/code&gt;, &lt;code&gt;--r&lt;/code&gt;, and &lt;code&gt;--c&lt;/code&gt; custom properties for position, rotation, and color, so they scatter across the donut face believably instead of repeating in an obvious pattern. Because every topping is decoupled from the glaze logic, you can mix and match freely — chocolate glaze with powdered sugar, strawberry with all three toppings at once — without any rule fighting another.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "Serve" animation
&lt;/h3&gt;

&lt;p&gt;Hitting Serve doesn't just reveal a new state — it moves the donut. The &lt;code&gt;.donut&lt;/code&gt; element transitions its &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, and &lt;code&gt;transform&lt;/code&gt; properties with a bouncy cubic-bezier easing, so it visibly travels from the dashed-outline "prep station" down onto the plate, complete with a staggered pop-in for the glaze puddle, stray crumbs, and a handwritten napkin tag that reads "Enjoy Your Donut." The button label itself flips from "Serve the Donut" to "Served! Enjoy 🎉" using the same checked-state trick, and the topping menu visually locks (dimmed, grayscaled, &lt;code&gt;pointer-events: none&lt;/code&gt;) so you can't keep editing a donut that's already left the kitchen.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'm proud of
&lt;/h3&gt;

&lt;p&gt;Getting an entire multi-step interaction — pick a glaze, pick toppings, serve, watch it land, get locked out of editing — to run on nothing but &lt;code&gt;:has()&lt;/code&gt;, custom properties, and transitions is the part I'm happiest with. It's proof that a lot of what we instinctively reach for JavaScript to do is really just state living in the DOM already, if you're willing to let a checkbox be the source of truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;If I extend this past the challenge, I'd like to add more glaze and topping combinations, and maybe a "box of donuts" view that remembers everything you've built across a session — a good excuse to push &lt;code&gt;:has()&lt;/code&gt; even further before I let JavaScript back in.&lt;/p&gt;




&lt;h2&gt;
  
  
  📄License
&lt;/h2&gt;

&lt;p&gt;This project is open source, licensed under MIT. Fork it, remix it, build your own topping bar on top of it — just keep the license notice intact.&lt;/p&gt;




&lt;p&gt;Happy Coding &amp;lt;/&amp;gt;&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AbilityGuard v1.1: I Gave My Abilities API Monitor a Memory (and a Voice)</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:35:57 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/abilityguard-v11-i-gave-my-abilities-api-monitor-a-memory-and-a-voice-2fo7</link>
      <guid>https://dev.to/kushang_tailor/abilityguard-v11-i-gave-my-abilities-api-monitor-a-memory-and-a-voice-2fo7</guid>
      <description>&lt;p&gt;A few weeks back I published an article upon my latest &lt;a href="https://dev.to/kushang_tailor/building-abilityguard-monitoring-the-wordpress-abilities-api-in-production-3mpe"&gt;AbilityGuard&lt;/a&gt; plugin so you could actually &lt;em&gt;see&lt;/em&gt; what the WordPress Abilities API was doing on your site — which abilities were registered, which ones were risky, and what ran when nobody was watching.&lt;/p&gt;

&lt;p&gt;That part worked. But pretty quickly I ran into a problem I should've seen coming: a monitor that only shows you the last 100 log entries and makes you stare at the screen isn't really monitoring anything. It's just a mirror. You still have to be looking at it at the exact moment something happens.&lt;/p&gt;

&lt;p&gt;So v1.1 isn't a "few bug fixes" release. It's the release where AbilityGuard stopped being a dashboard and started being something closer to a watchdog — one that remembers, filters, exports, flags, and taps you on the shoulder when it needs to.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 The Problem With a 100-Entry Memory
&lt;/h2&gt;

&lt;p&gt;The original execution log was capped at a rolling 100 entries. Fine for a quick check. Useless for an actual audit trail. If you wanted to know what an ability did last Tuesday, and 150 executions had happened since, that data was just gone.&lt;/p&gt;

&lt;p&gt;So the first thing I fixed in 1.1 was retention itself. You can now configure how many log entries AbilityGuard keeps — including &lt;strong&gt;unlimited retention&lt;/strong&gt; if you want a permanent record. No more silently losing history because the log rotated while you weren't looking.&lt;/p&gt;

&lt;p&gt;Of course, unlimited retention is only useful if you can actually find what you're looking for inside it. Which brings me to the next piece.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 Filters That Don't Fight Each Other
&lt;/h2&gt;

&lt;p&gt;The old filter set technically existed, but it wasn't built for real audit work. In v1.1 I rebuilt it as &lt;strong&gt;advanced execution log filters&lt;/strong&gt; — status, trigger context, risk level, category, user ID, date range, and free-text search, all combinable.&lt;/p&gt;

&lt;p&gt;The detail that actually took the most iteration wasn't adding filters, it was making them behave independently. Previously, selecting one filter could quietly narrow or reset another in a way that wasn't obvious. Now each filter selection stands on its own, so "show me destructive abilities triggered via REST by user 14 last week" actually returns what you'd expect, instead of a filter fighting itself behind the scenes.&lt;/p&gt;

&lt;p&gt;Once you've built a filter combination you actually care about, you shouldn't have to rebuild it every time. So I added &lt;strong&gt;saved views&lt;/strong&gt; — save a filter set once, reuse it from a dropdown. If you're doing weekly reviews, this alone probably saves you the most clicks.&lt;/p&gt;

&lt;p&gt;And once you've filtered down to the exact slice of log you care about, you'll usually want to take it somewhere else — which is exactly what CSV export is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  📤 Getting Data Out of the Admin Screen
&lt;/h2&gt;

&lt;p&gt;Every filtered log view can now be &lt;strong&gt;exported to CSV&lt;/strong&gt; directly from the current view. This wasn't a hard technical problem, but it mattered a lot — because plenty of AbilityGuard users aren't just checking a dashboard, they're pulling this data into a compliance report, a security review, or a spreadsheet someone else on the team is going to read.&lt;/p&gt;




&lt;h2&gt;
  
  
  👀 The Watchlist: Not All Abilities Deserve Equal Attention
&lt;/h2&gt;

&lt;p&gt;Here's the thing about a site with the Abilities API active — you might have dozens of registered abilities, and realistically only a handful of them actually worry you. The ones marked destructive. The ones an AI agent could trigger unattended. The ones tied to user data.&lt;/p&gt;

&lt;p&gt;v1.1 adds a &lt;strong&gt;sensitive ability watchlist&lt;/strong&gt; so you can flag exactly those abilities for closer attention, instead of treating every entry in the inventory as equally important. It's a small feature on paper, but it's the one that changes how you actually use the plugin day to day — you stop scanning everything and start watching what matters.&lt;/p&gt;

&lt;p&gt;And a watchlist that requires you to keep checking back is still passive. So the next two features exist to close that gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  📧 Email Alerts and Scheduled Reports
&lt;/h2&gt;

&lt;p&gt;This is the part that turns AbilityGuard from "something I check" into "something that tells me."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email alerts&lt;/strong&gt; fire for high-risk, failed, or watchlisted ability executions — so if something destructive runs, or something fails, or a watched ability triggers, you find out without opening wp-admin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled reports&lt;/strong&gt; send a daily or weekly summary of activity, so even if nothing alarming happened, you still get a pulse check in your inbox.
Between the two, you get both ends of monitoring covered: immediate notice when something risky happens, and a steady rhythm of visibility even when nothing does.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🌐 Multisite Finally Gets Its Own Dashboard
&lt;/h2&gt;

&lt;p&gt;If you're running a network, checking abilities site by site was never going to scale. v1.1 adds a &lt;strong&gt;Network Admin dashboard&lt;/strong&gt; that shows per-site ability and log counts in one place, so a network admin can spot which site is generating unusual activity without switching contexts twenty times.&lt;/p&gt;

&lt;p&gt;And while I was in the multisite code, I found (and fixed in the 1.1.1 patch) a bug where uninstall cleanup only ran on the main site instead of every site in the network. If you'd removed AbilityGuard from a multisite install, subsites were quietly keeping their data behind. That's fixed now — cleanup runs network-wide.&lt;/p&gt;

&lt;p&gt;The 1.1.1 patch also brought the inventory detail popup in line with the execution log's detail modal, so the two feel like the same plugin instead of two screens that were designed a week apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 What Didn't Change
&lt;/h2&gt;

&lt;p&gt;No new abilities are logged that weren't logged before. AbilityGuard still only tracks Abilities API registrations and executions — not general WordPress admin actions like post edits or media uploads. That boundary was intentional in 1.0, and it's still intentional now. This release is about doing more with the data AbilityGuard already collects, not collecting more kinds of data.&lt;/p&gt;

&lt;p&gt;Payload logging also stays privacy-conscious by default: output logging is still off unless you turn it on, same as before.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Where This Goes Next
&lt;/h2&gt;

&lt;p&gt;Retention, filters, exports, watchlist, alerts, reports, multisite — that's a lot for one point release, and it's the reason it took a few weeks instead of a few days. What's next is less about adding more surfaces and more about making the existing ones smarter — better risk scoring, and eventually, a Pro tier for teams that need more than a single site's worth of visibility. More on that when it's actually ready to talk about.&lt;/p&gt;

&lt;p&gt;If you're running the Abilities API on your site — whether it's your own plugins registering abilities or third-party AI tools doing it for you — v1.1 is a straightforward update. No config migration, no breaking changes, just a plugin that remembers more and bothers you less.&lt;/p&gt;

&lt;p&gt;You can grab it from the &lt;a href="https://wordpress.org/plugins/ability-guard/" rel="noopener noreferrer"&gt;WordPress.org plugin page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From a 10-Minute Nap to 300+ Installs: The Story of My 'Restrict WP Upload Type' Plugin</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:13:58 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/from-a-10-minute-nap-to-300-installs-the-story-of-my-restrict-wp-upload-type-plugin-dgn</link>
      <guid>https://dev.to/kushang_tailor/from-a-10-minute-nap-to-300-installs-the-story-of-my-restrict-wp-upload-type-plugin-dgn</guid>
      <description>&lt;h2&gt;
  
  
  📼 It Started With WordPress 4.7
&lt;/h2&gt;

&lt;p&gt;I still remember the version number: &lt;strong&gt;WordPress 4.7&lt;/strong&gt;. That's when I properly started my journey as a WordPress developer. Not as a "I read a tutorial and now I'm an expert" kind of start — more like a "I broke a client's site three times before lunch" kind of start.&lt;/p&gt;

&lt;p&gt;Those were good years, honestly. I built a lot of sites. Small business sites, portfolio sites, e-commerce stores. But there was one problem that followed me across almost every single project, like a background process that never quits: &lt;strong&gt;the media library.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sites would crash. Uploads would eat up server resources. And every now and then, out of nowhere — the dreaded &lt;strong&gt;white screen of death&lt;/strong&gt;, right after someone in the client's team uploaded "just one more file."&lt;/p&gt;




&lt;h2&gt;
  
  
  🩹 The Debugging Loop I Knew Too Well
&lt;/h2&gt;

&lt;p&gt;Every time this happened, I went into the same routine. Open the error logs, check the &lt;code&gt;wp-content/uploads&lt;/code&gt; folder, check PHP memory limits, and dig line by line until I found the culprit.&lt;/p&gt;

&lt;p&gt;And more often than not, the answer was almost embarrassingly simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;corrupt file&lt;/strong&gt; that somehow made it through upload&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;heavy media file&lt;/strong&gt; nobody bothered to compress&lt;/li&gt;
&lt;li&gt;Someone uploading a &lt;strong&gt;.exe or .zip disguised as an image&lt;/strong&gt; — sometimes by mistake, sometimes not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these were WordPress core problems. They were &lt;strong&gt;upload hygiene&lt;/strong&gt; problems. But WordPress, out of the box, doesn't really give site owners a simple way to say "only allow these file types, nothing else." So every time, I'd write a quick one-off fix for that one client, ship it, and move on. It solved the symptom. It never solved the actual disease.&lt;/p&gt;

&lt;p&gt;Now, to be fair — the &lt;strong&gt;White Screen of Death&lt;/strong&gt; isn't only caused by bad uploads. Over the years I've seen it come from all sorts of directions: a &lt;strong&gt;plugin conflict&lt;/strong&gt;, a &lt;strong&gt;theme function throwing a fatal error&lt;/strong&gt;, &lt;strong&gt;PHP memory limits&lt;/strong&gt; being exhausted, a &lt;strong&gt;corrupted &lt;code&gt;.htaccess&lt;/code&gt; file&lt;/strong&gt;, or even a simple &lt;strong&gt;PHP version mismatch&lt;/strong&gt; after a server update. Uploads were just the culprit I kept running into the most.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 New Job, New Plugins, New Skills
&lt;/h2&gt;

&lt;p&gt;Time moved on, and so did I. I switched companies, and with the new job came new opportunities — this time, actual &lt;strong&gt;plugin and theme development&lt;/strong&gt; work, not just "fix this WordPress site" freelance gigs.&lt;/p&gt;

&lt;p&gt;This is where I really understood how plugins and themes are built from the ground up. I started building plugins for clients and for in-house company websites, and eventually shipped my very first personal plugin on WordPress.org — &lt;a href="https://wordpress.org/plugins/product-category/" rel="noopener noreferrer"&gt;Product Category&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That first release taught me a lot: how WordPress.org review works, how real users behave differently than you expect, and how a plugin is never really "done" — it just moves into its next version.&lt;/p&gt;




&lt;h2&gt;
  
  
  💤 The Idea That Came During a Nap
&lt;/h2&gt;

&lt;p&gt;Here's the part of the story I still find funny. In early 2022, I was taking a short 10-minute nap — the kind where your brain refuses to fully switch off. And in that half-asleep state, old memories started resurfacing. Corrupt uploads. Wrong file extensions. Clients accidentally uploading a &lt;code&gt;.zip&lt;/code&gt; where an image was supposed to go. Years of the same recurring headache, replaying like a highlight reel.&lt;/p&gt;

&lt;p&gt;And then the thought hit me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What if there was one simple plugin where a site owner could just check which file types are allowed — and everything else gets blocked automatically?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No more one-off fixes per client. No more custom snippets copy-pasted from an old project. Just one clean toggle-based settings screen.&lt;/p&gt;

&lt;p&gt;That nap turned into a plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 Planning Before a Single Line of Code
&lt;/h2&gt;

&lt;p&gt;I didn't jump straight into the editor. I sat down and actually mapped out the plugin — what file types needed support, how the settings screen should look, how validation should happen both on the frontend and the server side (because client-side checks alone are basically decorative).&lt;/p&gt;

&lt;p&gt;After the research phase, I started building. Following &lt;strong&gt;pure OOP structure&lt;/strong&gt;, keeping things aligned with &lt;strong&gt;WordPress coding standards&lt;/strong&gt;, and making sure every single input was properly sanitized, validated, and escaped — non-negotiables for me, then and now.&lt;/p&gt;




&lt;h2&gt;
  
  
  🐛 "It Worked Locally... So Why Is Production Rejecting Everything?"
&lt;/h2&gt;

&lt;p&gt;After a solid months of development, the plugin was functionally complete. Time for testing.&lt;/p&gt;

&lt;p&gt;I ran it on a few local environments first — smooth. Then I moved it to some live production sites for real-world testing, and that's when things got interesting: &lt;strong&gt;uploads stopped working entirely.&lt;/strong&gt; Every single file, blocked. Media library, dead silent.&lt;/p&gt;

&lt;p&gt;My first reaction was pure confusion — it worked perfectly on local, so why was production behaving like it had personally decided to reject every file on Earth?&lt;/p&gt;

&lt;p&gt;I dug in. Checked hooks, checked filter priorities, checked MIME validation order. Eventually I traced it back to a &lt;strong&gt;plugin conflict&lt;/strong&gt; — another plugin on those production sites was hooking into the same upload filters, and the two were essentially fighting over who gets the final say on what's "allowed."&lt;/p&gt;

&lt;p&gt;So I went back in, added new compatibility functions, and reworked the restriction logic to play nicely with other plugins instead of assuming it owned the upload pipeline alone. Retested. This time — &lt;strong&gt;it worked like a charm.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🎉 Launch Day: April 2022
&lt;/h2&gt;

&lt;p&gt;After that final round of verification, I published &lt;strong&gt;Restrict WP Upload Type&lt;/strong&gt; on WordPress.org in April 2022.&lt;/p&gt;

&lt;p&gt;If you check the plugin today, it does exactly what that half-asleep idea imagined:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Control over &lt;strong&gt;97 file extensions and MIME types&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A simple allow/block toggle for each format&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;dedicated SVG security toggle&lt;/strong&gt;, since SVGs need special handling&lt;/li&gt;
&lt;li&gt;Real-time validation that blocks bad uploads before WordPress even shows its generic error&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;global file size limit&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Zero configuration complexity — install, check some boxes, done&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⭐ What the Reviews Taught Me
&lt;/h2&gt;

&lt;p&gt;Shipping the plugin wasn't the finish line — it was the starting line for actually listening. The plugin currently sits at a clean 5-star average across its &lt;a href="https://wordpress.org/plugins/restrict-wp-upload-type/#reviews" rel="noopener noreferrer"&gt;reviews&lt;/a&gt;, and every one of them nudged the roadmap in a real direction.&lt;/p&gt;

&lt;p&gt;One review praised how simple and effective the restriction logic was. Another user pointed out something I hadn't considered — they wanted the &lt;strong&gt;Restrict Files menu tucked inside the Media menu&lt;/strong&gt; instead of sitting on its own, and asked for a clearer message telling users &lt;em&gt;which&lt;/em&gt; plugin blocked their upload, so support requests wouldn't turn into a guessing game. Another review flagged a rough patch where settings weren't saving properly for one specific site.&lt;/p&gt;

&lt;p&gt;I took all of it seriously. Over the following versions, I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Moved the settings page &lt;strong&gt;under Media &amp;gt; Restrict Files&lt;/strong&gt;, exactly as requested&lt;/li&gt;
&lt;li&gt;Added a &lt;strong&gt;global max file size&lt;/strong&gt; control&lt;/li&gt;
&lt;li&gt;Made the &lt;strong&gt;blocked-upload message fully customizable&lt;/strong&gt;, so site owners can explain exactly why a file was rejected&lt;/li&gt;
&lt;li&gt;Improved validation so it runs &lt;strong&gt;before&lt;/strong&gt; WordPress's generic security error even shows up&lt;/li&gt;
&lt;li&gt;Added a &lt;strong&gt;settings search&lt;/strong&gt; and enabled/disabled filters, since scrolling through 97 file types isn't fun for anyone&lt;/li&gt;
&lt;li&gt;Tightened up nonce protection and added a proper &lt;strong&gt;uninstall cleanup&lt;/strong&gt; routine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four reviews might not sound like a lot on paper, but each one represented a real site, a real workflow, and a real reason to keep improving.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔮 What's Next
&lt;/h2&gt;

&lt;p&gt;The plugin has grown a lot since that April 2022 launch — from a one-page toggle screen to a proper settings experience with search, custom messaging, and file size controls. But I'm not done.&lt;/p&gt;

&lt;p&gt;Next on the roadmap: deeper &lt;strong&gt;role-based restriction controls&lt;/strong&gt;, so different user roles can have different upload permissions instead of one global rule for everyone — along with custom MIME type management and upload logs, so site owners get more visibility into who's uploading what.&lt;/p&gt;

&lt;p&gt;It's a small plugin in the grand scheme of the WordPress ecosystem. But it came from a real, recurring frustration — and honestly, that 10-minute nap in early 2022 might be one of the more productive naps of my career.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you manage WordPress media libraries and have ever stared at a white screen after someone uploaded "just one file," you can find Restrict WP Upload Type on &lt;a href="https://wordpress.org/plugins/restrict-wp-upload-type/" rel="noopener noreferrer"&gt;WordPress.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>plugindevelopment</category>
      <category>opensource</category>
    </item>
    <item>
      <title>🛡️ Building AbilityGuard: Monitoring the WordPress Abilities API in Production</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:20:05 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/building-abilityguard-monitoring-the-wordpress-abilities-api-in-production-3mpe</link>
      <guid>https://dev.to/kushang_tailor/building-abilityguard-monitoring-the-wordpress-abilities-api-in-production-3mpe</guid>
      <description>&lt;p&gt;A few weeks ago I wrote about the WordPress Abilities API — what it is, why WordPress 6.9 shipped it, and what it means for how plugins will talk to each other and to AI agents going forward. That post was theory. This one is the part where theory meets a &lt;code&gt;composer.json&lt;/code&gt; file and a stubborn bug at 1 AM.&lt;/p&gt;

&lt;p&gt;This is the story of building &lt;strong&gt;AbilityGuard&lt;/strong&gt; — a plugin that monitors Abilities API usage in production, so you actually know what's happening when abilities get registered, called, and (occasionally) abused.&lt;/p&gt;




&lt;h2&gt;
  
  
  👀 Why monitoring, and why now
&lt;/h2&gt;

&lt;p&gt;Here's the thing about the Abilities API that got me nervous the first time I really understood it: it's a &lt;em&gt;capability surface&lt;/em&gt;. Any plugin can register an ability. Any authorized caller — a human-triggered action, an automation, or increasingly, an AI agent — can invoke one. That's the whole point of the API, and it's genuinely exciting. But it also means your site now has a growing list of "things that can be done to it programmatically," and most WordPress admins have zero visibility into that list.&lt;/p&gt;

&lt;p&gt;I've spent enough years debugging WordPress sites in production to know what happens when you can't see something: you find out about it during an incident, not before. Slow queries, rogue cron jobs, plugin conflicts — they all follow the same pattern. Nobody notices the small thing until the small thing becomes the outage.&lt;/p&gt;

&lt;p&gt;So the idea for AbilityGuard was simple: give site owners a dashboard and a log for every ability registered on their site, every time one gets called, and by whom. Not another abstract "security scanner" — just honest, readable visibility into a part of WordPress that's brand new and mostly invisible right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔌 Where I started: hooking into the registry, not fighting it
&lt;/h2&gt;

&lt;p&gt;The Abilities API exposes a central registry (&lt;code&gt;wp_get_ability_registry()&lt;/code&gt; under the hood, with helper functions layered on top). My first instinct was to intercept ability calls by wrapping core functions — and I killed that idea within the hour. Wrapping core functions is exactly the kind of thing that breaks on the next minor release and turns your plugin into the villain of someone else's changelog.&lt;/p&gt;

&lt;p&gt;Instead, AbilityGuard hooks into the lifecycle events the API already exposes: registration, before-execution, and after-execution actions. That's the whole trick. I'm not intercepting anything — I'm listening. When an ability is registered, I capture its metadata (name, description, input/output schema, permission callback). When it's executed, I capture the caller context, the arguments passed, the execution time, and the result status.&lt;/p&gt;

&lt;p&gt;That distinction — listening instead of wrapping — ended up shaping almost every architectural decision that followed.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ The architecture: keeping the pieces simple, so nothing breaks quietly
&lt;/h2&gt;

&lt;p&gt;I could get technical here, but honestly, the idea behind the build is simple enough to explain without any code.&lt;/p&gt;

&lt;p&gt;Think of AbilityGuard as three small, separate jobs living under one roof, and none of them stepping on each other's toes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One part just watches.&lt;/strong&gt; It sits quietly and notices whenever an ability is registered or run — it doesn't touch anything, doesn't change how the site behaves, it just pays attention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One part writes it down.&lt;/strong&gt; Whatever the first part notices gets recorded neatly — what happened, who triggered it, and how (through the website, a command line, an automated schedule, or regular code).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One part shows it to you.&lt;/strong&gt; The &lt;a href="https://wordpress.org/plugins/ability-guard/" rel="noopener noreferrer"&gt;inventory and execution log&lt;/a&gt; you see in your WordPress admin area is just a clean readout of what the other two parts already collected.
Each of these pieces only knows how to do its own job and nothing else. The "watcher" doesn't know how logs get stored. The "writer" doesn't know how the admin screen looks. That separation sounds like a small detail, but it's the reason the plugin can stay light and predictable — if I ever need to change how something is displayed, I'm only touching the "display" piece, not the parts responsible for actually catching and recording the activity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a monitoring tool, that predictability matters more than clever code. A plugin whose entire job is to watch quietly in the background should never be the thing that causes a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔒 The part nobody wants to think about: sanitize, validate, escape — for data you don't control
&lt;/h2&gt;

&lt;p&gt;This is where AbilityGuard gets a little more interesting than a typical CRUD plugin, because the "input" isn't a form field a human filled in — it's arbitrary arguments passed to an ability, possibly by an automated caller. I can't assume anything about shape or intent.&lt;/p&gt;

&lt;p&gt;Every argument captured for logging goes through explicit validation against expected types before it's ever sanitized or stored — nothing gets written on faith. Output is escaped at render time, not at storage time, which is the rule I see broken most often in plugins I audit: people sanitize once on the way in and assume that protects them on the way out. It doesn't. Two different jobs, two different moments.&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;// LogRepository.php (simplified)&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insert_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sanitized&lt;/span&gt; &lt;span class="o"&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;'ability_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sanitize_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ability_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;'trigger_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'trigger_type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// rest, wp-cli, cron, php&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="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&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;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failure'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'denied'&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="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'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;'unknown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'input'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logging_enabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'input'&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_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'input'&lt;/span&gt;&lt;span class="p"&gt;]&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="s1"&gt;'output'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logging_enabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'output'&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_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'output'&lt;/span&gt;&lt;span class="p"&gt;]&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="s1"&gt;'created_at'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&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="k"&gt;return&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sanitized&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;Two decisions there came straight out of thinking about what an admin would actually want, and what they'd regret leaving on by accident. Input logging is configurable, and output logging is &lt;strong&gt;off by default&lt;/strong&gt;, because ability responses can carry sensitive or genuinely large payloads and I'd rather someone opt in deliberately than discover a log table quietly filling up with data they didn't mean to store. On top of that, the log itself is a rolling window of the latest 100 execution entries rather than an ever-growing table — visibility without turning into its own storage liability. And none of it leaves the site: everything stays in a single custom table, &lt;code&gt;{$wpdb-&amp;gt;prefix}ability_guard_logs&lt;/code&gt;, with no data sent to an external service.&lt;/p&gt;




&lt;h2&gt;
  
  
  😮 What surprised me while building this
&lt;/h2&gt;

&lt;p&gt;Two things I didn't expect going in.&lt;/p&gt;

&lt;p&gt;First, how much discipline it took to &lt;em&gt;not&lt;/em&gt; scope-creep into a general activity log. It would have been easy to also log post edits, media uploads, settings changes — the usual audit-log territory. I deliberately left all of that out. AbilityGuard only logs what runs through a registered ability's native execution hooks; if an action doesn't go through the Abilities API, AbilityGuard stays quiet about it. That restraint is the whole point — it's a focused lens on one new capability surface, not a replacement for a general audit log plugin.&lt;/p&gt;

&lt;p&gt;Second, how much the "listener, not wrapper" decision simplified everything downstream. Because &lt;code&gt;ExecutionListener&lt;/code&gt; only hooks into native ability execution hooks instead of patching internals, trigger context — REST, WP-CLI, cron, or plain PHP — falls out of the hook data for free. I didn't have to reconstruct &lt;em&gt;how&lt;/em&gt; something ran; the API already tells me.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Where it goes next
&lt;/h2&gt;

&lt;p&gt;The next thing on my list is extending AbilityGuard toward accessibility auditing — using the same "listen, don't wrap" pattern to flag abilities whose responses might affect assistive technology output. That's still early, but it feels like a natural second act for a plugin whose whole reason for existing is &lt;em&gt;making the invisible visible&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building on the Abilities API yourself — whether that's registering your own abilities or just trying to understand what's already running on your site — I'd genuinely like to hear what you're building. This corner of WordPress is still small enough that we're all figuring it out together.&lt;/p&gt;




&lt;p&gt;🔗 &lt;strong&gt;AbilityGuard is live on WordPress.org:&lt;/strong&gt; &lt;a href="https://wordpress.org/plugins/ability-guard/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/ability-guard/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>plugin</category>
    </item>
    <item>
      <title>I Sold My PS5. A Very Big Step.</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:09:17 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/i-sold-my-ps5-a-very-big-step-pfn</link>
      <guid>https://dev.to/kushang_tailor/i-sold-my-ps5-a-very-big-step-pfn</guid>
      <description>&lt;p&gt;I know from the title you're thinking, &lt;strong&gt;"What does a PS5 have to do with a &lt;em&gt;big step&lt;/em&gt;?"&lt;/strong&gt; But c'mon, we all know this — nobody hates PlayStation. Everyone loves it. Some people bought it on EMI, some paid full price without blinking, some just saved for months to finally get their hands on one. No matter the cost, everyone wants it in their living room.&lt;/p&gt;

&lt;p&gt;And it's not like the timing was even good. Two massive upcoming games are lined up  — &lt;strong&gt;GTA 6 and John Wick&lt;/strong&gt;. Yeah, the games that gamers like me have been waiting for, refreshing trailers, checking release dates every other week. So selling my PS5 right &lt;em&gt;now&lt;/em&gt;, right before all that? Trust me, it hurt more than it sounds.&lt;/p&gt;

&lt;p&gt;But here's the thing about big steps — they're rarely the fun kind.&lt;/p&gt;




&lt;h2&gt;
  
  
  💸 The Real Reason
&lt;/h2&gt;

&lt;p&gt;I wasn't proud of admitting this, but I'll say it plainly because that's the whole point of writing this: I was going through a financial crisis. Not the dramatic kind you see in movies, just the quiet, everyday kind — bills stacking up, income drying up, and that constant math running in your head every single day. You know the feeling. You open your banking app, do the mental subtraction, and close it again pretending you didn't just do that.&lt;/p&gt;

&lt;p&gt;I'd been picking up Uber delivery shifts on my bike to fill the gaps, something I've written about before — You can check &lt;a href="https://dev.to/kushang_tailor/100-job-applications-2-months-zero-offers-why-i-started-riding-an-uber-delivery-bike-h1a"&gt;here&lt;/a&gt;. It helped, but it wasn't enough to cover everything. Some months you patch the hole. Other months the hole is just bigger than the patch you have.&lt;/p&gt;

&lt;p&gt;So I sat there one evening, phone in hand, staring at my PS5 sitting quietly under the TV. It wasn't collecting dust — I loved that thing. But love doesn't pay bills, and I had to be honest with myself about what mattered more right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎮 Making the Call
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this was some heroic, easy decision. It wasn't. I went back and forth for a couple of days. I even checked the resale price twice hoping it'd somehow answer the question for me. But at some point you just have to stop overthinking and act, because the tension of &lt;em&gt;not deciding&lt;/em&gt; is worse than the decision itself.&lt;/p&gt;

&lt;p&gt;I listed it. Priced it fair — not a giveaway, but not greedy either. Within a few days, I got a buyer. Someone genuinely excited to take it home, probably as pumped about GTA 6 as I was. There was a small, weird moment of handing it over — like saying bye to a good friend who's moving to another city. You're happy for them, a little sad for yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌤️ What Happened Next
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually matters: I got a genuinely good amount for it. Good enough that next month's tension just... vanished. That constant low-grade stress I'd been carrying for weeks? Gone, at least for a while.&lt;/p&gt;

&lt;p&gt;And because of that breathing room, I did something I didn't expect — I temporarily stopped the Uber deliveries. Not because I don't need that income anymore, but because for the first time in a long time, I wasn't running on empty. I could actually sit, think, plan, and not just react to whatever bill showed up next.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✍️ Why I'm Writing This
&lt;/h2&gt;

&lt;p&gt;Honestly? Because I think we don't talk about this stuff enough. Everyone posts the wins — the new gear, the new setup, the "look what I bought" content. Nobody really posts "I sold my PS5 because I needed the money." It feels a little embarrassing to admit, even now while typing this.&lt;/p&gt;

&lt;p&gt;But I think that's exactly why it's worth sharing. Financial pressure isn't rare. It's not a personal failure. It's just life catching up sometimes, especially when you're juggling freelance income, gig work, and unpredictable months. Selling something you love isn't defeat — it's just a decision. A trade-off. You give up a &lt;em&gt;want&lt;/em&gt; to protect your &lt;em&gt;stability&lt;/em&gt;, and that's not weakness, that's just being an adult about it.&lt;/p&gt;

&lt;p&gt;Will I buy a PS5 again someday? Probably, yeah. Maybe when GTA 6 actually drops and I've got some cushion built back up. But for now, I don't regret it. It did exactly what I needed it to do — it gave me one less thing to worry about, and that's worth more than any game right now.&lt;/p&gt;

&lt;p&gt;Sometimes the "big step" isn't buying something new. Sometimes it's letting go of something you love, for the version of you that needs it more.&lt;/p&gt;




&lt;h2&gt;
  
  
  📸 One Last Look
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3x4zq52mgkgmqdp0twti.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3x4zq52mgkgmqdp0twti.jpeg" alt="My PS5, before it went to its new home" width="799" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before I handed it over, I took one last photo. Felt a little silly doing it, honestly — like taking a picture with an old friend before they leave. But I'm glad I did. It's a small reminder of where I was, the decision I made, and why I made it. Not everything worth keeping has to stay in your living room.&lt;/p&gt;

</description>
      <category>ps5</category>
      <category>life</category>
      <category>money</category>
      <category>finance</category>
    </item>
    <item>
      <title>The Bug I Fixed Nine Times Before I Finally Killed It For Good</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:39:03 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/the-bug-i-fixed-nine-times-before-i-finally-killed-it-for-good-1m18</link>
      <guid>https://dev.to/kushang_tailor/the-bug-i-fixed-nine-times-before-i-finally-killed-it-for-good-1m18</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🎭 The Recurring Villain
&lt;/h2&gt;

&lt;p&gt;If you've built WordPress sites long enough, you know this bug. It doesn't show up as an error in your console. It doesn't throw a fatal exception. It just quietly sits there, page after page, client after client — and it's called &lt;strong&gt;video bloat&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nine years into web development, I lost count of how many times a client sent me a site with a message like &lt;em&gt;"it's just... slow."&lt;/em&gt; And nine times out of ten, when I opened DevTools and checked the waterfall, the story was the same: three, four, sometimes ten embedded YouTube videos, each one dragging in its own iframe, its own set of scripts, its own chunk of megabytes — all loading the second the page rendered, whether the visitor scrolled past them or not.&lt;/p&gt;

&lt;p&gt;One video embed alone can pull in 500KB–1MB+ before a user even presses play. Multiply that by a landing page stacked with testimonials, product demos, and a hero video, and you've got a page that fights your Core Web Vitals before it's even finished loading.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔁 The Manual Fix (Every. Single. Time.)
&lt;/h2&gt;

&lt;p&gt;For years, my solution was the same tedious ritual:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find every video embed on the page.&lt;/li&gt;
&lt;li&gt;Replace the iframe with a static thumbnail image.&lt;/li&gt;
&lt;li&gt;Write a bit of custom JavaScript to swap the thumbnail for the real embed on click or scroll.&lt;/li&gt;
&lt;li&gt;Repeat it for YouTube.&lt;/li&gt;
&lt;li&gt;Repeat it again for Vimeo, because Vimeo's oEmbed API works differently.&lt;/li&gt;
&lt;li&gt;Repeat it &lt;em&gt;again&lt;/em&gt; for self-hosted &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tags, because now you're dealing with &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; tags instead of iframes.&lt;/li&gt;
&lt;li&gt;Test it across whatever page builder the client was using — Elementor, Divi, WPBakery, or straight-up Gutenberg blocks — because every one of them nests the video markup slightly differently.
It worked. But it was never reusable. Every project meant writing the lazy-load script again, half from memory, half copy-pasted from my own old projects, tweaked just enough to fit the new theme's markup. It was the same bug, wearing a different site's clothes each time.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  💡 The Moment It Clicked
&lt;/h2&gt;

&lt;p&gt;The turning point wasn't one dramatic incident — it was the accumulation. After the fifth or sixth time writing near-identical vanilla JS to defer a YouTube iframe, I finally asked myself the obvious question: &lt;em&gt;why am I solving this per-project instead of solving it once?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's what pushed me to stop patching symptoms and build &lt;strong&gt;VidDefer&lt;/strong&gt; — a plugin that treats video lazy-loading as a solved problem you should never have to think about again.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ How VidDefer Actually Fixes It
&lt;/h2&gt;

&lt;p&gt;The core trick is that VidDefer doesn't care how or where your video got onto the page. Instead of hooking into specific page builders one by one (which is a losing battle — there's always a new builder), it uses &lt;strong&gt;PHP output buffering&lt;/strong&gt; to intercept the final rendered HTML before it reaches the browser. Any YouTube iframe, Vimeo iframe, or self-hosted &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag gets swapped for a lightweight thumbnail-and-play-button placeholder — regardless of whether it came from Gutenberg, Elementor, Divi, WPBakery, or hand-written HTML in a Custom HTML block.&lt;/p&gt;

&lt;p&gt;The real video only loads when a visitor actually clicks, or scrolls near it — saving hundreds of kilobytes on every page load where a video isn't watched (which, statistically, is most of the time).&lt;/p&gt;

&lt;p&gt;A few details I was stubborn about while building it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero configuration.&lt;/strong&gt; Activate it, and it just works. No setup wizard required, though there's a settings screen if you want to tweak things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No jQuery.&lt;/strong&gt; Pure ES6, under 2KB gzipped. The whole point is performance — the fix itself shouldn't add weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-source handling done properly.&lt;/strong&gt; YouTube thumbnails come straight from YouTube's public CDN (no API key needed). Vimeo thumbnails come through Vimeo's oEmbed endpoint and get cached locally via WordPress transients, so your server isn't pinging Vimeo on every single page load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility wasn't optional.&lt;/strong&gt; The placeholders are keyboard-navigable and follow WCAG 2.1, because a performance fix that breaks accessibility isn't a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An escape hatch.&lt;/strong&gt; Some videos — popups, lightboxes — shouldn't be touched. A simple &lt;code&gt;no-lazy&lt;/code&gt; class tells VidDefer to leave them alone.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚡ Before vs. After
&lt;/h2&gt;

&lt;p&gt;Here's where I want to be upfront instead of hand-wavy: I'm not going to drop a flashy "300% faster!" screenshot in this post, because that number would either be fake or borrowed from a client site — and client performance data isn't mine to publish. Privacy first, always.&lt;/p&gt;

&lt;p&gt;What I &lt;em&gt;can&lt;/em&gt; tell you honestly: VidDefer just landed on WordPress.org. It's brand new. So instead of cherry-picking an old result, I'd rather show you real, verifiable numbers once they exist — a proper before/after on a demo site loaded with heavy video embeds, tested with actual Lighthouse and PageSpeed runs, out in the open where anyone can double-check it.&lt;/p&gt;

&lt;p&gt;That's next on my list: building a public demo site stacked with YouTube, Vimeo, and self-hosted videos, and publishing the raw before/after report for everyone to see — no cherry-picking, no marketing spin. If you're curious, that follow-up post is coming.&lt;/p&gt;

&lt;p&gt;What I can say with confidence today, from nine years of doing this fix by hand: every unloaded iframe is bytes and requests your browser simply never has to make. The math isn't controversial — deferring what the visitor hasn't asked to see yet is a straightforward win. I just want to &lt;em&gt;show&lt;/em&gt; you that win with real data, not tell you about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎓 What I Learned
&lt;/h2&gt;

&lt;p&gt;The real lesson here wasn't about video embeds at all — it was about recognizing when you're solving the same bug over and over and calling it "just part of the job." If you find yourself writing a similar patch for the third or fourth time, that's not a coincidence. That's a signal that the fix belongs in a tool, not in your muscle memory.&lt;/p&gt;

&lt;p&gt;For me personally, the fix that used to take an hour or two of custom scripting per project now takes the time it takes to click "Activate." That alone was worth building.&lt;/p&gt;

&lt;p&gt;VidDefer is live on WordPress.org now, and every install is one fewer freelancer somewhere staying up late writing the same lazy-load script I used to write.&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://wordpress.org/plugins/viddefer/" rel="noopener noreferrer"&gt;VidDefer – Deferred Video Embeds on WordPress.org&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>lazyload</category>
      <category>webdev</category>
    </item>
    <item>
      <title>100+ Job Applications, 2 Months, Zero Offers — Why I Started Riding an Uber Delivery Bike</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:18:11 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/100-job-applications-2-months-zero-offers-why-i-started-riding-an-uber-delivery-bike-h1a</link>
      <guid>https://dev.to/kushang_tailor/100-job-applications-2-months-zero-offers-why-i-started-riding-an-uber-delivery-bike-h1a</guid>
      <description>&lt;p&gt;&lt;em&gt;Never break when your time is tough — just stay calm, stay active, and keep moving.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 The Application That Broke Me
&lt;/h2&gt;

&lt;p&gt;I stopped counting after 100.&lt;/p&gt;

&lt;p&gt;I applied to every role that matched my resume, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress Developer&lt;/li&gt;
&lt;li&gt;Full-Stack Developer&lt;/li&gt;
&lt;li&gt;Frontend Developer&lt;/li&gt;
&lt;li&gt;Backend Developer&lt;/li&gt;
&lt;li&gt;Business Development Executive - Entry Level&lt;/li&gt;
&lt;li&gt;Project Manager - Entry Level&lt;/li&gt;
&lt;li&gt;Junior React Native Developer&lt;/li&gt;
&lt;li&gt;Senior WordPress Developer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two months. Every relevant listing I could find. Custom cover letters for the ones that mattered. Follow-ups. LinkedIn messages to hiring managers who never wrote back.&lt;/p&gt;

&lt;p&gt;Nothing worked out.&lt;/p&gt;

&lt;p&gt;Not one offer. Barely a handful of interviews. A few "we'll get back to you" emails that never came back.&lt;/p&gt;

&lt;p&gt;So a few weeks ago, I picked up a food and package delivery job on Uber, riding my own Hero Xtreme 125 through morning and evening traffic. I'm writing this because I think a lot of developers reading this are living some version of the same story right now, and nobody in our field likes to say it out loud. I do.&lt;/p&gt;




&lt;h2&gt;
  
  
  📊 The Market Isn't in Your Head — It's Actually This Bad
&lt;/h2&gt;

&lt;p&gt;If job hunting in 2026 feels harder than it should, that's not you being unskilled or unlucky. The numbers back you up.&lt;/p&gt;

&lt;p&gt;The time it takes to get a first job offer jumped from 57 days to 83 days in 2025 — almost a month and a half longer than before. Entry-level hiring at the biggest tech companies dropped 25% between 2023 and 2024, and it still hasn't bounced back. Software job postings are still down more than a third from pre-pandemic levels, and about 7% lower than they were just a year ago.&lt;/p&gt;

&lt;p&gt;It's not just a "big tech" problem either. At one well-known Indian engineering institute, fewer than a quarter of a graduating class of 400 students had job offers just months before their course ended. Students there are calling it a "jobpocalypse." Tech layoffs crossed 150,000 in a single year not long ago, and even though the number has come down since, Q1 2026 alone saw close to 80,000 more tech workers lose their jobs.&lt;/p&gt;

&lt;p&gt;Here's the part that hurts if you're early in your career: it's not spread evenly. Jobs for young software developers have dropped almost 20% from their late-2022 peak. Senior and specialist roles are actually growing at some companies — Google alone posted 62% more engineering openings in the first half of 2026 than a year earlier — but that growth sits at the top of the experience ladder, not at the entry point most of us are trying to get through.&lt;/p&gt;

&lt;p&gt;So if you've sent 50, 80, 100+ applications and heard mostly silence — you're not the exception. You're the current normal.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ What's Actually Causing This
&lt;/h2&gt;

&lt;p&gt;Four things are happening at once, and it helps to name them instead of just feeling them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. AI is eating the "easy" tickets first.&lt;/strong&gt; Company leaders aren't hiding it anymore. Microsoft's CEO has said AI now writes a real chunk of the code in some of their projects. Salesforce's CEO said his company hired zero new engineers in a recent year because AI tools closed that gap. The small, simple tasks that used to be a junior developer's way in — quick bug fixes, boilerplate features, basic integrations — are exactly what AI coding tools now handle well. That doesn't mean developers are useless, but it does remove a lot of the "starter" work companies used to hire freshers for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Companies are still recovering from over-hiring in 2021-2022.&lt;/strong&gt; A lot of today's layoffs trace back to companies that doubled their engineering teams during the cheap-money pandemic years and are now cutting that back. This has nothing to do with your skills. It's a hiring hangover that started years before you even applied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Global conflicts are adding extra pressure on hiring budgets.&lt;/strong&gt; With active wars and unrest going on in multiple regions right now, companies are dealing with higher energy costs, shakier supply chains, and more cautious investors. Reports through 2026 show business leaders openly saying this instability is a factor in tighter, slower hiring decisions — money is moving toward "safe" spends like cybersecurity and core AI infrastructure, and away from riskier hiring plans, especially at the entry level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The bar for "entry-level" quietly moved higher.&lt;/strong&gt; Companies don't just want a degree and a to-do-list app on your GitHub anymore. They want real, shipped work — actual projects, actual collaboration, actual production experience — squeezed into what used to be an entry-level ask. It's a classic trap: you need production experience to get hired, and you need to get hired to gain production experience.&lt;/p&gt;

&lt;p&gt;None of this means the field is dying. Developer roles are still expected to grow well into the next decade, and plenty of companies are hiring hard for the right profile. But "the right profile" has shifted, and nobody warns you about that shift until you're three months deep into rejections.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 What Skills Are Actually in Demand Right Now
&lt;/h2&gt;

&lt;p&gt;Going through current hiring data and job posts, the same pattern keeps showing up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Working with AI tools is no longer a bonus — it's expected.&lt;/strong&gt; More than half of tech job posts now ask for AI/ML familiarity, and daily use of tools like GitHub Copilot, Cursor, Codex, or Claude Code is fast becoming a basic skill, not an impressive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth over "I can do everything."&lt;/strong&gt; The generalist pitch is losing ground to developers with real, provable depth in one lane — cloud, DevOps, ML, security, or a specific ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For WordPress specifically&lt;/strong&gt;, the bar has moved well past "I can install a theme and turn on WooCommerce." Companies now want REST API and headless WordPress experience, React-based block development, clean OOP plugin architecture, solid Git habits, and comfort with the platform's built-in AI tooling. WordPress still runs over 40% of the web, so the demand is real — it's just concentrated on developers who've moved past template installs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof over polish.&lt;/strong&gt; A strong GitHub history, published articles, or real shipped projects now carry more weight than a clean resume, since most hires still come through referrals and visible work, not cold applications.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚖️ My Skills vs. What's Actually Demanded
&lt;/h2&gt;

&lt;p&gt;Here's where I had to be honest with myself instead of just angry at the market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I bring:&lt;/strong&gt; I've built and shipped 4 WordPress plugins from scratch, following pure OOP architecture, WordPress VIP coding standards, and proper sanitization/escaping at every layer. I've built enterprise-level websites and web apps using React and Next.js, including headless WordPress setups where the frontend is fully decoupled from the CMS. Performance and security aren't an afterthought for me — I bake them into the build from day one, whether that's caching strategy, query optimization, or basic hardening against common attack vectors. I also actively use AI tools like Codex, Claude, and GitHub Copilot as part of my real workflow — not just to autocomplete a line, but to help scaffold plugins, debug tricky WordPress hooks, and speed up full web app builds. On top of that, I keep a solid, active GitHub history and write technical articles regularly, so my work is documented and public, not sitting in private repos nobody sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the gap actually is:&lt;/strong&gt; the problem wasn't that I lacked AI fluency — I use these tools daily and build with them. The real gap was in how I was positioning myself. I was applying into the same volume-heavy, entry-level pool as everyone else, instead of clearly showing recruiters that I already work the way the market now expects: AI-assisted, specialization-focused, with real production and architecture experience behind me. My resume wasn't telling that story loudly enough.&lt;/p&gt;

&lt;p&gt;That gap isn't a character flaw. It's a positioning problem, and it's fixable — just not in the two months I had bills to pay in the meantime.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛵 Why I Joined Uber
&lt;/h2&gt;

&lt;p&gt;I didn't join Uber because I gave up on development. I joined because rent, food, and basic responsibilities don't pause for a job market correction, and two months of zero income while sending applications into a void just isn't sustainable for anyone.&lt;/p&gt;

&lt;p&gt;Delivery work on my own bike gave me something a regular part-time job couldn't: income starting almost right away, with zero disruption to my ability to keep applying, interviewing, and writing during the hours I wasn't riding. It's physically harder than sitting at a desk, and yes, some pride had to be swallowed. But a bridge is a bridge, and I'd rather ride one than fall through the gap it's covering.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ The Onboarding Process Was Refreshingly Simple
&lt;/h2&gt;

&lt;p&gt;Compared to the black-hole application process I'd just been through, signing up to deliver was almost jarring in how simple it was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No bond. No lock-in agreement of any kind.&lt;/li&gt;
&lt;li&gt;No fixed shift hours — I choose when I ride, and I choose when I stop.&lt;/li&gt;
&lt;li&gt;Document verification, a basic orientation, and I was live on the app within days, not weeks.&lt;/li&gt;
&lt;li&gt;Full control to log off any time — no penalty for taking a day off for an interview or a writing deadline.
That flexibility is exactly why it works as a bridge income source. I run two shifts a day — morning and evening — and build the rest of my day around job hunting and content work.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  💰 The Actual Numbers: What My Hero Xtreme 125 Earns Me
&lt;/h2&gt;

&lt;p&gt;Let's do this properly, because vague "I make decent money" claims don't help anyone else weighing this option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My setup:&lt;/strong&gt; a Hero Xtreme 125, petrol at roughly ₹100/liter, riding two shifts a day — morning and evening — spending roughly ₹100 in fuel per shift (about ₹200/day, close to 2 liters). I also take one full day off every week to rest, both for me and the bike.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hours/day&lt;/th&gt;
&lt;th&gt;Gross earning&lt;/th&gt;
&lt;th&gt;Fuel cost&lt;/th&gt;
&lt;th&gt;Net earning/day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6 hrs (standard)&lt;/td&gt;
&lt;td&gt;₹700–800&lt;/td&gt;
&lt;td&gt;~₹200&lt;/td&gt;
&lt;td&gt;₹500–600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 hrs (extended)&lt;/td&gt;
&lt;td&gt;₹1,000+&lt;/td&gt;
&lt;td&gt;~₹200–250&lt;/td&gt;
&lt;td&gt;₹750–800+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 hrs (long day)&lt;/td&gt;
&lt;td&gt;~₹1,250–1,300&lt;/td&gt;
&lt;td&gt;~₹280–300&lt;/td&gt;
&lt;td&gt;~₹950–1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 hrs (max stretch)&lt;/td&gt;
&lt;td&gt;~₹1,450–1,550&lt;/td&gt;
&lt;td&gt;~₹320–350&lt;/td&gt;
&lt;td&gt;~₹1,100–1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the per-hour rate drops a bit as the hours climb — that's normal, since the best surge-pricing windows (early morning and evening rush) get used up first, and the extra hours are usually slower, lower-paying stretches.&lt;/p&gt;

&lt;p&gt;Scaling this out with a weekly off day built in — roughly 26 riding days a month:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;6 hrs/day&lt;/th&gt;
&lt;th&gt;8 hrs/day&lt;/th&gt;
&lt;th&gt;10 hrs/day&lt;/th&gt;
&lt;th&gt;12 hrs/day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly (net)&lt;/td&gt;
&lt;td&gt;~₹14,300&lt;/td&gt;
&lt;td&gt;~₹20,000–20,800&lt;/td&gt;
&lt;td&gt;~₹25,350&lt;/td&gt;
&lt;td&gt;~₹29,900–31,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly (net)&lt;/td&gt;
&lt;td&gt;~₹1,71,600&lt;/td&gt;
&lt;td&gt;~₹2,40,000–2,49,600&lt;/td&gt;
&lt;td&gt;~₹3,04,200&lt;/td&gt;
&lt;td&gt;~₹3,58,800–3,74,400&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A quick mileage sanity check: the Xtreme 125 is rated over 60 kmpl on paper, but real-world, stop-start delivery riding usually brings that down to somewhere between 40 and 50 kmpl. At ₹100/liter, that's roughly ₹2–2.5 per km, which lines up closely with the ₹200/day I actually spend across two shifts covering around 80–100 km total.&lt;/p&gt;

&lt;p&gt;Worth being upfront about the parts this table doesn't show: chain lubrication, oil changes, tyre wear, and regular servicing add up over months of daily riding. So the real net is a little lower than the fuel-only numbers suggest once you spread maintenance across the year. I'd set aside roughly ₹500–800 a month for that on top of fuel.&lt;/p&gt;

&lt;p&gt;It's not developer-salary money. It's not meant to be. It's meant to keep the lights on while I fix the real problem — landing the next role.&lt;/p&gt;




&lt;h2&gt;
  
  
  💭 A Few Things I'd Add From My Side
&lt;/h2&gt;

&lt;p&gt;A few honest thoughts that didn't fit neatly into the sections above but felt worth saying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This isn't a failure story, it's a sequencing story.&lt;/strong&gt; The job search didn't stop. It just stopped being the only thing paying the bills while it played out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing kept me sane.&lt;/strong&gt; Publishing articles and working on side projects during this stretch gave me something the job hunt alone couldn't — a sense of moving forward on days when applications went nowhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stigma is the hardest part, not the job itself.&lt;/strong&gt; Riding a delivery bike after years of writing production code messes with your head more than the physical work does. Worth saying plainly, because I suspect a lot of developers going through something similar feel that shame alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexibility is underrated as a survival tool.&lt;/strong&gt; A gig with no bond and no fixed hours meant I never had to choose between an interview and a shift. That mattered more than the pay itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The market will turn — but not by waiting.&lt;/strong&gt; Every report on this points the same way: demand for developers who can specialize and work fluently with AI tools is actually growing, even while generic entry-level postings shrink. The move isn't to give up on development. It's to close the gap between how you're positioned and what's currently getting hired.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🌱 Where This Leaves Me
&lt;/h2&gt;

&lt;p&gt;I'm still applying. I'm still writing. I'm still building and the rest of my plugins work in whatever hours are left after two shifts on the bike. The Uber income isn't the destination — it's what's making sure I don't have to pause the actual goal while the market sorts itself out.&lt;/p&gt;

&lt;p&gt;If you're in the same spot right now — sending applications into what feels like a void — you're not behind, and you're not broken. The data shows the market genuinely shifted under everyone's feet at the same time. The only real move is to keep adjusting how you show up, while keeping the lights on however you need to.&lt;/p&gt;

&lt;p&gt;If this resonated with you, or you're going through something similar, I'd genuinely like to hear how you're handling it. Drop a comment.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources referenced:&lt;/strong&gt; IEEE Spectrum, Rest of World, The Pragmatic Engineer's 2026 job market deep-dive, Final Round AI's 2026 software engineering market report, Second Talent's 2026 tech job market trends, SQ Magazine's layoff statistics roundup, Forbes' 2026 midyear geopolitical trends tracker, IDC's 2026 IT spending outlook, and current WordPress hiring guides from Wisemonk and Boundev.&lt;/p&gt;

</description>
      <category>career</category>
      <category>wordpress</category>
      <category>jobsearch</category>
      <category>reallife</category>
    </item>
    <item>
      <title>Why I Stopped Trusting 'Just Embed the Video' and Built VidDefer Instead</title>
      <dc:creator>Kushang Tailor</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kushang_tailor/why-i-stopped-trusting-just-embed-the-video-and-built-viddefer-instead-1ag8</link>
      <guid>https://dev.to/kushang_tailor/why-i-stopped-trusting-just-embed-the-video-and-built-viddefer-instead-1ag8</guid>
      <description>&lt;h1&gt;
  
  
  🎬 Why I Built VidDefer — And Why Your Video-Heavy WordPress Site Probably Needs It
&lt;/h1&gt;

&lt;p&gt;Let me start with a confession: for years, I treated video embeds like they were "free." Drop a YouTube iframe into a post, hit publish, move on. No big deal, right?&lt;/p&gt;

&lt;p&gt;Wrong. Dead wrong.&lt;/p&gt;

&lt;p&gt;The moment I actually opened DevTools on a client site with 15+ embedded YouTube videos on a single landing page, I understood why the Core Web Vitals report looked like a crime scene. Every single embed was silently loading its own player, its own scripts, its own tracking pixels — before the visitor had even scrolled past the hero section.&lt;/p&gt;

&lt;p&gt;That frustration is the entire origin story of &lt;strong&gt;&lt;a href="https://wordpress.org/plugins/viddefer/" rel="noopener noreferrer"&gt;VidDefer – Deferred Video Embeds&lt;/a&gt;&lt;/strong&gt;, a plugin I just shipped to the WordPress.org repository.&lt;/p&gt;

&lt;p&gt;This isn't a "top 10 performance tips" article. It's the story of a real problem, why existing solutions didn't fully cut it, and how VidDefer approaches it differently — plus an honest breakdown of who actually needs this plugin (spoiler: not everyone does).&lt;/p&gt;




&lt;h2&gt;
  
  
  💡 Where the Idea Actually Came From
&lt;/h2&gt;

&lt;p&gt;I didn't wake up one day and decide "I should build a video lazy-load plugin." It came from a pattern I kept noticing across client projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A course platform with a video per lesson page.&lt;/li&gt;
&lt;li&gt;A real estate site with property walkthrough videos on every listing.&lt;/li&gt;
&lt;li&gt;A recipe blog embedding a YouTube tutorial in nearly every post.&lt;/li&gt;
&lt;li&gt;A SaaS marketing site with a demo video stacked on top of three testimonial videos, all on the homepage.
Every single one of these sites had the same complaint: &lt;strong&gt;"Our PageSpeed score is terrible, and we don't know why — we already optimized images and enabled caching."&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer was almost always hiding in plain sight: multiple video iframes loading simultaneously on page load, each one pulling in third-party JavaScript before a visitor had shown any intent to actually watch anything.&lt;/p&gt;

&lt;p&gt;Existing lazy-load plugins handled images beautifully. Videos were treated like an afterthought — usually just "lazy load the iframe attribute" without actually deferring the heavy scripts behind it. That gap is where VidDefer was born.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Why This Plugin? What Problem Does It Actually Solve?
&lt;/h2&gt;

&lt;p&gt;Here's the technical reality most WordPress users don't realize: a standard YouTube or Vimeo embed doesn't just load a video. It loads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The full embedded player iframe&lt;/li&gt;
&lt;li&gt;YouTube/Vimeo's own JavaScript player framework&lt;/li&gt;
&lt;li&gt;Analytics and tracking scripts bundled by the provider&lt;/li&gt;
&lt;li&gt;Preconnect and font requests tied to the player UI
All of this fires &lt;strong&gt;immediately on page load&lt;/strong&gt;, whether or not the visitor ever intends to press play. Multiply that by every video on the page, and you've got a serious Largest Contentful Paint (LCP) and Total Blocking Time (TBT) problem — even if your images and fonts are perfectly optimized.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;VidDefer solves this with a simple idea: &lt;strong&gt;show a lightweight thumbnail placeholder first, and only load the real player when the visitor clicks it (or scrolls to it, if you enable that mode).&lt;/strong&gt; The visitor sees a legitimate, good-looking video preview instantly. The heavy player only loads on genuine interaction.&lt;/p&gt;

&lt;p&gt;The result: pages that used to ship hundreds of kilobytes of unused video-player JavaScript on load now ship a tiny thumbnail image instead — until the user actually asks for the video.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎥 Built Specifically for Video-Heavy Sites — Not Every Site Needs This
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about something most plugin marketing pages avoid saying: &lt;strong&gt;if your site has one video, buried at the bottom of one blog post, you probably don't need VidDefer.&lt;/strong&gt; The performance gain there is marginal.&lt;/p&gt;

&lt;p&gt;This plugin earns its place specifically on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Course and membership sites&lt;/strong&gt; with a video embedded on every lesson page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real estate and travel sites&lt;/strong&gt; using video walkthroughs across dozens of listings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS and agency landing pages&lt;/strong&gt; stacking demo videos, testimonial videos, and explainer videos together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media and review blogs&lt;/strong&gt; that embed a YouTube video in nearly every article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portfolio and creative sites&lt;/strong&gt; showcasing multiple embedded reels or showcases per page
If your site falls into "video-heavy" territory — meaning multiple embeds per page, or video embeds across a large percentage of your total pages — that's exactly where VidDefer is designed to shine. On a content-light, video-light site, a general-purpose performance plugin will likely already cover you. VidDefer isn't trying to replace that; it's solving a narrower, deeper problem for sites where video embeds are the actual bottleneck.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔍 The Real-World Problem, In Numbers
&lt;/h2&gt;

&lt;p&gt;Here's a rough breakdown of what a single unoptimized YouTube embed typically drags along with it on load, compared to a deferred thumbnail placeholder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A standard embedded iframe pulls in the player framework, related scripts, and thumbnail assets that can easily add several hundred kilobytes and multiple third-party requests — before playback even starts.&lt;/li&gt;
&lt;li&gt;A deferred placeholder, by contrast, is just a compressed thumbnail image and a small play-button overlay, with the actual plugin script itself weighing in at under 2KB gzipped.
Now multiply that by five, ten, or fifteen embeds on a single page — which is completely normal for course platforms or video-heavy blogs — and you can see why Core Web Vitals scores collapse on these page types specifically, even when everything else on the site is well-optimized.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚙️ Which Third-Party Scripts Does VidDefer Actually Defer?
&lt;/h2&gt;

&lt;p&gt;This is the part developers usually want to know, so let's get specific. On visitor interaction (click or scroll trigger, depending on your settings), VidDefer defers and then loads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Video Source&lt;/th&gt;
&lt;th&gt;What Gets Deferred&lt;/th&gt;
&lt;th&gt;What Loads Only on Interaction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YouTube&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The full YouTube iframe player + its embedded scripts&lt;/td&gt;
&lt;td&gt;Real YouTube iframe player, pulling thumbnails from YouTube's public thumbnail CDN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vimeo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The full Vimeo iframe player + its embedded scripts&lt;/td&gt;
&lt;td&gt;Real Vimeo iframe player, with thumbnail/title metadata fetched via Vimeo's public oEmbed API and cached locally via WordPress transients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosted video&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element and its associated MP4/WebM/OGV source loading&lt;/td&gt;
&lt;td&gt;The actual video file, only once the visitor triggers playback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few implementation details worth calling out for fellow developers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vimeo thumbnail/title data is fetched via a secure server-to-server request and then &lt;strong&gt;cached using WordPress transients&lt;/strong&gt;, so your site only contacts Vimeo once per video, not on every page load.&lt;/li&gt;
&lt;li&gt;YouTube thumbnails are pulled straight from YouTube's public thumbnail CDN — no API key required, no quota limits to worry about.&lt;/li&gt;
&lt;li&gt;The plugin intercepts embeds using PHP output buffering, so it works regardless of whether the video was added via Gutenberg, Elementor, Divi, WPBakery, or hand-coded HTML.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📊 Without VidDefer vs. With VidDefer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Without VidDefer&lt;/th&gt;
&lt;th&gt;With VidDefer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Initial page load&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every embedded video loads its full iframe + scripts immediately&lt;/td&gt;
&lt;td&gt;Only a lightweight thumbnail image loads initially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Third-party requests on load&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple requests per video (player framework, tracking, fonts)&lt;/td&gt;
&lt;td&gt;Zero third-party requests until visitor interacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JavaScript weight&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grows with every additional embedded video&lt;/td&gt;
&lt;td&gt;Plugin adds under 2KB gzipped, regardless of video count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LCP / TBT impact&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Degrades significantly on video-heavy pages&lt;/td&gt;
&lt;td&gt;Minimal impact — thumbnails are lightweight and render fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Visitor experience&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Player controls appear before intent to watch is shown&lt;/td&gt;
&lt;td&gt;Clean thumbnail + play button, feels intentional and fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GDPR / privacy exposure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Third-party cookies/scripts may load before consent&lt;/td&gt;
&lt;td&gt;No third-party connection made until visitor clicks play&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Depends entirely on the embed provider's markup&lt;/td&gt;
&lt;td&gt;WCAG 2.1 keyboard-accessible placeholders built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Page builder compatibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Works with Gutenberg, Elementor, Divi, and WPBakery via output buffering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🥊 Competitors vs. VidDefer
&lt;/h2&gt;

&lt;p&gt;There are a handful of established performance plugins that touch video lazy loading as a side feature. Here's an honest, non-exaggerated comparison of where VidDefer differs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;General-purpose performance/caching plugins&lt;/strong&gt; (the big all-in-one optimization suites): most of these treat video lazy loading as one small checkbox buried inside a much larger feature set focused on caching, minification, and image optimization. It works, but it's rarely built with video-specific controls like thumbnail quality, play-button styling, or per-video exclusion classes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated "lazy load for videos" plugins&lt;/strong&gt;: a few of these exist, but many are YouTube-only, require an API key, or haven't been updated in a while. Vimeo and self-hosted video support is often missing entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manually coding it yourself&lt;/strong&gt;: totally possible if you're comfortable writing custom output-buffering logic and thumbnail-fetching code for three different video sources — but that's a real time investment for something that should be a five-minute plugin activation.
VidDefer's niche is being &lt;strong&gt;video-first and multi-source from day one&lt;/strong&gt;: YouTube, Vimeo, and self-hosted videos, all handled the same way, with zero configuration required to get the core benefit, and deeper customization available for anyone who wants it (thumbnail quality, play button style, colors, exclusions, GDPR overlay).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be fair to the alternatives: if you only need basic image lazy loading and video is a minor part of your site, an all-in-one performance plugin is still a perfectly reasonable choice. VidDefer is for when video is the &lt;em&gt;primary&lt;/em&gt; performance bottleneck, not a footnote.&lt;/p&gt;




&lt;h2&gt;
  
  
  ➕ A Few Additional Things Worth Knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No API key required&lt;/strong&gt; — YouTube thumbnails come from the public CDN, Vimeo data comes from the public oEmbed endpoint. Nothing to configure, nothing to break when a key expires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclusion support&lt;/strong&gt; — Add a &lt;code&gt;no-lazy&lt;/code&gt; class to any video, iframe, or wrapping element to skip it entirely, useful for popups and lightboxes that need immediate access to the player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube Shorts are handled automatically&lt;/strong&gt;, since they use the same embed format as standard YouTube videos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three play button styles and custom colors&lt;/strong&gt; mean the placeholder doesn't have to look like an obvious plugin bolt-on — it can match your site's branding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in GDPR/privacy notice overlay&lt;/strong&gt; is a nice side effect of the deferred-loading architecture: since no third-party connection happens until the click, you get a natural checkpoint to show a consent notice first.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🚀 Try It, Break It, Tell Me About It
&lt;/h2&gt;

&lt;p&gt;If your site has more than a couple of embedded videos per page — especially across course content, listings, or media-heavy posts — I'd genuinely love for you to try &lt;strong&gt;&lt;a href="https://wordpress.org/plugins/viddefer/" rel="noopener noreferrer"&gt;VidDefer – Deferred Video Embeds&lt;/a&gt;&lt;/strong&gt; and see what it does to your Core Web Vitals numbers.&lt;/p&gt;

&lt;p&gt;It's version 1.0.0, freshly published, so bug reports and feature requests through the support forum are extremely welcome. If you've solved this problem a different way on your own sites, I'd love to hear that too — drop a comment below.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>performance</category>
      <category>webperf</category>
      <category>plugin</category>
    </item>
  </channel>
</rss>
