<?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: jean-smaug</title>
    <description>The latest articles on DEV Community by jean-smaug (@jeansmaug).</description>
    <link>https://dev.to/jeansmaug</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%2F28646%2F15eea80b-1e57-4ed9-9f5c-99798df435f6.png</url>
      <title>DEV Community: jean-smaug</title>
      <link>https://dev.to/jeansmaug</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeansmaug"/>
    <language>en</language>
    <item>
      <title>I Deleted 5,000 Lines of a Feature That Worked</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/i-deleted-5000-lines-of-a-feature-that-worked-260k</link>
      <guid>https://dev.to/jeansmaug/i-deleted-5000-lines-of-a-feature-that-worked-260k</guid>
      <description>&lt;p&gt;Loopress has one rule: it versions configuration, never content. Field groups, menus, post types, options, code. Not posts, not pages, not the things editors type into wp-admin every day.&lt;/p&gt;

&lt;p&gt;For months, one module broke that rule, and I didn’t notice, because it worked. I found out while writing the docs for a different feature, when the example I picked was a setting the old module was supposed to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SEO module
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lps seo pull&lt;/code&gt; read everything Rank Math or Yoast stored and wrote it as JSON: the site-wide title templates, every post’s SEO meta, every redirect. &lt;code&gt;lps seo push&lt;/code&gt; sent it back. It had a diff, a rollback, unit tests, an end-to-end suite running against both plugins, and a guard refusing to run when both plugins were active at once. It was in the first sentence of the Loopress Light listing on WordPress.org and on the homepage.&lt;/p&gt;

&lt;p&gt;Building it took a few days. The project’s structure was already set, and Claude Code did most of the typing. By every metric I usually look at, it was a finished feature.&lt;/p&gt;

&lt;p&gt;I just never really used it in production. And Loopress is young: as far as I know, nobody else did either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The module that made me look
&lt;/h2&gt;

&lt;p&gt;The more time I spent in the WordPress ecosystem, the more I saw how much of WordPress core already fits Loopress’s rule, with no third-party plugin involved. Options came first: &lt;code&gt;lps option add &amp;lt;name&amp;gt;&lt;/code&gt; tracks a single &lt;code&gt;wp_options&lt;/code&gt; row as a file in Git.&lt;/p&gt;

&lt;p&gt;Its docs needed a realistic example, and the first one that came to mind was &lt;code&gt;wpseo_titles&lt;/code&gt;, Yoast’s settings row. That’s when it clicked. One of the SEO module’s jobs had just become a one-liner of another command. So I went to check what the rest of it was doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually put in Git
&lt;/h2&gt;

&lt;p&gt;I opened the &lt;code&gt;seo/&lt;/code&gt; folder of the site Loopress was born on, my sister’s French tutoring site, and read it file by file instead of trusting the green checkmarks.&lt;/p&gt;

&lt;p&gt;80 files of per-post meta. In all 80, a &lt;code&gt;rank_math_internal_links_processed&lt;/code&gt; flag: an internal marker Rank Math sets for itself. In 72, a &lt;code&gt;rank_math_seo_score&lt;/code&gt;: a number Rank Math computes. In 71, a &lt;code&gt;rank_math_analytic_object_id&lt;/code&gt;: a row id in one database, which means nothing on another environment, and pushing it there quietly breaks the link. Robots directives stored as serialized PHP strings.&lt;/p&gt;

&lt;p&gt;What a human had actually written: 4 descriptions, 1 title, 9 focus keywords. And all of it belonged to a post, which makes it content, edited by whoever edits that post.&lt;/p&gt;

&lt;p&gt;27 redirect files, each carrying a hit counter and two timestamps, so every pull produced a diff nobody had caused, and every push could overwrite live counters on production. Targets were absolute production URLs. Every 410 became a file named after its database row id, &lt;code&gt;N-redirect.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And one &lt;code&gt;settings.json&lt;/code&gt;: the title templates and schema defaults. Real configuration, finally. It was one row in &lt;code&gt;wp_options&lt;/code&gt;, exactly what the &lt;a href="https://docs.loopress.dev/options/" rel="noopener noreferrer"&gt;option module&lt;/a&gt; now tracks. So the only part of the SEO module that respected the rule was a duplicate of the newer command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
lps option add rank-math-options-titles # Rank Math

lps option add wpseo_titles # Yoast SEO

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

&lt;/div&gt;



&lt;p&gt;Everything else was content dressed up as configuration, plus the code needed to make that look safe: an adapter per SEO plugin, raw SQL against Rank Math’s redirect table, a check that redirect targets stay on the site, active-content stripping on every string. Each of those existed because the module was doing something Loopress shouldn’t do in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What removing it cost
&lt;/h2&gt;

&lt;p&gt;It wasn’t free, and pretending otherwise would be dishonest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A headline feature.&lt;/strong&gt; The WordPress.org listing, the homepage and the comparison pages all said “SEO”. Now they don’t.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect versioning.&lt;/strong&gt; Some people will want it. It’s still content: redirects are created by editors in wp-admin, and they belong to the site, not the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone who did use it.&lt;/strong&gt; &lt;code&gt;lps seo&lt;/code&gt; is gone since 0.30.0. Migrating is one command: track the option above, then delete &lt;code&gt;seo/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The removal itself was easy: &lt;a href="https://github.com/loopress/loopress/pull/307" rel="noopener noreferrer"&gt;one pull request&lt;/a&gt;, 110 files touched, close to 5,000 lines deleted for 248 added. Not one folder, though. A headline feature spreads into the plugin, the CLI, the MCP server, the docs, the homepage and the end-to-end tests. Easy to remove is not the same as contained.&lt;/p&gt;

&lt;p&gt;What it gave back: a plugin that no longer reads or writes another plugin’s database tables, one less thing to explain on the homepage, and a rule that is true again, with no “except SEO” footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I want to remember
&lt;/h2&gt;

&lt;p&gt;Nothing was broken. No bug report, no failing test, no angry user, and no daily use of my own that could have shown me. A feature that is clearly wrong gets removed eventually. A feature that works, is tested, and doesn’t fit the product’s rule can stay forever, because it never gives you a reason to look at it.&lt;/p&gt;

&lt;p&gt;With an AI agent, building a feature like this takes days, not weeks. That makes the build the cheap part. The expensive part is deciding whether a feature belongs, and a working, tested module makes that question easy to skip.&lt;/p&gt;

&lt;p&gt;What finally got me to look was a newer feature overlapping it, and then opening the real files on a real site. Not the fixtures, not the tests: the folder a user would actually commit. If most of what’s in there wasn’t written by a person, it isn’t configuration, and it doesn’t belong in Git.&lt;/p&gt;

&lt;p&gt;The modules built around the removal all come from WordPress core: post types, taxonomies, post meta, user roles, block patterns. I can’t say what Loopress will need next. I do have a much sharper idea of what it is, and what it isn’t.&lt;/p&gt;




&lt;p&gt;Tracking SEO plugin settings is covered in the &lt;a href="https://docs.loopress.dev/options/#seo-plugin-settings" rel="noopener noreferrer"&gt;Options reference&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>product</category>
      <category>story</category>
      <category>configuration</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>When a WordPress REST Route Shouldn't Return JSON</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/when-a-wordpress-rest-route-shouldnt-return-json-g63</link>
      <guid>https://dev.to/jeansmaug/when-a-wordpress-rest-route-shouldnt-return-json-g63</guid>
      <description>&lt;p&gt;It started with a student on my sister’s tutoring site asking for the blog posts as PDFs. A route that renders a post to PDF is a few lines with Dompdf. Then I started imagining the next ones: an Excel export of orders, a QR code for an event ticket, a calendar feed, a Markdown version of a post for an AI crawler. None of them wanted to answer in JSON.&lt;/p&gt;

&lt;p&gt;The WordPress REST API is built around JSON. Return an array from a callback, WordPress serializes it, sets &lt;code&gt;Content-Type: application/json&lt;/code&gt;, done. It’s such a good default that it becomes the only answer, and then you find yourself base64-encoding a PDF into a JSON field so a browser can… decode it back into a PDF.&lt;/p&gt;

&lt;p&gt;The question that decides the response shape isn’t “what does the route produce”. It’s &lt;strong&gt;who calls it, and what do they do with the answer&lt;/strong&gt;. Across the &lt;a href="https://docs.loopress.dev/cookbook/" rel="noopener noreferrer"&gt;cookbook&lt;/a&gt; recipes I’ve built with &lt;a href="https://docs.loopress.dev/api/routes/" rel="noopener noreferrer"&gt;custom API routes&lt;/a&gt;, four shapes cover everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 1: JSON, for code that reads fields
&lt;/h2&gt;

&lt;p&gt;A frontend &lt;code&gt;fetch()&lt;/code&gt;, a mobile app, a CI script: the caller parses the response and picks fields out of it. That’s what JSON is for, and what a route gives you by returning an array or a &lt;code&gt;WP_REST_Response&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="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;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;array&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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;482&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="s1"&gt;'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;Nothing to decide here. It’s the default because it’s the right answer most of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 2: a file inside JSON, as a data URI
&lt;/h2&gt;

&lt;p&gt;Sometimes the caller is still code, but what it wants is a small image to drop into the page: a resized thumbnail, a ticket’s QR code. The route can put the image in a JSON field as a &lt;code&gt;data:&lt;/code&gt; URI:&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;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;'data_uri'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'data:image/png;base64,'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getString&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 frontend sets it straight as &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;, no second request, and the JSON envelope can carry other fields next to it (dimensions, an expiry). I use this for &lt;a href="https://docs.loopress.dev/cookbook/documents-and-files/qr-code-generation-wordpress-rest-api/" rel="noopener noreferrer"&gt;ticket QR codes&lt;/a&gt; and on-the-fly image resizing.&lt;/p&gt;

&lt;p&gt;The limits are real, so keep it for small files. Base64 makes the payload a third bigger than the raw bytes. The browser can’t cache the image on its own, only the whole JSON response. And a human can’t open the URL and see the image, they see a wall of base64. My QR code is a 519-byte PNG, 737 bytes as JSON: fine. A 2 MB photo as JSON is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 3: raw bytes, with your own headers
&lt;/h2&gt;

&lt;p&gt;When the caller isn’t your code at all, JSON is simply wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A browser navigating to the URL&lt;/strong&gt; , from a “Download as PDF” link: it needs &lt;code&gt;Content-Type: application/pdf&lt;/code&gt; and a &lt;code&gt;Content-Disposition&lt;/code&gt; header, or it shows gibberish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A calendar app subscribed to a feed&lt;/strong&gt; : it expects &lt;code&gt;text/calendar&lt;/code&gt;, and never sends anything WordPress could negotiate on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl -o report.xlsx&lt;/code&gt;&lt;/strong&gt; in someone’s cron job: it writes whatever bytes arrive to disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An AI crawler or an &lt;code&gt;llms.txt&lt;/code&gt; consumer&lt;/strong&gt; : it wants &lt;code&gt;text/markdown&lt;/code&gt;, not HTML in a JSON string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A route verb method doesn’t have to return anything. It can send its own headers, echo the body, and stop:&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;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;void&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// ... build $xlsxBytes&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;'Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'&lt;/span&gt;&lt;span class="p"&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;'Content-Disposition: attachment; filename="orders.xlsx"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$xlsxBytes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s the whole trick, documented as &lt;a href="https://docs.loopress.dev/api/routes/#streaming-a-file-instead-of-json" rel="noopener noreferrer"&gt;streaming a file instead of JSON&lt;/a&gt;. The &lt;a href="https://docs.loopress.dev/cookbook/documents-and-files/post-to-pdf-dompdf-wordpress-rest-api/" rel="noopener noreferrer"&gt;post to PDF&lt;/a&gt; recipe, the one the student asked for, works this way, and so does &lt;a href="https://docs.loopress.dev/cookbook/documents-and-files/spreadsheet-export-phpspreadsheet-wordpress-rest-api/" rel="noopener noreferrer"&gt;WooCommerce orders to Excel&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape 4: a redirect, when the file already exists
&lt;/h2&gt;

&lt;p&gt;That 2 MB photo, or a PDF already sitting in the media library, doesn’t need to travel through PHP at all. The route decides &lt;em&gt;which&lt;/em&gt; file the caller gets, checks they’re allowed to have it, and points them at 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="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;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="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;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_attachment_url&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;'attachment_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;$url&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;'No such file.'&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Location'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&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 web server sends the bytes, with its own caching and range requests, and the route stays a normal REST response: no &lt;code&gt;exit&lt;/code&gt;, nothing skipped. Keep the &lt;code&gt;WP_Error&lt;/code&gt;: without it, an unknown id answers a 302 with an empty &lt;code&gt;Location&lt;/code&gt;, which I only caught by calling the route on a real site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know before shipping one
&lt;/h2&gt;

&lt;p&gt;Checked on a real site, and in how Loopress loads route files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Errors can still be JSON.&lt;/strong&gt; Return a &lt;code&gt;WP_Error&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the headers go out and WordPress answers it like any other REST error, &lt;code&gt;{"code":"not_found",...,"data":{"status":404}}&lt;/code&gt;. Only the success path is raw. A caller that gets a 404 shouldn’t have to parse a PDF to find out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A throw is a JSON 500, not a fatal.&lt;/strong&gt; An uncaught exception in a verb method, raised before your headers go out, comes back as &lt;code&gt;{"error":"Internal error"}&lt;/code&gt; with a 500 status, and the rest of the site keeps working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions work the same.&lt;/strong&gt; &lt;code&gt;permission()&lt;/code&gt; and &lt;code&gt;#[Permission]&lt;/code&gt; are checked before the verb method ever runs, raw output or not. My Excel export is admin-only, the curl in its video authenticates with an Application Password like any other REST call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress’s own REST headers are already there.&lt;/strong&gt; A raw response still carries &lt;code&gt;Access-Control-Expose-Headers&lt;/code&gt; and the API &lt;code&gt;Link&lt;/code&gt; header, WordPress sends those before your method runs. What &lt;code&gt;exit&lt;/code&gt; skips is everything after it: the JSON serialization, and the &lt;code&gt;rest_post_dispatch&lt;/code&gt; filters other plugins might hook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A route-wide &lt;code&gt;headers()&lt;/code&gt; method doesn’t run on a raw response.&lt;/strong&gt; Loopress applies &lt;a href="https://docs.loopress.dev/api/routes/#route-wide-headers-including-the-options-preflight" rel="noopener noreferrer"&gt;&lt;code&gt;headers()&lt;/code&gt;&lt;/a&gt; when WordPress serves the response, which is after your verb method returns. A method that calls &lt;code&gt;exit&lt;/code&gt; never returns, so a CORS header declared there won’t be on the download. Send it with &lt;code&gt;header()&lt;/code&gt; inside the verb method itself, next to &lt;code&gt;Content-Type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A raw route doesn’t belong in the discovery index.&lt;/strong&gt; &lt;code&gt;/wp-json/{namespace}&lt;/code&gt; lists every route as a REST resource with its methods and args, which says nothing useful about an endpoint that answers with a PDF or a calendar feed. &lt;a href="https://docs.loopress.dev/api/routes/#hiding-a-route-from-the-discovery-index" rel="noopener noreferrer"&gt;&lt;code&gt;#[Hidden]&lt;/code&gt;&lt;/a&gt; on the class keeps the route out of that index, and it keeps dispatching exactly as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Choosing, in one line each
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The caller&lt;/th&gt;
&lt;th&gt;The shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your code, reading fields&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your code, showing a small image&lt;/td&gt;
&lt;td&gt;data URI in JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A browser link, a download, &lt;code&gt;curl -o&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;raw bytes + &lt;code&gt;Content-Disposition&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A calendar app, a feed reader, a crawler&lt;/td&gt;
&lt;td&gt;raw bytes + the format’s own &lt;code&gt;Content-Type&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anyone, for a file that already exists&lt;/td&gt;
&lt;td&gt;a 302 to the file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake isn’t picking the wrong one of these. It’s never asking the question, and shipping JSON to a calendar app because that’s what the REST API does by default.&lt;/p&gt;




&lt;p&gt;Try it on your own site: one PHP file in &lt;code&gt;api/&lt;/code&gt;, one &lt;code&gt;lps api push&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @loopress/cli

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

&lt;/div&gt;



&lt;p&gt;Custom API routes and Composer dependency management are &lt;a href="https://docs.loopress.dev/wordpress-plugin/" rel="noopener noreferrer"&gt;Loopress Full&lt;/a&gt; features, free. &lt;a href="https://docs.loopress.dev/api/routes/" rel="noopener noreferrer"&gt;Writing Route Files&lt;/a&gt; covers the rest: dynamic segments, verbs, permissions, CORS.&lt;/p&gt;

</description>
      <category>restapi</category>
      <category>wordpress</category>
      <category>php</category>
    </item>
    <item>
      <title>Astra vs Twenty Twenty-Five: 34% Fewer Queries on a Real Site</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Wed, 30 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/astra-vs-twenty-twenty-five-34-fewer-queries-on-a-real-site-1da4</link>
      <guid>https://dev.to/jeansmaug/astra-vs-twenty-twenty-five-34-fewer-queries-on-a-real-site-1da4</guid>
      <description>&lt;p&gt;&lt;a href="https://nativefrenchteacher.com" rel="noopener noreferrer"&gt;Native French Teacher&lt;/a&gt; is my sister’s online French tutoring business, and the site &lt;a href="https://docs.loopress.dev/blog/why-i-built-loopress/" rel="noopener noreferrer"&gt;Loopress started from&lt;/a&gt;. It launched last year on &lt;strong&gt;Astra&lt;/strong&gt; with the Spectra blocks plugin. I recently rebuilt it on &lt;strong&gt;Twenty Twenty-Five&lt;/strong&gt; , the default block theme, with a new design along the way, using Loopress to manage the pages, templates and snippets from a Git repo.&lt;/p&gt;

&lt;p&gt;I didn’t do it for speed. I did it so the site would live in a repository. But I had Query Monitor open before and after, so here is what it measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Astra was the right call, at first
&lt;/h2&gt;

&lt;p&gt;Let’s be fair to Astra. When you launch a site for a non-technical person, it’s hard to beat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;header and footer builder&lt;/strong&gt; where you drag a logo, a menu and a button into place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global colors and typography&lt;/strong&gt; in the Customizer, with a live preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-page layout options&lt;/strong&gt; : hide the title, go full width, remove the sidebar, from a panel in the editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter templates&lt;/strong&gt; and Spectra’s blocks, so a decent-looking page takes minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty Twenty-Five on its own has none of that polish. It’s a clean block theme with a Site Editor, and the Site Editor saves everything in the database, just like Astra’s Customizer does. Swap one for the other and you’ve traded features for speed, but you still can’t answer “who changed the header, and what did it look like before?”&lt;/p&gt;

&lt;p&gt;That’s the gap Loopress fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Same Hostinger server, same PHP, same plugin stack apart from the theme and Spectra.&lt;/li&gt;
&lt;li&gt;Not the same pages: the rebuild came with a redesign. That matters most for what the browser downloads (images, fonts, layout), much less for the server work, which is mostly WordPress, the theme and the plugins.&lt;/li&gt;
&lt;li&gt;Homepage only, logged in as admin (Query Monitor needs that, so the admin bar is included in both runs).&lt;/li&gt;
&lt;li&gt;Several page loads per state, keeping the &lt;strong&gt;median&lt;/strong&gt; : &lt;strong&gt;cold&lt;/strong&gt; loads (first request after a while, OPcache and internal caches not warm) and &lt;strong&gt;warm&lt;/strong&gt; loads (right after). The screenshots below are the loads closest to that median.&lt;/li&gt;
&lt;li&gt;No page cache and no persistent object cache in either case. The site runs LiteSpeed Cache, but it doesn’t serve logged-in users, so these are the numbers a logged-in visitor actually gets. The query count barely moves between cold and warm, so the difference between the two is PHP warm-up, not a cache skipping work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s a live site, not a lab, so small differences are noise. The large ones (queries, scripts, autoload) are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The homepage, side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Astra&lt;/th&gt;
&lt;th&gt;Twenty Twenty-Five&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generation time, cold&lt;/td&gt;
&lt;td&gt;2.61 s&lt;/td&gt;
&lt;td&gt;1.92 s&lt;/td&gt;
&lt;td&gt;-26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation time, warm&lt;/td&gt;
&lt;td&gt;0.69 s&lt;/td&gt;
&lt;td&gt;0.50 s&lt;/td&gt;
&lt;td&gt;-28%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory, cold&lt;/td&gt;
&lt;td&gt;78.5 MB&lt;/td&gt;
&lt;td&gt;65.7 MB&lt;/td&gt;
&lt;td&gt;-16%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory, warm&lt;/td&gt;
&lt;td&gt;59.5 MB&lt;/td&gt;
&lt;td&gt;47.9 MB&lt;/td&gt;
&lt;td&gt;-19%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database queries, cold&lt;/td&gt;
&lt;td&gt;274&lt;/td&gt;
&lt;td&gt;182&lt;/td&gt;
&lt;td&gt;-34%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time in database, cold&lt;/td&gt;
&lt;td&gt;55 ms&lt;/td&gt;
&lt;td&gt;38 ms&lt;/td&gt;
&lt;td&gt;-31%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueued scripts&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;-62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueued styles&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;-6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object cache hits&lt;/td&gt;
&lt;td&gt;6,489&lt;/td&gt;
&lt;td&gt;4,137&lt;/td&gt;
&lt;td&gt;-36%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-overview-cold.DPr06upo_XAXhC.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-overview-cold.DPr06upo_XAXhC.webp" alt="Query Monitor overview with Astra, cold load: 2.61 s, 78.5 MB, 274 queries" width="800" height="362"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Astra, cold load: 2.61 s, 274 queries, 65 scripts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-overview-cold.BSiPkedR_VVbwE.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-overview-cold.BSiPkedR_VVbwE.webp" alt="Query Monitor overview with Twenty Twenty-Five, cold load: 1.92 s, 65.7 MB, 182 queries" width="799" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Twenty Twenty-Five, cold load: 1.92 s, 182 queries, 25 scripts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-overview-warm.BXASRQ79_ZNFFaW.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-overview-warm.BXASRQ79_ZNFFaW.webp" alt="Query Monitor overview with Astra, warm load: 0.69 s, 59.5 MB, 282 queries" width="800" height="362"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Astra, warm load: 0.69 s. The query count barely moves, so no cache is skipping work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-overview-warm.DFYsphEW_1GN2tB.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-overview-warm.DFYsphEW_1GN2tB.webp" alt="Query Monitor overview with Twenty Twenty-Five, warm load: 0.50 s, 47.9 MB, 182 queries" width="799" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Twenty Twenty-Five, warm load: 0.50 s, same 182 queries.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The biggest single number is scripts: &lt;strong&gt;65 down to 25&lt;/strong&gt;. That’s mostly Astra and Spectra’s own scripts no longer being enqueued. Styles barely moved, because most of them come from plugins (forms, booking, quiz), not from the theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the time went
&lt;/h2&gt;

&lt;p&gt;Query Monitor’s timeline shows how long each WordPress hook took on a warm load.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Astra&lt;/th&gt;
&lt;th&gt;Twenty Twenty-Five&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plugins_loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;141 ms&lt;/td&gt;
&lt;td&gt;138 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;after_setup_theme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;28 ms&lt;/td&gt;
&lt;td&gt;26 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;init&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;91 ms&lt;/td&gt;
&lt;td&gt;78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32 ms&lt;/td&gt;
&lt;td&gt;6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;template_redirect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16 ms&lt;/td&gt;
&lt;td&gt;51 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_head&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;81 ms&lt;/td&gt;
&lt;td&gt;31 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-timeline.DxR2rim__1NkOQ9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-timeline.DxR2rim__1NkOQ9.webp" alt="Query Monitor timeline with Astra" width="800" height="437"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Astra: wp_head takes 81 ms.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-timeline.CTnyYe9l_Z1d6Lh5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-timeline.CTnyYe9l_Z1d6Lh5.webp" alt="Query Monitor timeline with Twenty Twenty-Five" width="800" height="437"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Twenty Twenty-Five: wp_head down to 31 ms, template_redirect up to 51 ms, plugins_loaded unchanged.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wp_head&lt;/code&gt; dropped from 81 ms to 31 ms.&lt;/strong&gt; Most likely Astra’s dynamic CSS and inline configuration, built at runtime on every request. A block theme ships its styles from &lt;code&gt;theme.json&lt;/code&gt;, and WordPress only outputs the CSS for blocks actually on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;template_redirect&lt;/code&gt; went up, from 16 ms to 51 ms.&lt;/strong&gt; Not everything got faster. Something hooked there now does more work, and it’s on my list to look at with the per-component view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;plugins_loaded&lt;/code&gt; didn’t move.&lt;/strong&gt; At 140 ms it’s the largest single chunk of the request, and changing the theme can’t touch it. The remaining plugin stack is now the thing to optimize, not the theme.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The database got lighter too
&lt;/h2&gt;

&lt;p&gt;Every WordPress request loads all &lt;em&gt;autoloaded&lt;/em&gt; options into memory, whether the page needs them or not. Astra and Spectra stored a lot of their settings that way.&lt;/p&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;Astra&lt;/th&gt;
&lt;th&gt;Twenty Twenty-Five&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Autoloaded options size&lt;/td&gt;
&lt;td&gt;1,013 KB (946 options)&lt;/td&gt;
&lt;td&gt;519 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Big autoloaded options&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database size&lt;/td&gt;
&lt;td&gt;176 MB&lt;/td&gt;
&lt;td&gt;160 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site Health&lt;/td&gt;
&lt;td&gt;Should be improved, 1 critical issue&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-autoload.C_WuZsDL_1He386.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-autoload.C_WuZsDL_1He386.webp" alt="Advanced DB Cleaner with Astra: autoload size is big, 1012.89 KB, 3 big options" width="799" height="359"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Astra: 1,013 KB of options loaded on every request, 3 of them flagged as big.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-autoload.Bv3SrOT4_ZeIExQ.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-autoload.Bv3SrOT4_ZeIExQ.webp" alt="Advanced DB Cleaner with Twenty Twenty-Five: autoload size is good, 518.54 KB, 0 big options" width="800" height="362"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Twenty Twenty-Five: autoload down to 519 KB, no big options left.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Removing the theme and its companion plugin, then cleaning up what they left behind, &lt;strong&gt;halved the autoloaded data&lt;/strong&gt;. That was also the only critical issue in Site Health (“Autoloaded options could affect performance”), and it’s gone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-site-health.BlQzbWM6_ZEjQHw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Fastra-site-health.BlQzbWM6_ZEjQHw.webp" alt="Site Health with Astra: should be improved, autoloaded options critical issue" width="799" height="439"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Astra: the autoloaded options are the one critical issue in Site Health.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-site-health.Bd1bCD0W_2vQOlM.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdocs.loopress.dev%2F_astro%2Ftt5-site-health.Bd1bCD0W_2vQOlM.webp" alt="Site Health with Twenty Twenty-Five: good" width="800" height="398"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Twenty Twenty-Five: Site Health is back to Good.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about visitors?
&lt;/h2&gt;

&lt;p&gt;I also ran PageSpeed Insights before and after, and it showed no major change. That’s not surprising: the site sits behind LiteSpeed Cache, and for visitors that’s the real game changer. Most pages are served straight from the cache, so the PHP work measured above rarely runs for them.&lt;/p&gt;

&lt;p&gt;So why care about server time at all? Because not every request can be cached:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logged-in users.&lt;/strong&gt; A page cache only works for anonymous visitors. Once someone is logged in, every page they open is rendered by PHP, theme included, which is exactly what the numbers above measure. The site is going to have more logged-in users, so a theme that does less work per page is worth more every month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache purges.&lt;/strong&gt; Every purge, including the one after each deploy, means the next visitor gets an uncached page. PHP is usually still warm at that point, so that’s the warm render: 0.69 s on Astra, 0.50 s now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom API routes.&lt;/strong&gt; With Loopress you can &lt;a href="https://docs.loopress.dev/api/" rel="noopener noreferrer"&gt;add API routes&lt;/a&gt; to WordPress, and the ones that return personal or changing data can’t be cached. They don’t render a template, but they still boot WordPress, autoloaded options and all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache hides the difference for anonymous visitors today. The lighter stack pays off for everything the cache can’t cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty Twenty-Five alone is fast. With Loopress, it’s also manageable
&lt;/h2&gt;

&lt;p&gt;The page is about 30% faster on the server, uses a fifth less memory, runs a third fewer queries and ships 40 fewer scripts. None of it came from a caching plugin or a server upgrade: it came from removing layers.&lt;/p&gt;

&lt;p&gt;But a lean theme is only half the story. What made me comfortable dropping Astra is that everything it gave me through panels, Loopress gives me through files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What Astra gave me&lt;/th&gt;
&lt;th&gt;Twenty Twenty-Five + Loopress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Header and footer builder&lt;/td&gt;
&lt;td&gt;Template parts as block markup in &lt;code&gt;parts/&lt;/code&gt;, pushed into a child theme with &lt;a href="https://docs.loopress.dev/pages/#templates-and-parts" rel="noopener noreferrer"&gt;&lt;code&gt;lps template push&lt;/code&gt;&lt;/a&gt;. The parent theme stays untouched and keeps its updates.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-page layout options&lt;/td&gt;
&lt;td&gt;Custom page templates in &lt;code&gt;templates/&lt;/code&gt;, picked per page with a &lt;code&gt;template:&lt;/code&gt; header. A bare landing page is one file.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starter templates and Spectra blocks&lt;/td&gt;
&lt;td&gt;Pages written as &lt;a href="https://docs.loopress.dev/pages/" rel="noopener noreferrer"&gt;plain HTML files&lt;/a&gt; in the repo, rendered by the theme, locked in wp-admin so Git stays the source of truth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom code in &lt;code&gt;functions.php&lt;/code&gt; or a snippet plugin&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.loopress.dev/hooks/" rel="noopener noreferrer"&gt;Hooks&lt;/a&gt; and &lt;a href="https://docs.loopress.dev/snippets/" rel="noopener noreferrer"&gt;snippets&lt;/a&gt; as PHP files in Git.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That split doesn’t take the editor away from my sister. She still writes her blog posts in the WordPress admin, and they live in the database like any other content. What moved to Git is the structure around them: templates, landing pages and code. She now also edits the site from Claude Desktop.&lt;/p&gt;

&lt;p&gt;Astra’s features are good, but they live in the database, next to the content, invisible to Git. On Twenty Twenty-Five with Loopress, the same decisions are files: I review them, diff them, push them to staging first, and &lt;a href="https://docs.loopress.dev/blog/undo-a-wordpress-deploy/" rel="noopener noreferrer"&gt;roll them back&lt;/a&gt; when something goes wrong. The server does less work &lt;em&gt;and&lt;/em&gt; the design got a history.&lt;/p&gt;

&lt;p&gt;That’s the combo I’d pick for the next site: the default block theme for the performance, Loopress for everything Astra used to do in a panel.&lt;/p&gt;

&lt;p&gt;Still to do: install a persistent object cache (Redis is on the server, just not wired up) and look at that &lt;code&gt;template_redirect&lt;/code&gt; regression.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://docs.loopress.dev/wordpress-plugin/" rel="noopener noreferrer"&gt;Get started with the plugin&lt;/a&gt; or &lt;a href="https://docs.loopress.dev/cli/" rel="noopener noreferrer"&gt;the CLI&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>blockthemes</category>
      <category>querymonitor</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Your WordPress Hooks Belong in Git, Not functions.php</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/your-wordpress-hooks-belong-in-git-not-functionsphp-5ei2</link>
      <guid>https://dev.to/jeansmaug/your-wordpress-hooks-belong-in-git-not-functionsphp-5ei2</guid>
      <description>&lt;p&gt;You want every blog post to end with a newsletter box. The code is four lines:&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;'the_content'&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="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$content&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;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_singular&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="p"&gt;{&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$content&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;$content&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;do_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[newsletter_form]'&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;Writing it took a minute. The question that eats the rest of the hour: where does it go, and how does it get to staging and production without someone pasting it into an admin textarea?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where hook code lives today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;functions.php&lt;/code&gt;.&lt;/strong&gt; Fastest to write, tied to your theme. Switch themes, lose the hook. A year later it’s 900 lines of unrelated callbacks nobody wants to touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A site-specific plugin.&lt;/strong&gt; The clean answer, and now your four lines come wrapped in a plugin header, and you still need a way to get that plugin onto each environment and know which version runs where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A snippets plugin.&lt;/strong&gt; Live in seconds, versioned never. The callback sits in the database, edited through a textarea, invisible to &lt;code&gt;git log&lt;/code&gt;, invisible to code review. Someone tweaks the priority, something downstream breaks, and there is no diff to point at.&lt;/p&gt;

&lt;p&gt;The hook logic is never the hard part. The delivery is.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file, one class
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://docs.loopress.dev/wordpress-plugin/" rel="noopener noreferrer"&gt;Loopress Full&lt;/a&gt; installed, a hook is a plain PHP file in your repo’s &lt;code&gt;hooks/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;
&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Loopress\Hooks\Attribute\Filter&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;ContentHooks&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="na"&gt;#[Filter('the_content', priority: 20)]&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;appendNewsletter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$content&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;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_singular&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="p"&gt;{&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$content&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;$content&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;do_shortcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[newsletter_form]'&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;Save it as &lt;code&gt;hooks/content-hooks.php&lt;/code&gt; and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps hook push

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

&lt;/div&gt;



&lt;p&gt;That is the whole thing. No plugin header, no &lt;code&gt;add_filter&lt;/code&gt; call, no registration array. One class per file, named however you like (the name is read from the file’s own tokens, not its filename), and any public method carrying an &lt;code&gt;#[Action]&lt;/code&gt;, &lt;code&gt;#[Filter]&lt;/code&gt;, or &lt;code&gt;#[Cron]&lt;/code&gt; attribute gets wired up. &lt;code&gt;priority&lt;/code&gt; and &lt;code&gt;acceptedArgs&lt;/code&gt; map straight onto the arguments you already know from &lt;a href="https://developer.wordpress.org/reference/functions/add_filter/" rel="noopener noreferrer"&gt;&lt;code&gt;add_filter()&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;#[Filter]&lt;/code&gt; method returns the value it receives, modified or not. An &lt;code&gt;#[Action]&lt;/code&gt; returns nothing:&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;Loopress\Hooks\Attribute\Action&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;CacheHooks&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="na"&gt;#[Action('save_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;purgeOnSave&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;$postId&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;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://cdn.example.com/purge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;

            &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&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 single class can carry as many of these as it wants, actions and filters mixed freely. Group them by what they touch, not by hook type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron, without wp-config surgery
&lt;/h2&gt;

&lt;p&gt;The same file format runs scheduled work. &lt;code&gt;#[Cron]&lt;/code&gt; takes a WordPress recurrence name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;
&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Loopress\Hooks\Attribute\Cron&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;InvoiceCleanup&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="na"&gt;#[Cron('daily')]&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;purgeExpired&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;// delete invoice drafts older than 30 days&lt;/span&gt;

    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;recurrence&lt;/code&gt; is any schedule WordPress knows: the built-in &lt;code&gt;hourly&lt;/code&gt;, &lt;code&gt;twicedaily&lt;/code&gt;, &lt;code&gt;daily&lt;/code&gt;, or a custom one registered through the &lt;a href="https://developer.wordpress.org/reference/hooks/cron_schedules/" rel="noopener noreferrer"&gt;&lt;code&gt;cron_schedules&lt;/code&gt;&lt;/a&gt; filter. The event is scheduled the first time the method is seen and left alone after that, so pushing the same file twice does not stack duplicate jobs.&lt;/p&gt;

&lt;p&gt;One sharp edge worth knowing up front: changing &lt;code&gt;#[Cron('daily')]&lt;/code&gt; to &lt;code&gt;#[Cron('hourly')]&lt;/code&gt; later does not reschedule the running event. Clear it yourself with &lt;a href="https://developer.wordpress.org/reference/functions/wp_clear_scheduled_hook/" rel="noopener noreferrer"&gt;&lt;code&gt;wp_clear_scheduled_hook()&lt;/code&gt;&lt;/a&gt; if you need the new interval to take effect now. If another part of your code needs to trigger the same job on demand, name the action yourself and &lt;code&gt;do_action()&lt;/code&gt; 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="na"&gt;#[Cron('daily', hook: 'acme_invoice_cleanup')]&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;purgeExpired&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Built to not break your site
&lt;/h2&gt;

&lt;p&gt;A hook has no URL and no permission check of its own. Once pushed it runs automatically every time WordPress fires the hook it is bound to, from &lt;code&gt;init&lt;/code&gt; on every page load to &lt;code&gt;save_post&lt;/code&gt; on every save, with nothing gating it. A mistake here affects everything that hits that hook, not one endpoint. Loopress keeps that contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lps hook push&lt;/code&gt; syntax-checks every file server-side and rejects broken PHP with the actual parse error, before anything is written. It also enforces one &lt;code&gt;declare(strict_types=1);&lt;/code&gt;, exactly one class, and no class-name collision with another plugin.&lt;/li&gt;
&lt;li&gt;If a hook method throws at runtime, it is caught and logged rather than left to white-screen the site. An &lt;code&gt;#[Action]&lt;/code&gt; simply does not run that time. A &lt;code&gt;#[Filter]&lt;/code&gt; falls back to the original, unfiltered value.&lt;/li&gt;
&lt;li&gt;Files are protected from direct HTTP access. Pull them back and you get exactly the source you pushed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because these files call WordPress functions from a repo where WordPress is not installed, set up &lt;a href="https://docs.loopress.dev/editor-setup/" rel="noopener noreferrer"&gt;editor stubs&lt;/a&gt; once for autocomplete and static analysis. The same page covers the &lt;a href="https://docs.loopress.dev/editor-setup/#loopress-attribute-classes" rel="noopener noreferrer"&gt;&lt;code&gt;loopress/php-attributes&lt;/code&gt;&lt;/a&gt; Composer package, which resolves the &lt;code&gt;#[Action]&lt;/code&gt;, &lt;code&gt;#[Filter]&lt;/code&gt;, and &lt;code&gt;#[Cron]&lt;/code&gt; classes your IDE is about to ask about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks your repository declares
&lt;/h2&gt;

&lt;p&gt;Because hooks are plain files, the workflow is the one you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps hook pull &lt;span class="c"&gt;# mirror what's on the site locally&lt;/span&gt;

git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; add-newsletter-box &lt;span class="c"&gt;# branch, edit, open a PR&lt;/span&gt;

lps hook push &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="c"&gt;# see exactly what would change&lt;/span&gt;

lps hook push &lt;span class="c"&gt;# deploy&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lps hook list&lt;/code&gt; prints what is live on a site. &lt;code&gt;lps hook diff&lt;/code&gt; shows what differs between your local files and an environment, or between two environments, and it rolls up into the aggregate &lt;code&gt;lps diff --only hook&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Across environments it is the same command with &lt;code&gt;--env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps hook push &lt;span class="nt"&gt;--env&lt;/span&gt; staging

&lt;span class="c"&gt;# looks right in staging&lt;/span&gt;

lps hook push &lt;span class="nt"&gt;--env&lt;/span&gt; production &lt;span class="nt"&gt;--yes&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--yes&lt;/code&gt; is required outside a terminal because pushing to an environment named &lt;code&gt;production&lt;/code&gt; asks for confirmation first. A local &lt;code&gt;.php&lt;/code&gt; file whose hook no longer exists on the site is removed on the next &lt;code&gt;pull&lt;/code&gt;, so the directory stays an honest mirror.&lt;/p&gt;

&lt;p&gt;Hooks stop being something you wire up on a site and become something your repository declares.&lt;/p&gt;




&lt;p&gt;Hooks ship with Loopress Full, the free full edition of the plugin. Loopress Light does not include them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @loopress/cli

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

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.loopress.dev/hooks/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers the full attribute reference, the cron scheduling model, and the push validation rules.&lt;/p&gt;

</description>
      <category>hooks</category>
      <category>wordpress</category>
      <category>cron</category>
      <category>git</category>
    </item>
    <item>
      <title>How Loopress Light Got Into the WordPress.org Directory</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/how-loopress-light-got-into-the-wordpressorg-directory-2doi</link>
      <guid>https://dev.to/jeansmaug/how-loopress-light-got-into-the-wordpressorg-directory-2doi</guid>
      <description>&lt;p&gt;Loopress Light is now on the &lt;a href="https://wordpress.org/plugins/loopress-light/" rel="noopener noreferrer"&gt;wordpress.org plugin directory&lt;/a&gt;. You install it from &lt;strong&gt;Plugins, Add New&lt;/strong&gt; in any WordPress admin, and it updates itself from there.&lt;/p&gt;

&lt;p&gt;Getting it accepted took an initial rejection, a failed appeal, and removing the feature the plugin was first built around. The &lt;a href="https://docs.loopress.dev/blog/wordpress-org-composer-rejection/" rel="noopener noreferrer"&gt;Composer side of that story is here&lt;/a&gt;. This is the snippet sync half, mostly in the review team’s own words.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first no
&lt;/h2&gt;

&lt;p&gt;We submitted Loopress Light on 16 July 2026. That version carried code snippet sync: the CLI could push and pull &lt;a href="https://wordpress.org/plugins/code-snippets/" rel="noopener noreferrer"&gt;Code Snippets&lt;/a&gt; and &lt;a href="https://wordpress.org/plugins/wpcode/" rel="noopener noreferrer"&gt;WPCode&lt;/a&gt; entries over a REST API, so snippets could live in Git alongside the rest of a project.&lt;/p&gt;

&lt;p&gt;The rejection came back within a day, and it was categorical:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your plugin has been rejected because we are not accepting plugins of this nature in most cases.&lt;/p&gt;

&lt;p&gt;Script insertion plugins are amazing and powerful. They’re also incredibly dangerous and require a high level understanding of sanitization, security, and usage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The directory rejects this whole category by default. Not this implementation, the category.&lt;/p&gt;

&lt;h2&gt;
  
  
  The appeal, and why it lost
&lt;/h2&gt;

&lt;p&gt;We wrote back the next day, arguing the mechanism was narrower than “an API that inserts scripts”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Loopress Light never executes snippet code. It moves a snippet’s fields (name, code, type, location) into Code Snippets or WPCode through that plugin’s own API. Storage, sanitization, and execution all stay inside the plugin the site owner already installed for exactly that.&lt;/li&gt;
&lt;li&gt;It is inert on its own. If neither Code Snippets nor WPCode is active, every endpoint returns a 400 and the plugin does nothing.&lt;/li&gt;
&lt;li&gt;Access uses WordPress core’s own Application Passwords flow. The site owner approves the CLI on WordPress’s own authorization screen and can revoke it from their dashboard at any time. We do not ship an auth scheme.&lt;/li&gt;
&lt;li&gt;Every write is gated by &lt;code&gt;current_user_can('manage_options')&lt;/code&gt;, the same capability already required to edit a snippet by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer did not move:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The plugin provides REST endpoints through which arbitrary PHP, JavaScript, CSS, and other code can be remotely submitted and stored in Code Snippets or WPCode, where that code can then be executed. It therefore facilitates remote deployment of arbitrary executable code, even though the final execution occurs in another plugin.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And on the authentication argument specifically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Application Passwords and manage_options checks establish who is authorized to make the request. They do not validate that the submitted code is safe, nor do they remove the inherent risks of providing an arbitrary-code deployment mechanism.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We had also pointed at the snippet plugins already in the directory. That got a direct response too:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Those plugins were approved years ago, before real-world experience demonstrated the risks of this model. Removing established plugins now could cause greater harm to their existing users, but their continued presence is not a basis for accepting new plugins that provide or facilitate the same capability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The takeaway: on this pattern, the directory does not weigh how good your authentication and capability checks are. The ability to deploy arbitrary code remotely is the thing they reject, wherever the execution finally lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking instead of guessing
&lt;/h2&gt;

&lt;p&gt;The rejection ended with a specific instruction: do not resubmit as is, and&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A materially different approach, such as providing only predefined, non-modifiable, thoroughly vetted operations rather than accepting arbitrary code, would need to be considered instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We had recently built ACF and SCF configuration sync into the CLI: push and pull custom field groups, post types, taxonomies, and options pages as JSON. That is configuration data, not executable code. Rather than strip the plugin and resubmit blind, we described the new flow to the team, an external caller hitting a REST endpoint that maps JSON to ACF’s own API and nothing else, and asked whether it would clear the same bar.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Synchronizing ACF/SCF configuration represented as JSON would not, by itself, raise the same arbitrary executable-code concern as the rejected snippet synchronization feature. However, we cannot pre-approve a plugin from a description or workflow diagram.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They listed what a full review would still check: that all snippet and arbitrary-code functionality was gone, that the synced data could not carry PHP, JS, CSS, or HTML, and that every REST endpoint had authentication, capability checks, validation, and sanitization. Not a green light, but enough to build against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;Snippet sync came out of the edition that ships to wordpress.org. Not disabled behind a flag: the code is physically absent from the zip, and a CI step unzips the built artifact and greps it for residue. The &lt;a href="https://docs.loopress.dev/blog/wordpress-org-composer-rejection/" rel="noopener noreferrer"&gt;Composer post&lt;/a&gt; covers the one-codebase, two-artifact build in detail.&lt;/p&gt;

&lt;p&gt;Snippet sync, WPForms sync, custom REST API routes, and Composer dependency management all live in &lt;strong&gt;Loopress Full&lt;/strong&gt; now, distributed from &lt;a href="https://loopress.dev" rel="noopener noreferrer"&gt;loopress.dev&lt;/a&gt;, never the directory. &lt;strong&gt;Loopress Light&lt;/strong&gt; is what was left: ACF and SEO configuration sync, plain JSON, over a &lt;code&gt;loopress/v1&lt;/code&gt; REST API restricted to administrators.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the ordinary review
&lt;/h2&gt;

&lt;p&gt;The resubmission cleared the policy question and hit a normal technical-issues pass. Two items:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks echoed straight into the admin page. Moved to &lt;code&gt;wp_enqueue_style&lt;/code&gt; and &lt;code&gt;wp_add_inline_style&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The top-level admin menu registered at position 6, sitting up among Posts and Media. Dropped to 100, below Settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both were fixed in one commit, and after that Loopress Light was approved.&lt;/p&gt;

&lt;p&gt;Worth knowing if you are in this queue now: the review emails are partly automated (the team says so in the footer, and that no personal data is shared with the AI), and they ask for short replies that do not enumerate your changes, because they re-review the whole plugin regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which edition you want
&lt;/h2&gt;

&lt;p&gt;If you sync ACF field groups and SEO settings, install &lt;a href="https://wordpress.org/plugins/loopress-light/" rel="noopener noreferrer"&gt;Loopress Light&lt;/a&gt; from the directory and let it update itself.&lt;/p&gt;

&lt;p&gt;If you also need snippet sync, WPForms sync, version-controlled REST API routes, or Composer packages managed from the admin, that is &lt;a href="https://docs.loopress.dev/wordpress-plugin/" rel="noopener noreferrer"&gt;Loopress Full&lt;/a&gt;, a separate download, for the reasons above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks
&lt;/h2&gt;

&lt;p&gt;The review team was direct at every step, including the ones where the answer was no, and the citations were specific enough to act on each time. Answering the ACF question before a full resubmission is the part we are most grateful for. It turned a blind rebuild-and-hope into a scoped piece of work.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>wordpressorg</category>
      <category>acf</category>
      <category>seo</category>
    </item>
    <item>
      <title>Composer Packages in a WordPress REST API Route</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/composer-packages-in-a-wordpress-rest-api-route-3b53</link>
      <guid>https://dev.to/jeansmaug/composer-packages-in-a-wordpress-rest-api-route-3b53</guid>
      <description>&lt;p&gt;Two Loopress Full features, each useful on their own: &lt;a href="https://docs.loopress.dev/blog/wordpress-composer-without-ssh/" rel="noopener noreferrer"&gt;Composer dependency management&lt;/a&gt; installs any Packagist package straight from the WordPress admin, and &lt;a href="https://docs.loopress.dev/blog/custom-wordpress-endpoint-without-writing-a-plugin/" rel="noopener noreferrer"&gt;Custom API Routes&lt;/a&gt; turn a version-controlled PHP file into a live REST endpoint, deployed with the &lt;a href="https://docs.loopress.dev/cli/getting-started/" rel="noopener noreferrer"&gt;Loopress CLI&lt;/a&gt; once it’s set up for the project. They were built separately, but not independently: a route file can &lt;code&gt;use&lt;/code&gt; any package Composer installed, no extra step in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reach for a package at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WP_Query&lt;/code&gt; (or &lt;code&gt;get_posts()&lt;/code&gt;) already covers anything the site itself holds: posts, pages, meta, taxonomies. A Composer package earns its place in a route the moment a request needs something WordPress doesn’t have on its own: a currency rate from another service, a PDF built for a download link, a CSV parsed from an upload, a payload checked against a validation library before it touches the database. Once a route file can &lt;code&gt;use&lt;/code&gt; a package, all of that is ordinary PHP, not a special case.&lt;/p&gt;

&lt;p&gt;This matters most when WordPress is used as a headless CMS: a separate frontend (Next.js, Astro) consuming &lt;code&gt;wp-json&lt;/code&gt; and rendering nothing of its own. There’s no theme, no template, nowhere else for that logic to live, every capability a request needs has to be in the route itself. That’s exactly where a route being limited to what &lt;code&gt;WP_Query&lt;/code&gt; already knows would start to hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line you don’t have to write
&lt;/h2&gt;

&lt;p&gt;Using a Composer package anywhere else in WordPress, a snippet, &lt;code&gt;functions.php&lt;/code&gt;, means loading the autoloader yourself first:&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;require_once&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;'/loopress/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;GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;That line has to be there, in every file that touches a package, every time. Miss it and you get a fatal “Class not found” the moment the file runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  In a route file, it’s not there
&lt;/h2&gt;

&lt;p&gt;A Custom API Route already goes through Loopress’s own loader before your code executes. That loader resolves the same &lt;code&gt;wp-content/loopress/vendor/&lt;/code&gt; autoloader &lt;code&gt;lps composer push&lt;/code&gt; fills, once, before your route class is even loaded, so a route file just uses a package like normal PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;
&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;GuzzleHttp\Client&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;Webhook&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;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="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;Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// ...&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;require_once&lt;/code&gt;, no path to get wrong, no difference between this and any Composer project you’ve worked on. The full reference is in &lt;a href="https://docs.loopress.dev/api/routes/#using-your-own-composer-dependencies" rel="noopener noreferrer"&gt;Writing Route Files&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the package isn’t there
&lt;/h2&gt;

&lt;p&gt;A corrupted or missing &lt;code&gt;wp-content/loopress/vendor/autoload.php&lt;/code&gt; is caught and &lt;a href="https://docs.loopress.dev/api/routes/#failure-isolation" rel="noopener noreferrer"&gt;logged&lt;/a&gt; once, before any route file loads, every other route still registers normally. A single package missing from an intact &lt;code&gt;vendor/&lt;/code&gt;, Guzzle never installed, say, isn’t something Loopress catches on your behalf: it surfaces as an ordinary PHP error on that request, the same as calling any undefined class anywhere else in PHP. WordPress serves each request in its own process, so the error stays contained to the one endpoint that hit it, everything else on the site keeps working, but push the package before you push the route that depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this opens up
&lt;/h2&gt;

&lt;p&gt;Once the autoloader is a non-issue, the interesting question stops being “can I use a package here” and becomes “what’s worth building with one.” A route enriching WordPress content with a live external rate. One generating a file on demand instead of storing it. One validating a webhook payload before it’s trusted. Each of those deserves its own walkthrough with real code and a real deploy, not a paragraph here, so that’s what the rest of this series is for.&lt;/p&gt;




&lt;p&gt;Both features ship with Loopress Full, free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @loopress/cli

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

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://docs.loopress.dev/composer/" rel="noopener noreferrer"&gt;Composer dependency management&lt;/a&gt; covers installing and auditing packages from the admin. &lt;a href="https://docs.loopress.dev/api/routes/" rel="noopener noreferrer"&gt;Writing Route Files&lt;/a&gt; covers everything a route can do beyond this: authentication, CORS, the security model.&lt;/p&gt;

</description>
      <category>composer</category>
      <category>restapi</category>
      <category>wordpress</category>
      <category>php</category>
    </item>
    <item>
      <title>Version Control Your WordPress Code Snippets</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jeansmaug/version-control-your-wordpress-code-snippets-4e11</link>
      <guid>https://dev.to/jeansmaug/version-control-your-wordpress-code-snippets-4e11</guid>
      <description>&lt;p&gt;If you use &lt;a href="https://wordpress.org/plugins/code-snippets/" rel="noopener noreferrer"&gt;Code Snippets&lt;/a&gt; or &lt;a href="https://wpcode.com/" rel="noopener noreferrer"&gt;WPCode&lt;/a&gt;, you’re probably editing your PHP functions directly in the admin panel. Click the snippet, make the change, save. Done.&lt;/p&gt;

&lt;p&gt;It works. Until it doesn’t.&lt;/p&gt;

&lt;p&gt;Someone edits a snippet and breaks something. You don’t know what changed. There’s no history. You either remember what it looked like before, or you don’t. There’s no &lt;code&gt;git log&lt;/code&gt;, no diff, no blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  How people try to solve this today
&lt;/h2&gt;

&lt;p&gt;The current options aren’t great.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: WPCodeBox or Code Snippets Cloud&lt;/strong&gt; These tools offer built-in versioning: you can see a history of changes and revert. It works, but it’s locked inside their platform. Your code history lives in their database, not in your repo, and it doesn’t integrate with your existing Git workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: Keeping snippets in your theme’s &lt;code&gt;functions.php&lt;/code&gt;&lt;/strong&gt; This at least puts the code in a file you can version. But it ties snippets to your theme, means they disappear on theme switch, and turns &lt;code&gt;functions.php&lt;/code&gt; into an unorganized dumping ground.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 3: A custom plugin&lt;/strong&gt; Some agencies build a site-specific plugin to hold all their custom code. This is better: it’s version-controlled and theme-independent. But it requires setup for every project and doesn’t give you a clean way to sync between environments.&lt;/p&gt;

&lt;p&gt;None of these feel like a real developer workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Loopress approach
&lt;/h2&gt;

&lt;p&gt;Loopress treats your snippets as plain files in a Git repository. Pull them down from WordPress, edit them locally, push them back. Every change is a commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;Install the Loopress CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @loopress/cli

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

&lt;/div&gt;



&lt;p&gt;Configure your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps project config

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

&lt;/div&gt;



&lt;p&gt;It asks for a project name, an environment (production, staging, local, or a custom name), and your WordPress URL. By default it then opens your browser to authorize and creates an Application Password for you automatically, no copy-pasting a generated password. If that flow can’t complete, it falls back to asking for a WordPress username and an Application Password you generate yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pull your snippets
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps snippet pull

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

&lt;/div&gt;



&lt;p&gt;This creates a &lt;code&gt;snippets/&lt;/code&gt; directory with one &lt;code&gt;.php&lt;/code&gt; file and one &lt;code&gt;.json&lt;/code&gt; sidecar per snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snippets/

  1-my-custom-function.php

  1-my-custom-function.json

  2-woocommerce-checkout-tweak.php

  2-woocommerce-checkout-tweak.json

  3-disable-gutenberg.php

  3-disable-gutenberg.json

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

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;.php&lt;/code&gt; file contains the raw snippet code. The &lt;code&gt;.json&lt;/code&gt; sidecar stores the metadata (id, name, type, active status, tags) that Loopress uses to identify and update the snippet on WordPress.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit locally
&lt;/h3&gt;

&lt;p&gt;Open any file in your editor. Make changes. The feedback loop is your local environment, not the WordPress admin panel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
git add snippets/2-woocommerce-checkout-tweak.php

git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: apply discount code before tax calculation"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Now you have a commit. A diff. A message explaining why. This is reviewable, reversible, and shareable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push back to WordPress
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps snippet push

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

&lt;/div&gt;



&lt;p&gt;Loopress syncs your local files back to WordPress via the REST API. No FTP, no copy-paste.&lt;/p&gt;

&lt;p&gt;If you want to see what would change without actually changing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
lps snippet push &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Working across environments
&lt;/h2&gt;

&lt;p&gt;The real value shows up when you’re working with local, staging, and production environments. Register each one once with &lt;code&gt;lps project config&lt;/code&gt;, then target any of them with &lt;code&gt;--env&lt;/code&gt; on a single command:&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;# Pull from production&lt;/span&gt;

lps snippet pull &lt;span class="nt"&gt;--env&lt;/span&gt; production

&lt;span class="c"&gt;# Test locally, commit changes&lt;/span&gt;

&lt;span class="c"&gt;# Push to staging first&lt;/span&gt;

lps snippet push &lt;span class="nt"&gt;--env&lt;/span&gt; staging

&lt;span class="c"&gt;# When ready, push to production&lt;/span&gt;

lps snippet push &lt;span class="nt"&gt;--env&lt;/span&gt; production &lt;span class="nt"&gt;--yes&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;That trailing &lt;code&gt;--yes&lt;/code&gt; isn’t optional flair: pushing to an environment literally named &lt;code&gt;production&lt;/code&gt; asks for confirmation in a terminal, and requires &lt;code&gt;--yes&lt;/code&gt; to skip that prompt outside one (CI, scripts). &lt;code&gt;--env&lt;/code&gt; also takes priority over whatever &lt;code&gt;lps project switch&lt;/code&gt; last left active globally, which matters the moment more than one person runs commands against the same project.&lt;/p&gt;

&lt;p&gt;Your snippets flow between environments the same way your code does. No database exports, no manual copy-paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing to keep in mind
&lt;/h2&gt;

&lt;p&gt;Loopress syncs snippets by ID (stored in the &lt;code&gt;.json&lt;/code&gt; sidecar). If you pull first, the ID is saved and subsequent pushes update the correct snippet regardless of name changes. Without a sidecar ID, Loopress creates a new snippet. Treat the files as the source of truth once you’re in this workflow.&lt;/p&gt;




&lt;p&gt;If this resonates, the next step is &lt;code&gt;lps project config&lt;/code&gt; and adding &lt;code&gt;snippets/&lt;/code&gt; to your project’s Git repository. From there, every snippet change is a commit.&lt;/p&gt;

</description>
      <category>codesnippets</category>
      <category>git</category>
      <category>wordpress</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Ship a Custom WordPress Endpoint Without Writing a Plugin</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Mon, 27 Jul 2026 07:02:40 +0000</pubDate>
      <link>https://dev.to/jeansmaug/ship-a-custom-wordpress-endpoint-without-writing-a-plugin-24i1</link>
      <guid>https://dev.to/jeansmaug/ship-a-custom-wordpress-endpoint-without-writing-a-plugin-24i1</guid>
      <description>&lt;p&gt;Your JavaScript frontend needs a list of featured products from WordPress. Not the whole REST API surface, not raw post objects with forty fields, just the ten items the homepage carousel shows, shaped the way the frontend wants them.&lt;/p&gt;

&lt;p&gt;The endpoint is ten minutes of work: query, map, return. The question that eats the rest of the afternoon: where does that code go?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where custom endpoints live today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;functions.php&lt;/code&gt;.&lt;/strong&gt; Fastest to write, tied to your theme. Switch themes, lose the endpoint. Two years later it's line 1400 of a file nobody wants to scroll.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A site-specific plugin.&lt;/strong&gt; The clean classic answer. But now your ten minutes of logic come wrapped in a plugin header, a hook, and a registration array:&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;'acme/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/featured-products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'acme_featured_products'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you still need a way to get that plugin onto the site. And onto staging. And to know which version runs where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A snippets plugin.&lt;/strong&gt; Deployed in seconds, versioned never. The code lives in the database, edited through an admin textarea, invisible to Git and code review.&lt;/p&gt;

&lt;p&gt;The endpoint logic is never the problem. The delivery vehicle is.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file, one route
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://docs.loopress.dev/wordpress-plugin/" rel="noopener noreferrer"&gt;Loopress Full&lt;/a&gt; installed, a custom endpoint is a single PHP file in your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FeaturedProducts&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&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="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'meta_key'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'featured'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'meta_value'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&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;10&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;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="o"&gt;=&amp;gt;&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;'url'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$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="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;Save it as &lt;code&gt;api/featured-products.php&lt;/code&gt; and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lps api push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conventions do all the wiring you saw above. The filename is the route. The class name is the filename in PascalCase. Each public method named after an HTTP verb (&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;, &lt;code&gt;delete&lt;/code&gt;) becomes that verb's handler. No registration code, no plugin header, no hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now call it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://your-site.com/wp-json/loopress-api/v1/featured-products
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"rest_forbidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Sorry, you are not allowed to do that."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 401 is the feature. A fresh route requires an administrator, the same authentication the Loopress CLI itself uses. Nothing you push is publicly reachable by accident.&lt;/p&gt;

&lt;p&gt;For an endpoint another system calls on your behalf, a reporting script, an internal dashboard, a cron job, that default is already the right answer: the caller authenticates with an &lt;a href="https://docs.loopress.dev/application-passwords/" rel="noopener noreferrer"&gt;application password&lt;/a&gt; over Basic auth, and you have a protected endpoint without writing a single line of security code.&lt;/p&gt;

&lt;p&gt;This feed, though, is meant for the browser. Making it public is a decision, so it's expressed where decisions belong, in the file, in the diff, in code review:&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;callable&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;fn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://your-site.com/wp-json/loopress-api/v1/featured-products
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;231&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Weekender Backpack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://your-site.com/product/weekender-backpack"&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;One greppable line. A reviewer seeing this diff knows exactly what's being opened to the world, which is not something you can say about a checkbox in an admin screen. The same &lt;code&gt;permission()&lt;/code&gt; method takes any callable, so routes that need their own rules, logged-in users only, a capability check, a signed webhook, express them the same way (the &lt;a href="https://docs.loopress.dev/api/routes/" rel="noopener noreferrer"&gt;docs&lt;/a&gt; cover those patterns).&lt;/p&gt;

&lt;p&gt;The frontend lives on another domain? Declare CORS headers in the file too, they apply to every request, preflight included:&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;headers&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Access-Control-Allow-Origin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://app.example.com'&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;Need an HTTP client or an SDK in a route? If the site uses &lt;a href="https://docs.loopress.dev/composer/" rel="noopener noreferrer"&gt;Loopress's Composer integration&lt;/a&gt;, your packages are directly available to &lt;code&gt;use&lt;/code&gt; in route files. No manual autoloader loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built to not break your site
&lt;/h2&gt;

&lt;p&gt;Deployed code that runs inside WordPress has one non-negotiable requirement: a mistake must stay contained.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lps api push&lt;/code&gt; syntax-checks every file server-side and rejects broken PHP with the actual parse error, before anything is written.&lt;/li&gt;
&lt;li&gt;A file that still fails at runtime (a class collision with another plugin, a throwing dependency) is skipped and logged. Every other route, and the rest of the site's REST API, keeps working.&lt;/li&gt;
&lt;li&gt;Files are protected from direct HTTP access and written atomically. Pull them back and you get exactly the source you wrote.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Endpoints your repository declares
&lt;/h2&gt;

&lt;p&gt;Because routes are plain files, everything you already do with code applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lps api pull                  &lt;span class="c"&gt;# mirror what's on the site locally&lt;/span&gt;
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; add-feed      &lt;span class="c"&gt;# branch, edit, review&lt;/span&gt;
lps api push                  &lt;span class="c"&gt;# deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different environments? Push the same files to staging first, then production, like the rest of your Loopress-managed configuration. Endpoints stop being something you set up on a site and become something your repository declares.&lt;/p&gt;




&lt;p&gt;Custom API Routes ship with Loopress Full, the free full edition of the plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @loopress/cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.loopress.dev/api/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers the complete file reference: request handling, response types, authentication, CORS, namespaces, and the security model.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>restapi</category>
      <category>git</category>
      <category>loopress</category>
    </item>
    <item>
      <title>Installing Composer Packages in WordPress Without SSH</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:59:17 +0000</pubDate>
      <link>https://dev.to/jeansmaug/installing-composer-packages-in-wordpress-without-ssh-5dmk</link>
      <guid>https://dev.to/jeansmaug/installing-composer-packages-in-wordpress-without-ssh-5dmk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from the Loopress docs&lt;br&gt;
Loopress is a toolset to &lt;strong&gt;make WordPress reproducible and reviewable via Git&lt;/strong&gt;. Versioned snippets, Composer without SSH, and more coming...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://loopress.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Check Loopress&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;You need to add a PHP dependency to a WordPress site. Maybe a PDF generation library, a CSV parser, an image manipulation package. Something available on Packagist.&lt;/p&gt;

&lt;p&gt;Here's what the standard workflow looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The current workflow
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you're on managed hosting (most clients are):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if SSH is available (often it isn't, or it requires upgrading the hosting plan)&lt;/li&gt;
&lt;li&gt;If SSH is available: connect, check if Composer is installed, install it if not&lt;/li&gt;
&lt;li&gt;Navigate to the WordPress root&lt;/li&gt;
&lt;li&gt;Initialize &lt;code&gt;composer.json&lt;/code&gt; if it doesn't exist&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;composer require vendor/package&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Hope the server's PHP version matches your local environment&lt;/li&gt;
&lt;li&gt;Hope the &lt;code&gt;vendor/&lt;/code&gt; directory ends up in the right place&lt;/li&gt;
&lt;li&gt;Repeat for every site, every environment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is assuming nothing goes wrong. In practice, you'll hit permission issues, PHP version mismatches, or a host that simply blocks Composer from running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workaround people actually use:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run Composer locally, then upload the &lt;code&gt;vendor/&lt;/code&gt; directory to the server via FTP or rsync. This works but means you're manually syncing binary files, and the next developer who needs to add a dependency starts from scratch figuring out where everything lives.&lt;/p&gt;

&lt;p&gt;Neither option is good.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Loopress does differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Loopress Full&lt;/strong&gt; adds a &lt;strong&gt;Dependency Management&lt;/strong&gt; panel to your WordPress admin. It talks to Packagist, runs Composer server-side through a sandboxed process, and handles the install without you needing a terminal or SSH access.&lt;/p&gt;

&lt;p&gt;This is Loopress Full specifically, not Loopress Light (the free edition on wordpress.org). wordpress.org's plugin guidelines don't allow a plugin to install executable code from a third-party registry like Packagist, so this feature ships separately, from &lt;a href="https://loopress.dev" rel="noopener noreferrer"&gt;loopress.dev&lt;/a&gt;, rather than the official directory. &lt;a href="https://dev.to/blog/wordpress-org-composer-rejection/"&gt;Here's why&lt;/a&gt;, if you're curious.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/loopress/loopress/releases" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Download Loopress Full&lt;/a&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing a package
&lt;/h3&gt;

&lt;p&gt;Here's what that looks like from the admin, in under a minute:&lt;/p&gt;


  &lt;p&gt;Your browser doesn't support HTML5 video. &lt;a href="https://github.com/loopress/loopress/raw/refs/heads/main/documentation/src/content/videos/composer-install-admin-ui.webm" rel="noopener noreferrer"&gt;Download the video&lt;/a&gt; instead.&lt;/p&gt;


&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;WordPress Admin → Loopress → Dependencies&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Search for a package by name (&lt;code&gt;guzzlehttp/guzzle&lt;/code&gt;, or whatever you need). Loopress queries Packagist directly and shows you available versions.&lt;/li&gt;
&lt;li&gt;Select the version, click &lt;strong&gt;Install&lt;/strong&gt;. Loopress runs &lt;code&gt;composer require&lt;/code&gt; on the server and installs the package into a managed &lt;code&gt;vendor/&lt;/code&gt; directory inside the plugin's scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Using it in a snippet
&lt;/h3&gt;

&lt;p&gt;Require the autoloader once, then use the package like any other Composer dependency. Here it is fetching and displaying data from an API in a code snippet:&lt;/p&gt;


  &lt;p&gt;Your browser doesn't support HTML5 video. &lt;a href="https://github.com/loopress/loopress/raw/refs/heads/main/documentation/src/content/videos/snippet-using-composer.webm" rel="noopener noreferrer"&gt;Download the video&lt;/a&gt; instead.&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;require_once&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;'/loopress/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;GuzzleHttp\Client&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;'the_content'&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;$content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;in_the_loop&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_main_query&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;$content&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;Client&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="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://jsonplaceholder.typicode.com/posts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'_limit'&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="nv"&gt;$posts&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="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&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;getContents&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="nv"&gt;$list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;ul&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$posts&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$list&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;li style="color: red"&amp;gt;'&lt;/span&gt; &lt;span class="mf"&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="nv"&gt;$post&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="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/li&amp;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;$list&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/ul&amp;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;$content&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$list&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;
  
  
  Why this matters for agencies
&lt;/h3&gt;

&lt;p&gt;If you manage multiple client sites, the usual Composer workflow means either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Maintaining SSH access to every server&lt;/li&gt;
&lt;li&gt;Teaching clients how to run terminal commands (not happening)&lt;/li&gt;
&lt;li&gt;Keeping a pile of FTP credentials and manually uploading &lt;code&gt;vendor/&lt;/code&gt; directories&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With Loopress, the dependency management surface moves into the WordPress admin. You can install, update, and remove packages from the same interface you use to manage everything else. No server access required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Seeing what's installed
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Dependencies&lt;/strong&gt; panel shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All installed packages with their current versions&lt;/li&gt;
&lt;li&gt;Available updates from Packagist&lt;/li&gt;
&lt;li&gt;A one-click update path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the equivalent of &lt;code&gt;composer show&lt;/code&gt; and &lt;code&gt;composer outdated&lt;/code&gt;, surfaced in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Loopress doesn't replace
&lt;/h2&gt;

&lt;p&gt;Loopress's dependency management is designed for adding libraries to a running WordPress site, pulling in packages that your snippets or custom code need. It's not a replacement for a full Bedrock/Composer setup where WordPress itself is a Composer dependency.&lt;/p&gt;

&lt;p&gt;If you're building on &lt;a href="https://roots.io/bedrock/" rel="noopener noreferrer"&gt;Roots/Bedrock&lt;/a&gt;, you already have a proper Composer workflow and probably don't need this. Loopress fills the gap for the other 90% of WordPress sites that aren't running a full stack framework: sites on shared hosting, client sites where the server environment is opaque, or projects where "set up Bedrock" isn't a conversation you can have.&lt;/p&gt;




&lt;p&gt;Loopress Full is a free download, installed like any other plugin zip (&lt;strong&gt;Plugins → Add New → Upload Plugin&lt;/strong&gt;), no server configuration required. If you only need snippet sync, Loopress Light is the free edition submitted to the wordpress.org directory instead.&lt;/p&gt;

</description>
      <category>composer</category>
      <category>php</category>
      <category>wordpress</category>
      <category>loopress</category>
    </item>
    <item>
      <title>Why wordpress.org Won't Let You Install Composer Packages From a Plugin</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Mon, 20 Jul 2026 06:55:14 +0000</pubDate>
      <link>https://dev.to/jeansmaug/why-wordpressorg-wont-let-you-install-composer-packages-from-a-plugin-3dhf</link>
      <guid>https://dev.to/jeansmaug/why-wordpressorg-wont-let-you-install-composer-packages-from-a-plugin-3dhf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from the Loopress docs&lt;br&gt;
Loopress is a toolset to &lt;strong&gt;make WordPress reproducible and reviewable via Git&lt;/strong&gt;. Versioned snippets, Composer without SSH, and more coming...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://loopress.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Check Loopress&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;WordPress doesn't have a package manager. If you want a PHP library in your project, be it Guzzle for HTTP calls or a PDF generation library in a snippet, you're either vendoring the code by hand or running Composer somewhere the WordPress admin can't see.&lt;/p&gt;

&lt;p&gt;We built a feature to fix that: a Composer UI inside the WordPress admin. Search Packagist, install a package, audit it for known vulnerabilities, all without SSH access (&lt;a href="https://dev.to/blog/composer-without-ssh/"&gt;full walkthrough here&lt;/a&gt;). Before shipping it, we asked the wordpress.org plugin review team whether it would be acceptable in the official directory.&lt;/p&gt;

&lt;p&gt;The answer was no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Here's the relevant line from Guideline 8 of the wordpress.org plugin directory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Plugins may not send executable code via third-party systems."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Installing a PHP package from Packagist is, by definition, downloading executable code from a third party and writing it to disk. It doesn't matter that our plugin never calls the installed code's autoloader itself, that the user has to load it deliberately from their own snippet. The review team was clear: the indirection changes nothing. The mere presence of that capability is enough to trigger the rule, whether or not it's ever used. There's no folder you can hide it in that makes it acceptable, they weren't shy about saying that outright.&lt;/p&gt;

&lt;p&gt;If you want PHP dependencies in a plugin distributed on wordpress.org, the only accepted path is to vendor them at submission time: ship the code, with a compatible license and readable source, not fetch it dynamically.&lt;/p&gt;

&lt;p&gt;For us, that meant Composer package management could never live in the same plugin that ships on wordpress.org. Full stop, no clever workaround changes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried first, and undid
&lt;/h2&gt;

&lt;p&gt;Our first move was the obvious one: split into two plugins. A "Core" plugin with snippet sync, distributed on wordpress.org, and a "Plus" plugin with Composer management, distributed separately, with the two detecting each other at runtime.&lt;/p&gt;

&lt;p&gt;We built it. Two npm packages, two &lt;code&gt;composer.json&lt;/code&gt; files, two QA configs, two release pipelines, a filter contract between them so Plus could register itself with Core. Tests passed on both. Then we looked at what we'd actually built and reverted the whole thing the same day.&lt;/p&gt;

&lt;p&gt;The duplication cost more than the split saved us. Every config file, every CI job, every README existed twice for no product reason. Worse, one version of the idea we considered, Core dynamically loading Plus's code, would have reintroduced the exact pattern the review team had just told us was the problem. That one wasn't even a contender once we thought it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped instead
&lt;/h2&gt;

&lt;p&gt;One codebase. Two build artifacts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wordpress-plugin/
├── src/
│   ├── Module/, Service/, RestApi/    snippet sync, shared by both editions
│   └── Dependencies/                  all Composer code, this directory
│                                      is physically absent from the
│                                      wordpress.org build, not just inactive
├── frontend/
│   ├── full/    → App.tsx             Composer UI lives here
│   └── light/   → LightApp.tsx        never imports anything from Dependencies/
└── scripts/build-flavor.cjs           assembles each edition and zips it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pnpm pack:light&lt;/code&gt; produces &lt;strong&gt;Loopress Light&lt;/strong&gt;: snippet sync only, no Composer code anywhere in the zip, not dead code, not a disabled feature flag, physically not present. &lt;code&gt;composer.json&lt;/code&gt; in that build ships with an empty &lt;code&gt;require&lt;/code&gt;. A CI step unzips the artifact after the build and greps it for Composer residue, because trusting the build script to get this right isn't good enough when the whole point is a compliance boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pnpm pack:full&lt;/code&gt; produces &lt;strong&gt;Loopress Full&lt;/strong&gt;: everything, including the package installer, the security audit, and CLI sync for your &lt;code&gt;composer.json&lt;/code&gt;/&lt;code&gt;composer.lock&lt;/code&gt;. It's distributed from our own site, never wordpress.org, and its plugin header sets an &lt;code&gt;Update URI&lt;/code&gt; that stops wordpress.org from ever pushing an update over it by mistake. If Loopress Light is already active on a site, activating Loopress Full deactivates it. The two editions don't coexist. Full replaces Light, it doesn't sit next to it.&lt;/p&gt;

&lt;p&gt;This is the same model ACF and Elementor use for their free/pro split: one codebase, one build script, two artifacts with a real feature boundary between them, not a paywall drawn in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;If you only need snippet sync, Loopress Light is a free wordpress.org install, same as any other plugin in the directory. If you want Composer packages managed from WordPress without SSH, that's Loopress Full (&lt;a href="https://dev.to/blog/composer-without-ssh/"&gt;see how it works&lt;/a&gt;), a separate download from our site, precisely because wordpress.org's own rules don't allow that capability to live there. We'd rather tell you that upfront than have you discover it after installing the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks
&lt;/h2&gt;

&lt;p&gt;Credit where it's due: the wordpress.org plugins review team answered our question directly and quickly, before we'd submitted anything. That single clear answer, citing the exact guideline, saved us from finding out the hard way through a full submission and rejection cycle. Loopress Light is in review now, built around that answer from the start.&lt;/p&gt;

</description>
      <category>composer</category>
      <category>wordpress</category>
      <category>loopress</category>
      <category>opensource</category>
    </item>
    <item>
      <title>WordPress Has a DX Problem, and Developers Are Paying for It</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Wed, 15 Jul 2026 17:34:49 +0000</pubDate>
      <link>https://dev.to/jeansmaug/wordpress-has-a-dx-problem-and-developers-are-paying-for-it-c9f</link>
      <guid>https://dev.to/jeansmaug/wordpress-has-a-dx-problem-and-developers-are-paying-for-it-c9f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from the Loopress docs&lt;br&gt;
Loopress is a toolset to &lt;strong&gt;make WordPress reproducible and reviewable via Git&lt;/strong&gt;. Versioned snippets, Composer without SSH, and more coming...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://loopress.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Check Loopress&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;WordPress is remarkable. It powers well over 40% of all websites, according to &lt;a href="https://w3techs.com/technologies/details/cm-wordpress" rel="noopener noreferrer"&gt;W3Techs&lt;/a&gt;. Your clients love it. Their non-technical staff can update content without calling you. The plugin ecosystem is enormous.&lt;/p&gt;

&lt;p&gt;But if you're a developer, the moment you sit down to &lt;em&gt;build&lt;/em&gt; with WordPress, something feels off.&lt;/p&gt;

&lt;p&gt;You're editing files directly on the server. You're copy-pasting snippets from the admin panel. You're hoping you remember what you changed last week. You're sending a colleague a zip file with the "latest version" of some PHP function.&lt;/p&gt;

&lt;p&gt;None of this is how modern software is built. And yet, it's the default WordPress workflow. This is the gap Loopress exists to close, and it's worth understanding exactly where the friction comes from before looking at how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is developer experience?
&lt;/h2&gt;

&lt;p&gt;Developer experience (DX) refers to how smooth (or painful) it is to work with a tool as a developer. Good DX means: fast feedback loops, predictable environments, version-controlled code, and reproducible deployments.&lt;/p&gt;

&lt;p&gt;Think of how you work with a modern JavaScript project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code lives in Git&lt;/li&gt;
&lt;li&gt;Dependencies are declared in &lt;code&gt;package.json&lt;/code&gt; and locked in &lt;code&gt;package-lock.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;You run &lt;code&gt;npm install&lt;/code&gt; and the project works&lt;/li&gt;
&lt;li&gt;Staging and production match because the environment is defined in code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now think about a typical WordPress project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Snippets are stored in a database, edited through a UI&lt;/li&gt;
&lt;li&gt;PHP packages require SSH access to install&lt;/li&gt;
&lt;li&gt;"Deploying" means FTP or a database export&lt;/li&gt;
&lt;li&gt;Staging and production drift apart over time because changes happen manually in both&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a criticism of WordPress as a product; it was built for a different audience. But developers using it to build things deserve better.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;p&gt;Here's a real scenario: you add a PHP snippet to your WordPress site using the Code Snippets plugin. It works. Three months later, a junior dev edits it and introduces a bug that takes checkout down for an hour. You have no idea what changed, when, or why. There's no diff, no history, no rollback button. You dig through admin logs hoping to find something, then end up rewriting the snippet from memory.&lt;/p&gt;

&lt;p&gt;Or: a client wants a PDF export feature. You need to pull in a Packagist package. You SSH into the server, install Composer, run &lt;code&gt;composer require&lt;/code&gt;, and pray nothing breaks in production. It worked, but it was thirty minutes of friction that shouldn't exist.&lt;/p&gt;

&lt;p&gt;These aren't edge cases. They're the everyday reality of WordPress development.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a better workflow looks like
&lt;/h2&gt;

&lt;p&gt;Imagine instead:&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;# Pull your snippets down as files&lt;/span&gt;
lps snippet pull

&lt;span class="c"&gt;# Edit them locally, in your editor, with Git tracking changes&lt;/span&gt;
git add snippets/my-function.php
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: correct tax calculation logic"&lt;/span&gt;

&lt;span class="c"&gt;# Push back to WordPress&lt;/span&gt;
lps snippet push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your snippet is now version-controlled. You have a diff. You can roll back. You can review the change in a PR. This is how software is supposed to work, and it's exactly what Loopress brings to WordPress: a CLI that treats your snippets and Composer dependencies as files, plus a plugin that lets you manage PHP dependencies from the admin panel without server access.&lt;/p&gt;

&lt;p&gt;WordPress stays WordPress. Your clients can still use it the way they love. You just work with it like a developer.&lt;/p&gt;

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

&lt;p&gt;The rest of this blog will cover specific workflows in detail: version-controlling Code Snippets with Git, installing Composer packages without SSH, syncing menus and styles between environments, and managing multiple client sites as code. Those posts are coming soon; this one lays the groundwork for why they matter.&lt;/p&gt;

</description>
      <category>dx</category>
      <category>wordpress</category>
      <category>beginners</category>
      <category>loopress</category>
    </item>
    <item>
      <title>Why I Built Loopress</title>
      <dc:creator>jean-smaug</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:11:46 +0000</pubDate>
      <link>https://dev.to/jeansmaug/why-i-built-loopress-3ao6</link>
      <guid>https://dev.to/jeansmaug/why-i-built-loopress-3ao6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from the Loopress docs&lt;br&gt;
Loopress is a toolset to &lt;strong&gt;make WordPress reproducible and reviewable via Git&lt;/strong&gt;. Versioned snippets, Composer without SSH, and more coming...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://loopress.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Check Loopress&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;I'm a frontend developer who's spent the last few years working in environments where everything is versioned, deployments are automated, and "it works on my machine" isn't an acceptable answer.&lt;/p&gt;

&lt;p&gt;One of the jobs that shaped how I think about this was &lt;strong&gt;Bedrock Streaming&lt;/strong&gt;, a streaming platform where every developer owned their infrastructure, frontend included, and the tool we used was &lt;strong&gt;Terraform&lt;/strong&gt;. I remember something clicking when I started using it: your infra is just a file, which means it gets versioned, reviewed, and rolled back like any other piece of code. That idea stuck with me well past that job.&lt;/p&gt;

&lt;h2&gt;
  
  
  A site for my sister
&lt;/h2&gt;

&lt;p&gt;My sister runs an online French tutoring business (&lt;a href="https://nativefrenchteacher.com" rel="noopener noreferrer"&gt;nativefrenchteacher.com&lt;/a&gt;). She's not technical, and she needed to manage her own content without calling me every time. WordPress made sense.&lt;/p&gt;

&lt;p&gt;And honestly, it worked great for her. Whatever I needed (bookings, SEO, forms, caching) there was a plugin. The ecosystem is massive.&lt;/p&gt;

&lt;p&gt;Building &lt;em&gt;with&lt;/em&gt; it as a developer was another thing entirely.&lt;/p&gt;

&lt;p&gt;I used the Code Snippets plugin constantly. And every time I touched a snippet, the same thought: if something breaks, I have no history. No diff. Just hope it doesn't break.&lt;/p&gt;

&lt;p&gt;The real moment was when I needed to manipulate PDFs and wanted to pull in a Packagist package. So I SSH'd into the server, installed Composer manually, ran the install, crossed my fingers. It worked. But it felt wrong. This is a solved problem everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;I came from a world where code lives in Git and environments are reproducible. WordPress assumes you click things in the admin, upload files via FTP, and remember what you changed. Production is always slightly different from local.&lt;/p&gt;

&lt;p&gt;That's not a knock on WordPress. It's built for a different audience. But the developer building &lt;em&gt;on top&lt;/em&gt; of it is left to figure things out alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I started building &lt;strong&gt;Loopress&lt;/strong&gt;: a CLI to version-control code snippets in Git and sync them to any WordPress instance, and a plugin that brings Composer dependency management into the admin panel, no SSH needed.&lt;/p&gt;

&lt;p&gt;The goal was to bring the same idea I took away from Terraform into WordPress: your snippets, your dependencies, your configuration, all of it living in a repo, reviewable, deployable, not scattered across admin panels and SSH sessions you'll never remember.&lt;/p&gt;

&lt;p&gt;Still building. If any of this sounds familiar, I built it for you.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>dx</category>
      <category>loopress</category>
    </item>
  </channel>
</rss>
