<?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: Dimitri BOUTEILLE</title>
    <description>The latest articles on DEV Community by Dimitri BOUTEILLE (@dimitribouteille).</description>
    <link>https://dev.to/dimitribouteille</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%2F4227109%2F990d959e-5de8-440b-a29a-511508bf735d.jpg</url>
      <title>DEV Community: Dimitri BOUTEILLE</title>
      <link>https://dev.to/dimitribouteille</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dimitribouteille"/>
    <language>en</language>
    <item>
      <title>Stop piling up register_rest_route(): build WordPress REST APIs with PHP 8 attributes</title>
      <dc:creator>Dimitri BOUTEILLE</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:24:39 +0000</pubDate>
      <link>https://dev.to/dimitribouteille/stop-piling-up-registerrestroute-build-wordpress-rest-apis-with-php-8-attributes-1lf4</link>
      <guid>https://dev.to/dimitribouteille/stop-piling-up-registerrestroute-build-wordpress-rest-apis-with-php-8-attributes-1lf4</guid>
      <description>&lt;p&gt;&lt;em&gt;Your WordPress REST API deserves controllers, not a 600-line functions.php. Here is how to get there with PHP 8 attributes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The WordPress REST API is one of the best things that happened to WordPress. It powers the block editor, headless front-ends in Next.js or Nuxt, mobile apps and countless integrations.&lt;/p&gt;

&lt;p&gt;Adding your own endpoints, though, has not changed much since 2016. You call &lt;code&gt;register_rest_route()&lt;/code&gt;, pass an array of options, write a callback that reads &lt;code&gt;$request-&amp;gt;get_param()&lt;/code&gt; by hand, and remember to add a &lt;code&gt;permission_callback&lt;/code&gt;. For two endpoints, that is fine. For thirty, it becomes a long file of nested arrays that nobody wants to touch.&lt;/p&gt;

&lt;p&gt;In this article, we will build a small API for documents twice: first the native way, then with PHP 8 attributes using &lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api" rel="noopener noreferrer"&gt;wp-module-rest-api&lt;/a&gt;, an open-source package I maintain. By the end, each endpoint will be a typed method in a plain PHP class.&lt;/p&gt;

&lt;p&gt;You will need PHP 8.1+, Composer, and a WordPress install that loads Composer's autoloader. A &lt;a href="https://roots.io/bedrock/" rel="noopener noreferrer"&gt;Bedrock&lt;/a&gt; project is perfect, but a mu-plugin with its own &lt;code&gt;vendor/&lt;/code&gt; works too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The native way
&lt;/h2&gt;

&lt;p&gt;Our API needs two endpoints: read a document and delete it. Reading is public; deleting is reserved to administrators. With plain WordPress, it looks like this:&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;'app/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/documents/(?P&amp;lt;id&amp;gt;\d+)'&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;'methods'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;$document&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&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="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;$document&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="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;'not-found'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Document not found.'&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="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="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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$document&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;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'methods'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&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="n"&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;$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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="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;$deleted&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="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'administrator'&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;It works, but look at what it costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything is an array.&lt;/strong&gt; Your IDE cannot autocomplete the keys, and a typo in &lt;code&gt;permission_callback&lt;/code&gt; fails silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameters are untyped.&lt;/strong&gt; Every callback starts with &lt;code&gt;$request-&amp;gt;get_param()&lt;/code&gt; and a manual cast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security is opt-in.&lt;/strong&gt; Forget &lt;code&gt;permission_callback&lt;/code&gt; and your endpoint is public. WordPress only logs a notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are ad hoc.&lt;/strong&gt; Each callback builds its own &lt;code&gt;WP_Error&lt;/code&gt;, with its own codes and status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not scale.&lt;/strong&gt; Thirty endpoints means thirty of these blocks, usually in one file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of course, this code can be refactored: move the callbacks into classes, share the permission checks in functions, write a small helper around &lt;code&gt;register_rest_route()&lt;/code&gt;. It helps, but the core issue remains: every endpoint is still a configuration array to write and maintain next to the code that does the work, and the whole thing stays verbose.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up the attribute-based approach
&lt;/h2&gt;

&lt;p&gt;Install the package with Composer:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require dbout/wp-module-rest-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Routes are discovered from a PSR-4 namespace, so make sure your &lt;code&gt;composer.json&lt;/code&gt; autoloads your code:&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;"autoload"&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;"psr-4"&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;"App\\"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then register the loader once, early in the WordPress boot. A mu-plugin is a good place:&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="c1"&gt;// wp-content/mu-plugins/api.php&lt;/span&gt;

&lt;span class="k"&gt;require&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;'/../../vendor/autoload.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\NamespaceRouteLoader&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;NamespaceRouteLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\\Routes\\'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Adjust the path to &lt;code&gt;vendor/autoload.php&lt;/code&gt; to your project layout. Every class under &lt;code&gt;App\Routes\&lt;/code&gt; is now scanned for routes, and &lt;code&gt;register()&lt;/code&gt; hooks into &lt;code&gt;rest_api_init&lt;/code&gt; for you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your first controller
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;src/Routes/DocumentController.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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Routes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Attributes\Action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Attributes\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Enums\Method&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Route('app/v1', '/documents/(?P&amp;lt;id&amp;gt;\d+)')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Action(Method::GET)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="n"&gt;\WP_REST_Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$document&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;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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$document&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;$document&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Action(Method::DELETE)]&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&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="n"&gt;\WP_REST_Response&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="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="nf"&gt;wp_delete_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="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;That is the whole registration. &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;DELETE /wp-json/app/v1/documents/42&lt;/code&gt; now hit these two methods.&lt;/p&gt;

&lt;p&gt;A few things happen behind the scenes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#[Route]&lt;/code&gt; holds the namespace and the path, exactly as &lt;code&gt;register_rest_route()&lt;/code&gt; expects them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#[Action]&lt;/code&gt; maps a method to one or several HTTP methods: &lt;code&gt;#[Action([Method::POST, Method::PUT])]&lt;/code&gt; works too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arguments are injected by name.&lt;/strong&gt; The &lt;code&gt;id&lt;/code&gt; captured by the URL is passed to &lt;code&gt;$id&lt;/code&gt; and cast to &lt;code&gt;int&lt;/code&gt;. The same goes for query string and body parameters.&lt;/li&gt;
&lt;li&gt;Need the raw request for headers or a JSON body? Add a &lt;code&gt;\WP_REST_Request $request&lt;/code&gt; argument and you get it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No more &lt;code&gt;get_param()&lt;/code&gt;, no more manual casting, and your IDE understands every line. We are not done, though: right now, anyone can delete a document.&lt;/p&gt;
&lt;h2&gt;
  
  
  Locking it down with permissions
&lt;/h2&gt;

&lt;p&gt;Permissions are declared with the same attributes. Set &lt;code&gt;permissionCallback&lt;/code&gt; on &lt;code&gt;#[Route]&lt;/code&gt; to protect every action of the class, or on &lt;code&gt;#[Action]&lt;/code&gt; to protect a single one:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Permissions\IsAdministrator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Action(Method::DELETE, permissionCallback: IsAdministrator::class)]&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&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="n"&gt;\WP_REST_Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only administrators get here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The package ships with &lt;code&gt;IsAdministrator&lt;/code&gt;, &lt;code&gt;IsEditor&lt;/code&gt;, &lt;code&gt;IsAuthor&lt;/code&gt;, &lt;code&gt;IsContributor&lt;/code&gt; and &lt;code&gt;IsSubscriber&lt;/code&gt;. For anything more specific, implement &lt;code&gt;PermissionInterface&lt;/code&gt;. Here is a webhook endpoint that only accepts requests signed with a shared secret:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Permissions\PermissionInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SignaturePermission&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PermissionInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;\WP_Error&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&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="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'signature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_body&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="no"&gt;WEBHOOK_SECRET&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;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Route('app/v1', '/webhooks/payment', permissionCallback: SignaturePermission::class)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentWebhookController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Action(Method::POST)]&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&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;\WP_REST_Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The signature is valid&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The permission lives in its own class: you can reuse it on any route and unit test it on its own. And because it is visible right on top of the class, a code review spots a missing check immediately.&lt;/p&gt;
&lt;h2&gt;
  
  
  Validating input with #[Param]
&lt;/h2&gt;

&lt;p&gt;Let's add a list endpoint with filters. The &lt;code&gt;#[Param]&lt;/code&gt; attribute turns method arguments into the &lt;code&gt;args&lt;/code&gt; schema WordPress already knows how to validate:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Published&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[Route('app/v1', '/documents')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentListController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Action(Method::GET)]&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;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="na"&gt;#[Param(required: true, description: 'Category identifier')]&lt;/span&gt;
        &lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;#[Param]&lt;/span&gt;
        &lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Published&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;#[Param(name: 'per_page')]&lt;/span&gt;
        &lt;span class="n"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$perPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&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="n"&gt;\WP_REST_Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// $status is a Status instance, $perPage comes from ?per_page=&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;From these few lines, the package builds the schema for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The JSON type comes from the PHP type: &lt;code&gt;int&lt;/code&gt; becomes &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt; becomes &lt;code&gt;boolean&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A sanitizer is picked from the PHP type too, such as &lt;code&gt;sanitize_text_field&lt;/code&gt; for strings.&lt;/li&gt;
&lt;li&gt;A backed enum restricts the accepted values to its cases, so &lt;code&gt;?status=archived&lt;/code&gt; is rejected.&lt;/li&gt;
&lt;li&gt;A missing &lt;code&gt;category&lt;/code&gt; returns a &lt;code&gt;400&lt;/code&gt; from WordPress, before your code runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You still write plain PHP, but you get the validation of a hand-written schema. The descriptions even show up in the &lt;code&gt;/wp-json/&lt;/code&gt; index, which helps whoever consumes your API.&lt;/p&gt;
&lt;h2&gt;
  
  
  Errors as exceptions
&lt;/h2&gt;

&lt;p&gt;Our &lt;code&gt;get()&lt;/code&gt; method still returns an empty document when the ID does not exist. Instead of building a &lt;code&gt;WP_Error&lt;/code&gt; by hand, throw an exception:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\Exceptions\NotFoundException&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Action(Method::GET)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="n"&gt;\WP_REST_Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$document&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;$document&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Document'&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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$document&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;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The client receives a &lt;code&gt;404&lt;/code&gt; with a predictable body:&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;"error"&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;"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;"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;"Document 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="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;For business errors, &lt;code&gt;RouteException&lt;/code&gt; lets you choose the code, the HTTP status and extra data:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RouteException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'This document is already published.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;errorCode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'already-published'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;httpStatusCode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&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 what about bugs? Any other exception returns a &lt;code&gt;500&lt;/code&gt; with a generic message, so a database error never leaks into a public response. During development, a debug option shows the real message and the stack trace.&lt;/p&gt;

&lt;p&gt;If your front-end team prefers a standard, the package also ships an &lt;a href="https://www.rfc-editor.org/rfc/rfc7807" rel="noopener noreferrer"&gt;RFC 7807&lt;/a&gt; formatter that returns &lt;code&gt;application/problem+json&lt;/code&gt; responses.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ready for production
&lt;/h2&gt;

&lt;p&gt;Scanning classes on every request has a cost once you have dozens of controllers. Plug in any &lt;a href="https://www.php-fig.org/psr/psr-6/" rel="noopener noreferrer"&gt;PSR-6&lt;/a&gt; cache pool and the routes are discovered once:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\NamespaceRouteLoader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dbout\WpRestApi\RouteLoaderOptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Cache\Adapter\FilesystemAdapter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$options&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;RouteLoaderOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;cache&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;FilesystemAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;WP_CONTENT_DIR&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/cache/rest-api'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;wp_get_environment_type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'development'&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;new&lt;/span&gt; &lt;span class="nc"&gt;NamespaceRouteLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\\Routes\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clear the cache on each deployment, otherwise new routes are ignored.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;debug&lt;/code&gt; off in production. Stack traces are only added when both &lt;code&gt;debug&lt;/code&gt; and &lt;code&gt;WP_DEBUG&lt;/code&gt; are true, as a safety net.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;We started with nested arrays and closures, and ended with plain PHP classes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one controller per resource, discovered automatically through PSR-4;&lt;/li&gt;
&lt;li&gt;typed arguments injected by name, validated with &lt;code&gt;#[Param]&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;permissions declared next to the code they protect;&lt;/li&gt;
&lt;li&gt;errors thrown as exceptions, returned in a consistent format.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, nothing magical happens: every route still goes through &lt;code&gt;register_rest_route()&lt;/code&gt;. You keep the full WordPress REST API, including its index and its authentication, with a structure that holds up as your API grows.&lt;/p&gt;

&lt;p&gt;The package is open source and MIT-licensed. The &lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api/tree/develop/docs" rel="noopener noreferrer"&gt;full documentation&lt;/a&gt; covers every option. If it saves you a few hundred lines, a star on &lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps other developers find it, and issues and pull requests are welcome.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require dbout/wp-module-rest-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/dimitriBouteille" rel="noopener noreferrer"&gt;
        dimitriBouteille
      &lt;/a&gt; / &lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api" rel="noopener noreferrer"&gt;
        wp-module-rest-api
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      🚀 Quickly add routes to the WordPress REST API.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;WordPress Rest API&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api/releases" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/406acc4b5ba7d5004e219c6509b1bc0a26df474ec006770b21aeccc231786267/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f72656c656173652f64696d69747269426f757465696c6c652f77702d6d6f64756c652d726573742d617069" alt="GitHub Release"&gt;&lt;/a&gt; &lt;a href="https://github.com/dimitriBouteille/wp-module-rest-api/actions/workflows/tests.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/52812db06f42f3c98d9c810e60d31fe9b316befd19cc56b7a26d7fb83fa34e5a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f64696d69747269426f757465696c6c652f77702d6d6f64756c652d726573742d6170692f74657374732e796d6c3f6c6162656c3d7465737473" alt="tests"&gt;&lt;/a&gt; &lt;a href="https://packagist.org/packages/dbout/wp-module-rest-api" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/271a6c4bba904d40bdccb3f7efe811bd8a687baa98d0b1a937eebdd9dfa667a9/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f64626f75742f77702d6d6f64756c652d726573742d6170693f636f6c6f723d79656c6c6f77" alt="Packagist Downloads"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Declare your &lt;a href="https://developer.wordpress.org/rest-api/" rel="nofollow noopener noreferrer"&gt;WordPress REST API&lt;/a&gt; routes with &lt;strong&gt;PHP 8 attributes&lt;/strong&gt;, in plain classes, instead of piling up &lt;code&gt;register_rest_route()&lt;/code&gt; calls.&lt;/p&gt;

&lt;div class="highlight highlight-text-html-php notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;#[Route(&lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;app/v1&lt;/span&gt;'&lt;/span&gt;, &lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;/documents/(?P&amp;lt;id&amp;gt;\d+)&lt;/span&gt;'&lt;/span&gt;)]
&lt;span class="pl-k"&gt;class&lt;/span&gt; DocumentController
{
    #[Action(Method::&lt;span class="pl-c1"&gt;GET&lt;/span&gt;)]
    &lt;span class="pl-k"&gt;public&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-smi"&gt;int&lt;/span&gt; &lt;span class="pl-s1"&gt;&lt;span class="pl-c1"&gt;$&lt;/span&gt;id&lt;/span&gt;): &lt;span class="pl-smi"&gt;\&lt;span class="pl-smi"&gt;WP_REST_Response&lt;/span&gt;&lt;/span&gt;
    {
        &lt;span class="pl-c"&gt;// $id comes straight from the URL, already cast to int&lt;/span&gt;
    }
}&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why?&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;With native WordPress, every endpoint means a &lt;code&gt;register_rest_route()&lt;/code&gt; call, a callback that reads &lt;code&gt;$request-&amp;gt;get_param()&lt;/code&gt; by hand, and error handling to write yourself. Once you have more than a few routes, it gets hard to keep organized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;, with native WordPress:&lt;/p&gt;

&lt;div class="highlight highlight-text-html-php notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;add_action&lt;/span&gt;(&lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;rest_api_init&lt;/span&gt;'&lt;/span&gt;, &lt;span class="pl-k"&gt;function&lt;/span&gt; () {
    &lt;span class="pl-en"&gt;register_rest_route&lt;/span&gt;(&lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;app/v1&lt;/span&gt;'&lt;/span&gt;, &lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;/documents/(?P&amp;lt;id&amp;gt;\d+)&lt;/span&gt;'&lt;/span&gt;, [
        [
            &lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;methods&lt;/span&gt;'&lt;/span&gt; =&amp;gt; &lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;GET&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'&lt;span class="pl-s"&gt;callback&lt;/span&gt;'&lt;/span&gt; =&amp;gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; (&lt;span class="pl-smi"&gt;\&lt;span class="pl-smi"&gt;WP_REST_Request&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;&lt;span class="pl-c1"&gt;$&lt;/span&gt;request&lt;/span&gt;) {
                &lt;span class="pl-s1"&gt;&lt;span class="pl-c1"&gt;$&lt;/span&gt;document&lt;/span&gt; = &lt;span class="pl-en"&gt;get_post&lt;/span&gt;((&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/dimitriBouteille/wp-module-rest-api" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>wordpress</category>
      <category>api</category>
      <category>php</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
