<?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: Softminal</title>
    <description>The latest articles on DEV Community by Softminal (@softminal).</description>
    <link>https://dev.to/softminal</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%2F4073203%2Fba94ceda-5ecb-49a4-8c93-57a9d1b5cb29.png</url>
      <title>DEV Community: Softminal</title>
      <link>https://dev.to/softminal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/softminal"/>
    <language>en</language>
    <item>
      <title>We built an all-in-one WordPress image toolkit. Here's the architecture behind it</title>
      <dc:creator>Softminal</dc:creator>
      <pubDate>Thu, 13 Aug 2026 11:03:01 +0000</pubDate>
      <link>https://dev.to/softminal/we-built-an-all-in-one-wordpress-image-toolkit-heres-the-architecture-behind-it-cl</link>
      <guid>https://dev.to/softminal/we-built-an-all-in-one-wordpress-image-toolkit-heres-the-architecture-behind-it-cl</guid>
      <description>&lt;p&gt;We want to talk about why most WordPress image plugins exist as separate, single-purpose tools — and why we think that's the wrong approach.&lt;/p&gt;

&lt;p&gt;If you manage WordPress sites, you've probably installed some combination of these: an AI alt text generator, a compression plugin, a WebP converter, a media file renamer, and maybe something to catch broken images. Each one solves a real problem. But together, they create new ones.&lt;/p&gt;

&lt;p&gt;They fight over hook priority on &lt;code&gt;wp_get_attachment_image&lt;/code&gt;. They store overlapping metadata without knowing about each other. They enqueue separate admin scripts on the same pages. And when one of them breaks on a WordPress update, you're debugging a five-plugin interaction.&lt;/p&gt;

&lt;p&gt;ImageCraft is our answer to this. One plugin, one pipeline, every image optimization task in a single codebase. It's free and open source on WordPress.org.&lt;/p&gt;

&lt;p&gt;We want to walk through the technical decisions, because we think they're more interesting than a feature list.&lt;/p&gt;




&lt;h2&gt;
  
  
  Direct AI provider integration
&lt;/h2&gt;

&lt;p&gt;Most AI alt text plugins work on a credit model. You buy credits from the developer, they proxy your request to OpenAI or Claude, and pocket the margin. The markup is significant — GPT-4o-mini costs about $0.15 per 100 images at retail, but credit-based plugins charge $3–6 for the same volume.&lt;/p&gt;

&lt;p&gt;ImageCraft connects directly to the AI provider from your server. No middleman, no credit packs. You pay the provider at their published rates.&lt;/p&gt;

&lt;p&gt;Three providers are supported: Anthropic (Claude), OpenAI, and Google Gemini. Each extends a &lt;code&gt;BaseAIProvider&lt;/code&gt; class that handles image fetching, base64 encoding, MIME detection, and prompt construction. Adding a new provider means implementing one method: &lt;code&gt;generateAltText()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;API keys are AES-256-CBC encrypted in a custom database table. The REST API never returns the key — only a boolean &lt;code&gt;has_key&lt;/code&gt; field.&lt;/p&gt;




&lt;h2&gt;
  
  
  Combined meta generation (one call, three outputs)
&lt;/h2&gt;

&lt;p&gt;A naive implementation would make separate API calls for alt text, title, and caption. Three network round trips, three token charges, three latency waits.&lt;/p&gt;

&lt;p&gt;ImageCraft makes one call that returns all three in a structured JSON response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alt_text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tan leather crossbody bag with brass buckle and adjustable strap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Aria Crossbody Bag - Tan Leather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"caption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Handcrafted crossbody bag from the Aria Collection, featuring full-grain tan leather and antique brass hardware."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt instructs the model to return this exact shape. A &lt;code&gt;parseMetaJson()&lt;/code&gt; helper handles the response — including edge cases like markdown-wrapped JSON, extra fields, and truncated responses. Each field has a configurable max length with a &lt;code&gt;capText()&lt;/code&gt; function that truncates at the last word boundary rather than mid-word.&lt;/p&gt;

&lt;p&gt;For WooCommerce images, the prompt includes product metadata (name, SKU, categories, price) before the image, so the model writes with product context rather than describing the image generically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Server-side compression: why not an external service?
&lt;/h2&gt;

&lt;p&gt;Most compression plugins — ShortPixel, Imagify, TinyPNG — upload your images to an external API, compress them there, and download the result. This makes sense if your server doesn't have good image libraries. But most modern hosts have Imagick installed, and it's plenty capable.&lt;/p&gt;

&lt;p&gt;ImageCraft compresses directly on the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect if Imagick is available; fall back to GD&lt;/li&gt;
&lt;li&gt;Strip EXIF metadata and color profiles (these add kilobytes browsers don't use)&lt;/li&gt;
&lt;li&gt;Apply progressive JPEG encoding (loads top-to-bottom instead of block-by-block)&lt;/li&gt;
&lt;li&gt;Optionally downscale if the longest edge exceeds a threshold (default 2048px)&lt;/li&gt;
&lt;li&gt;Write to a temp file and compare sizes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only commit if the compressed file is actually smaller&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step is important. Some already-optimized images (especially PNGs with good compression) get larger after re-encoding. The never-enlarge guard prevents this silently.&lt;/p&gt;

&lt;p&gt;Originals are backed up to &lt;code&gt;wp-content/uploads/icais-originals/&lt;/code&gt; with the same directory structure. One-click restore copies the backup over the compressed version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified never-enlarge guard&lt;/span&gt;
&lt;span class="nv"&gt;$tempPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;compressToTemp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$quality&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="nb"&gt;filesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tempPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;filesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tempPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// original is already optimal&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  WebP sidecars: the least-invasive approach
&lt;/h2&gt;

&lt;p&gt;There are broadly three ways to serve WebP on WordPress:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rewrite rules&lt;/strong&gt; — &lt;code&gt;.htaccess&lt;/code&gt; or nginx config that serves a &lt;code&gt;.webp&lt;/code&gt; file when the browser sends &lt;code&gt;Accept: image/webp&lt;/code&gt;. Fragile. Breaks on some hosts. Requires server-level config.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;URL replacement&lt;/strong&gt; — change the image URL in the HTML from &lt;code&gt;.jpg&lt;/code&gt; to &lt;code&gt;.webp&lt;/code&gt;. Breaks caching. Confuses CDNs. Hard to reverse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; wrapping&lt;/strong&gt; — keep the original &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; intact, wrap it in a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element with &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; entries for WebP/AVIF. The browser picks the best format. The original URL stays as the fallback.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We went with approach 3. The converter generates sidecar files (&lt;code&gt;photo.jpg.webp&lt;/code&gt;, &lt;code&gt;photo.jpg.avif&lt;/code&gt;) next to the originals — for the full-size image and all registered intermediate sizes. A manifest is stored in post meta (&lt;code&gt;_icais_nextgen&lt;/code&gt;) with relative paths and byte savings.&lt;/p&gt;

&lt;p&gt;On the frontend, two hooks handle delivery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wp_content_img_tag&lt;/code&gt; (WP 6.0+) — wraps content images&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;post_thumbnail_html&lt;/code&gt; — wraps featured images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both hooks bail on &lt;code&gt;is_admin()&lt;/code&gt; and are wrapped in &lt;code&gt;try/catch (\Throwable)&lt;/code&gt; so a conversion bug can never break the frontend rendering. The worst case is: the &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; wrapper fails silently and the original &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; renders as it always did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Before --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"photo.jpg"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"photo-300x200.jpg 300w, photo-768x512.jpg 768w"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- After (automatic, on the frontend only) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;
          &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"photo.jpg.webp 1024w, photo-300x200.jpg.webp 300w, photo-768x512.jpg.webp 768w"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"photo.jpg"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"photo-300x200.jpg 300w, photo-768x512.jpg 768w"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleanup removes all sidecar files and the post meta. The original images and markup are exactly as they were before.&lt;/p&gt;




&lt;h2&gt;
  
  
  Broken image fallbacks: a &lt;code&gt;get_post_metadata&lt;/code&gt; trick
&lt;/h2&gt;

&lt;p&gt;The broken image fallback has two parts: a frontend JavaScript listener and a PHP metadata filter.&lt;/p&gt;

&lt;p&gt;The JS part is straightforward — a capture-phase &lt;code&gt;error&lt;/code&gt; listener on &lt;code&gt;document&lt;/code&gt; that catches &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; load failures and swaps in the fallback URL. It also does an initial sweep for images with &lt;code&gt;naturalWidth === 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The PHP part is more interesting. For missing featured images, we needed &lt;code&gt;has_post_thumbnail()&lt;/code&gt; to return &lt;code&gt;true&lt;/code&gt; even when the real thumbnail is gone. The function checks &lt;code&gt;get_post_meta($postId, '_thumbnail_id', true)&lt;/code&gt;, and if that returns a valid attachment ID that happens to be deleted, &lt;code&gt;has_post_thumbnail()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; but the image render fails.&lt;/p&gt;

&lt;p&gt;For posts where &lt;code&gt;_thumbnail_id&lt;/code&gt; is completely empty (the meta doesn't exist), we hook &lt;code&gt;get_post_metadata&lt;/code&gt; with a filter:&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;'get_post_metadata'&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;$value&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="nv"&gt;$metaKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$metaKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'_thumbnail_id'&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;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... bail checks, recursion guard, settings check ...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$fallbackImageId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes &lt;code&gt;has_post_thumbnail()&lt;/code&gt; return &lt;code&gt;true&lt;/code&gt; for any post type in the allow-list, and &lt;code&gt;get_the_post_thumbnail()&lt;/code&gt; renders the fallback image. The real database is never modified — it's a read-time filter only.&lt;/p&gt;

&lt;p&gt;The recursion guard matters because &lt;code&gt;get_post_meta&lt;/code&gt; for the fallback image's own &lt;code&gt;_thumbnail_id&lt;/code&gt; would trigger the same filter. A static flag prevents the infinite loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage limiting without being annoying
&lt;/h2&gt;

&lt;p&gt;The free tier allows 50 actions per day across a rolling 24-hour window. Generation and compression share the same pool.&lt;/p&gt;

&lt;p&gt;The implementation uses a single &lt;code&gt;wp_options&lt;/code&gt; key per user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stored as: icais_daily_usage_{userId}&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'started_at'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1691942400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// window start timestamp&lt;/span&gt;
    &lt;span class="s1"&gt;'ids'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// attachment IDs (deduped)&lt;/span&gt;
    &lt;span class="s1"&gt;'compress_count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// compression count (NOT deduped)&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's an intentional asymmetry here: generation is deduped per attachment ID (re-generating alt text for the same image within the window is free), while every compression counts. The reasoning is that regenerating alt text is a common review workflow (generate, reject, regenerate with a different tone), but re-compressing the same image is an unusual action that probably indicates a settings change.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;totalCount()&lt;/code&gt; = &lt;code&gt;count(ids) + compress_count&lt;/code&gt;. When it hits 50, the API returns a 429 with a human-readable message about when the window resets.&lt;/p&gt;

&lt;p&gt;Pro (detected via the &lt;code&gt;ICAIS_PRO&lt;/code&gt; constant defined by the separate Pro add-on plugin) bypasses the check entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things we got wrong and fixed
&lt;/h2&gt;

&lt;p&gt;A few mistakes from earlier versions, in case they're useful to someone building similar tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catching &lt;code&gt;\Exception&lt;/code&gt; instead of &lt;code&gt;\Throwable&lt;/code&gt;.&lt;/strong&gt; PHP 7+ throws &lt;code&gt;\Error&lt;/code&gt; for things like missing classes and type mismatches. &lt;code&gt;catch (\Exception)&lt;/code&gt; doesn't catch those. Any integration point with an external library or optional dependency now catches &lt;code&gt;\Throwable&lt;/code&gt;. This one nearly caused a white-screen on a site that had an older PHP version with a missing Imagick extension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings double-prefix.&lt;/strong&gt; Our settings system prepends &lt;code&gt;icais_&lt;/code&gt; to every key, and the keys in the defaults array already start with &lt;code&gt;icais_&lt;/code&gt;. So everything is stored as &lt;code&gt;icais_icais_default_tone&lt;/code&gt;. We caught this too late to fix without breaking existing installations. It works. It's ugly. It's documented. We moved on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wizard gate.&lt;/strong&gt; Early versions required an API key to get past the setup wizard. Then we added compression, which doesn't need an API key at all. Users who only wanted compression were blocked by a screen asking for an AI provider key. The wizard is now skippable, and the plugin works fine without any API key configured — you just don't get the AI features.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;ImageCraft is free on &lt;a href="https://wordpress.org/plugins/imagecraft-ai-alt-text-file-renamer-image-seo/" rel="noopener noreferrer"&gt;WordPress.org&lt;/a&gt;. Search "ImageCraft" in your plugin installer, or search for "imagecraft ai alt text" to find it.&lt;/p&gt;

&lt;p&gt;Powered By &lt;a href="https://www.softminal.com/" rel="noopener noreferrer"&gt;Softminal&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>performance</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Why Most WooCommerce 'B2B' Plugins Break at Checkout (and How We Fixed It)</title>
      <dc:creator>Softminal</dc:creator>
      <pubDate>Thu, 13 Aug 2026 04:17:30 +0000</pubDate>
      <link>https://dev.to/softminal/why-most-woocommerce-b2b-plugins-break-at-checkout-and-how-we-fixed-it-3oop</link>
      <guid>https://dev.to/softminal/why-most-woocommerce-b2b-plugins-break-at-checkout-and-how-we-fixed-it-3oop</guid>
      <description>&lt;p&gt;You know the bug before you've even opened the store. The product page says $12. The cart says $10.80. Checkout Blocks says something else entirely. And now it's late, you've got six plugins active, and you're grepping for &lt;code&gt;woocommerce_product_get_price&lt;/code&gt; trying to work out which one fired last.&lt;/p&gt;

&lt;p&gt;I've lost enough evenings to that exact thing that I stopped blaming the plugins. None of them is &lt;em&gt;wrong&lt;/em&gt;, really. They're just five strangers hooking the same filter, and the price a customer pays comes down to load order. That's not a store. That's a negotiation, and it's happening live in front of someone with a credit card out.&lt;/p&gt;

&lt;p&gt;We built &lt;strong&gt;Softminal B2B Suite for WooCommerce&lt;/strong&gt; mostly to stop living like that. So instead of a feature tour, here's what actually changed underneath — the three calls that decide whether a B2B plugin is safe on a store taking real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that keeps breaking
&lt;/h2&gt;

&lt;p&gt;Go look at the plugins page of pretty much any WooCommerce store selling to businesses. You'll find some version of this:&lt;/p&gt;

&lt;p&gt;wholesale pricing, a "hide price until login" thing, a plugin for registration fields, request-a-quote, min/max quantities, and something for invoices. Six add-ons. Each one solves its own slice fine. The trouble is none of them knows the others exist, and they're all elbowing for the same price and visibility filters.&lt;/p&gt;

&lt;p&gt;So the final price isn't decided by a rule you can point at. It's decided by whoever hooked in last. And when a buyer emails asking why their line came to $68.40, you honestly can't tell them — because nothing in your store &lt;em&gt;knows&lt;/em&gt; either. You'd be guessing along with them.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;One resolver. Everything goes through it.&lt;/strong&gt; That's a rule we actually enforce on ourselves in the codebase — price mutation runs through a single &lt;code&gt;Pricing\Resolver&lt;/code&gt; pipeline, and if you find a stray Woo price filter somewhere, that's a bug to rip out, not a feature.&lt;/p&gt;

&lt;p&gt;When someone looks at a product, the pipeline walks the options in a fixed order and stops at the first hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accepted quote
  → customer-specific rule
    → company rule
      → group rule
        → category / tag rule
          → sale price
            → regular price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing clever. The whole point is that it's boring and it's &lt;em&gt;one path&lt;/em&gt;. The same walk answers the shop, the product page, the cart, the Store API that Checkout Blocks reads from, and the little "why this price" panel. There's no second code path that can quietly disagree with the first.&lt;/p&gt;

&lt;p&gt;And that second-order effect is the part I care about most. Because the explainer is generated from the same walk that charges the customer, it can't drift. When a price panel &lt;em&gt;recomputes&lt;/em&gt; the answer on its own, you've just written a second bug that's going to disagree with the first one eventually. Ours literally can't, because it's the same code. So when that "why is this $68.40?" email lands, you pull up which rule won and which ones it skipped, and you answer it in ten seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The postmeta trap
&lt;/h2&gt;

&lt;p&gt;The other line we won't cross: no B2B data in postmeta. Pricing rules, companies, quotes, credit — all of it lives in its own &lt;code&gt;smb2b_&lt;/code&gt; tables.&lt;/p&gt;

&lt;p&gt;I get why people reach for postmeta. It's right there, it's easy, &lt;code&gt;update_post_meta&lt;/code&gt; and you're done. And then six months later the shop page is doing a &lt;code&gt;meta_query&lt;/code&gt; join against a table with a few million rows and taking four seconds to paint, and nobody can figure out why.&lt;/p&gt;

&lt;p&gt;B2B pricing is a relational problem wearing a shopping-cart costume. Customers belong to groups, groups own rules, rules target categories, companies carry a credit ledger. Model that in real indexed tables and pricing stays quick as the catalogue grows, a credit ledger is an actual append-only ledger you can query instead of a serialized blob, and &lt;code&gt;uninstall.php&lt;/code&gt; can drop the whole thing cleanly instead of leaving junk scattered across &lt;code&gt;wp_postmeta&lt;/code&gt; forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  HPOS and Blocks aren't a "later" thing
&lt;/h2&gt;

&lt;p&gt;A B2B plugin lives at the checkout, which is the one part of WordPress that punishes you hardest for cutting corners. Two things there just aren't negotiable for us.&lt;/p&gt;

&lt;p&gt;All order access goes through &lt;code&gt;WC_Order&lt;/code&gt; CRUD — never a direct query against &lt;code&gt;wp_posts&lt;/code&gt;. If your plugin is still reading orders out of the posts table, it's already broken on any store that's flipped on HPOS, whether or not anyone's noticed yet.&lt;/p&gt;

&lt;p&gt;And every checkout-affecting feature has to work in classic checkout &lt;em&gt;and&lt;/em&gt; Cart &amp;amp; Checkout Blocks, Store API included. "Works on classic" is half a plugin shipped as a whole one. A lot of the older B2B plugins predate both HPOS and Blocks and got patched forward — which means building on them today is inheriting all of that debt on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually gives you
&lt;/h2&gt;

&lt;p&gt;None of the above matters to a store owner on its own — it matters because of what it lets the plugin do without falling over:&lt;/p&gt;

&lt;p&gt;group and wholesale pricing (percentage, amount off, or fixed — shop-wide down to a single customer), hide prices until login, trade registration with an approval queue, company accounts with roles and spend limits and approval chains, net terms and credit on a real ledger, request-a-quote that turns an accepted quote into a real order at the agreed numbers, and a bulk order form for the fifty-line reorder by SKU or pasted CSV.&lt;/p&gt;

&lt;p&gt;The free plugin on WordPress.org runs a full single-group wholesale channel end to end — it's not a nagware trial. Pro stacks the organisational stuff on top (unlimited groups, companies, credit, quoting, EU VIES VAT validation, webhooks) on the &lt;em&gt;same&lt;/em&gt; tables and the &lt;em&gt;same&lt;/em&gt; resolver, so you install free first and add Pro whenever your buyers force the issue, without redoing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're evaluating one of these
&lt;/h2&gt;

&lt;p&gt;Skip the feature grid for a minute. The questions that actually predict whether you'll be up at 11pm are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does &lt;strong&gt;one&lt;/strong&gt; thing decide the price, or do several filters race for it?&lt;/li&gt;
&lt;li&gt;Can it &lt;strong&gt;explain&lt;/strong&gt; a price using the same code that charged it?&lt;/li&gt;
&lt;li&gt;Is the B2B data in &lt;strong&gt;real tables&lt;/strong&gt;, or smeared across postmeta?&lt;/li&gt;
&lt;li&gt;Does it work in &lt;strong&gt;Blocks and HPOS&lt;/strong&gt;, or just classic?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those four right and honestly the features sort themselves out. Get them wrong and no feature list is going to save you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Softminal B2B Suite is live on WordPress.org&lt;/strong&gt; — free is a real wholesale channel on its own. &lt;a href="https://b2bsuitewp.com/" rel="noopener noreferrer"&gt;b2bsuitewp.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Real B2B deserves better than a discount table. And a lot better than five of them stitched together.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>wholesale</category>
      <category>b2b</category>
    </item>
  </channel>
</rss>
