<?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: Robin Dhiman</title>
    <description>The latest articles on DEV Community by Robin Dhiman (@iamrobindhiman).</description>
    <link>https://dev.to/iamrobindhiman</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%2F3983853%2Fcd0105a9-6591-488d-93d0-be66eb1f2ba8.jpeg</url>
      <title>DEV Community: Robin Dhiman</title>
      <link>https://dev.to/iamrobindhiman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iamrobindhiman"/>
    <language>en</language>
    <item>
      <title>What a minimal JS framework hands back to you</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Fri, 07 Aug 2026 07:29:43 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/what-a-minimal-js-framework-hands-back-to-you-1494</link>
      <guid>https://dev.to/iamrobindhiman/what-a-minimal-js-framework-hands-back-to-you-1494</guid>
      <description>&lt;p&gt;Every few months someone publishes a minimal UI library in a few hundred lines of vanilla JavaScript and the comments split into the same two camps. One side says this is all anyone ever needed. The other says wait until you have a real application.&lt;/p&gt;

&lt;p&gt;Both camps are arguing about the wrong thing. I've spent the last few years building Magento storefronts on Hyvä, which is that argument made real: no React, no Knockout, no RequireJS, just Alpine and Tailwind. The storefronts are fast. The bundle argument is settled and it was never very interesting.&lt;/p&gt;

&lt;p&gt;What nobody writes about is the bill. When you remove a framework you don't just remove its bytes. You take back a set of responsibilities it was handling without telling you, and you get no warning about which ones. Here are the three that cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Initialisation timing becomes your problem
&lt;/h2&gt;

&lt;p&gt;React owns the lifecycle. A component mounts, effects run, and if it renders you know it initialised. That link is so reliable you stop thinking of it as a feature.&lt;/p&gt;

&lt;p&gt;A minimal setup breaks the link, and it breaks it deliberately, because initialising every interactive component on page load is exactly the cost you were trying to avoid. So components get deferred: registered now, initialised later, on some trigger.&lt;/p&gt;

&lt;p&gt;Hyvä does this with an &lt;code&gt;x-defer&lt;/code&gt; attribute. The markup is on the page, the component is registered, and Alpine doesn't touch it until the trigger fires. Great for a product page with a dozen interactive widgets below the fold.&lt;/p&gt;

&lt;p&gt;The failure mode is nasty. I once had a configurable-product size picker render as a thin strip with the label and no options in it. No console errors. The component function was defined on &lt;code&gt;window&lt;/code&gt;. The catalogue data was fine, five salable children, the attribute mapped correctly.&lt;/p&gt;

&lt;p&gt;Everything you'd check to diagnose a bug came back clean, because it wasn't a bug. The component simply hadn't been woken up. Its trigger never fired in that layout. It was sitting there fully registered and completely inert.&lt;/p&gt;

&lt;p&gt;That reads exactly like a data problem or a broken build. I checked the indexer. I checked the swatch attribute. I rebuilt static content twice. The actual answer was one attribute deciding &lt;em&gt;when&lt;/em&gt;, and nothing in the failure told me timing was the axis to look at.&lt;/p&gt;

&lt;p&gt;With React, "the component didn't render" and "the component didn't initialise" are the same event. Without it, they're two events, and only one of them leaves evidence. Add "is this thing actually initialised?" to the top of your debugging list, before you go anywhere near the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. State has to survive DOM it didn't create
&lt;/h2&gt;

&lt;p&gt;The second thing you inherit is harder, and it's specific to server-rendered commerce.&lt;/p&gt;

&lt;p&gt;In a React storefront the client owns the DOM. Cart state lives in a store, a mutation re-renders the subscribers, done. The framework's whole value proposition is that you describe state and never touch nodes.&lt;/p&gt;

&lt;p&gt;On a server-rendered storefront, large parts of the page arrive as HTML from the server, and some of them arrive &lt;em&gt;again&lt;/em&gt; after an interaction. Add to cart, and a section of markup is replaced wholesale by fresh HTML. Any JavaScript state attached to the old nodes goes with them.&lt;/p&gt;

&lt;p&gt;So you need a rule about where state lives, and you need it before you write the second component, not after. Roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State the server owns&lt;/strong&gt; (cart contents, customer group, prices) lives in a store the components read from, and gets refreshed from the server after a mutation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State the component owns&lt;/strong&gt; (is this dropdown open, what's in this quantity input) lives on the component and is allowed to die with its markup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get that boundary wrong in the obvious direction and you get the classic bug: a mini-cart that shows the right total until you add a second item, then shows a stale one, because two components each kept their own copy and only one of them heard about the update.&lt;/p&gt;

&lt;p&gt;React didn't solve this problem for you. It made it very hard to &lt;em&gt;express&lt;/em&gt; the broken version, which is not the same thing but works out similarly in practice. Write the boundary down. A comment at the top of your store file naming what's server-owned is worth more than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Content Security Policy stops being someone else's config
&lt;/h2&gt;

&lt;p&gt;This one only bites in a certain kind of project, but when it bites it invalidates a lot of code at once.&lt;/p&gt;

&lt;p&gt;A build step gives you a CSP-friendly bundle almost by accident: your JavaScript ends up in files, so a strict &lt;code&gt;script-src&lt;/code&gt; policy is satisfied without you thinking about it. Minimal setups tend to put behaviour in attributes, right in the markup, which is the whole ergonomic appeal: you can see what an element does by reading it.&lt;/p&gt;

&lt;p&gt;Under a strict CSP that's a problem, because expressions evaluated at runtime look like &lt;code&gt;eval&lt;/code&gt; to the browser. Alpine ships a CSP-compatible build precisely for this, and the tradeoff is that you can no longer write arbitrary expressions inline. Behaviour moves into registered component objects; the markup references methods and properties by name instead of evaluating logic.&lt;/p&gt;

&lt;p&gt;It's a fine constraint. It's just one you want to know about on day one, because retrofitting it means rewriting every component you wrote in the ergonomic style. If your project is likely to need a strict policy (anything handling payment, anything where a security review is part of shipping), start in the CSP-compatible style even while nothing is enforcing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the trade is worth it
&lt;/h2&gt;

&lt;p&gt;It's worth it more often than framework people admit and less often than the minimal-library posts imply.&lt;/p&gt;

&lt;p&gt;The minimal approach wins when the server already owns most of the truth. A commerce storefront renders catalogue and cart data that lives in a database and is authoritative there. Shipping a client framework to re-derive state the server already computed is paying twice. That's why Hyvä works, and it's a structural reason, not a taste one.&lt;/p&gt;

&lt;p&gt;It loses when the client is the source of truth: a configurator, a builder, a dashboard with heavy interdependent state, anything where the interesting state exists only in the browser and needs to stay coherent across many views. There, the framework's rendering model is the product, and hand-rolling it means writing a worse React with none of the documentation.&lt;/p&gt;

&lt;p&gt;The useful question isn't "do I need React." It's: which of these three responsibilities am I ready to own? Initialisation timing, state across server-rendered DOM, and policy constraints on where code lives. If the answer is all three, drop the framework and enjoy the speed. If you haven't thought about them, you haven't removed complexity. You've moved it somewhere with no error messages.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source / further reading: &lt;a href="https://pedroth.github.io/?p=post/NoNeedReact" rel="noopener noreferrer"&gt;https://pedroth.github.io/?p=post/NoNeedReact&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hyv</category>
      <category>javascript</category>
      <category>magento2</category>
      <category>frontend</category>
    </item>
    <item>
      <title>The shipping rate your cart quotes is not the one you get billed</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:49:46 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/the-shipping-rate-your-cart-quotes-is-not-the-one-you-get-billed-467e</link>
      <guid>https://dev.to/iamrobindhiman/the-shipping-rate-your-cart-quotes-is-not-the-one-you-get-billed-467e</guid>
      <description>&lt;p&gt;A store I worked on was quoting shipping at checkout from product weight alone. The numbers looked fine. Every order shipped. Nothing errored.&lt;/p&gt;

&lt;p&gt;The margin on bulky items was quietly gone.&lt;/p&gt;

&lt;p&gt;Couriers do not bill dead weight. They bill the &lt;strong&gt;greater&lt;/strong&gt; of dead weight and volumetric weight, where volumetric weight is &lt;code&gt;L × B × H ÷ 5000&lt;/code&gt; for most Indian carriers. A large, light parcel (a pillow, a lampshade, anything foam-packed) has a volumetric weight several times its actual weight. Rate on the scale reading alone and the store eats the difference on every one of those orders.&lt;/p&gt;

&lt;p&gt;That part is not a secret. It is printed in the courier's own terms. The interesting part is why a competent team ships the wrong calculation anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that is marked optional
&lt;/h2&gt;

&lt;p&gt;Open a courier's rate calculator and you will usually find a form roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pickup pincode&lt;/li&gt;
&lt;li&gt;Delivery pincode&lt;/li&gt;
&lt;li&gt;Weight (kg)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dimensions (Optional)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Leave the dimensions blank and the calculator does not complain. It does not warn you. It returns a rate.&lt;/p&gt;

&lt;p&gt;It also returns, in the breakdown, &lt;code&gt;Volumetric Weight: 0.00 KG&lt;/code&gt; and an applicable weight equal to the dead weight you typed. The quote that comes back is the dead-weight quote: for a bulky parcel, roughly half of what the courier will actually invoice.&lt;/p&gt;

&lt;p&gt;So the calculator is not lying. It answered the question you asked. You asked "what does a 2.86 kg parcel of unspecified shape cost," and the only honest answer to that is a dead-weight rate, because shape is the missing variable.&lt;/p&gt;

&lt;p&gt;But nobody reads it that way. You read it as "what does my parcel cost," and the interface encouraged you to, by marking the input that changes the answer as optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "optional" is the wrong word
&lt;/h2&gt;

&lt;p&gt;There are two kinds of optional input, and interfaces routinely conflate them.&lt;/p&gt;

&lt;p&gt;The first kind genuinely does not affect the result. A note on the order. A nickname on the address. Omit it and the number is identical.&lt;/p&gt;

&lt;p&gt;The second kind affects the result enormously, but the system has a fallback so it does not have to stop. Dimensions are this kind. Omitting them does not remove volumetric weight from the pricing model. It substitutes zero and carries on. Zero is a valid number. It is arithmetically fine. It is also the single most optimistic value the field can take, so the resulting quote is not merely wrong, it is wrong in the direction that looks best.&lt;/p&gt;

&lt;p&gt;The general shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An input marked optional whose omission silently selects a default is not optional. It is a required input with a hidden, favourable answer pre-filled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the same class of failure as a config flag that defaults to the permissive setting, or an ORM that silently coerces a bad string to &lt;code&gt;0&lt;/code&gt;. No error, valid-looking output, wrong result. You only find out downstream, when reality disagrees with your number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Run the arithmetic on a single parcel. A box 40 × 35 × 30 cm weighing 2.86 kg:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;volumetric = (40 × 35 × 30) / 5000
           = 42000 / 5000
           = 8.4 kg

applicable = max(2.86, 8.4) = 8.4 kg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You quoted for 2.86 kg. You get invoiced for 8.4. On a courier slab priced per half-kilo, that is not a rounding error. It is roughly triple the weight you charged for.&lt;/p&gt;

&lt;p&gt;Now notice what makes it hard to catch. The loss does not appear as a failed order or a support ticket. It appears as a courier invoice that is somewhat higher than expected, every month, spread across the subset of orders containing bulky items. There is no single event to investigate. It reads as "shipping is expensive," which is a sentence every merchant already believes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it in the catalog, not the checkout
&lt;/h2&gt;

&lt;p&gt;The temptation is to patch the shipping calculation: clamp it, add a fudge factor, apply a percentage uplift to bulky categories. Don't. A fudge factor is a second wrong number chosen to cancel the first one, and it stops cancelling the moment the product mix changes.&lt;/p&gt;

&lt;p&gt;The real fix is upstream and boring: &lt;strong&gt;dimensions are catalog data, and they are mandatory.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Magento, &lt;code&gt;product.weight&lt;/code&gt; exists out of the box and length, width and height do not. Whatever you do, the rating code needs all four. Something along these 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;applicableWeight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$deadWeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="nv"&gt;$dims&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&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;$dims&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LocalizedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Cannot rate a shipment without dimensions.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$volumetric&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'l'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$dims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'b'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$dims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'h'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DIVISOR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deadWeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$volumetric&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 throw is the important line, and it is the one that gets argued about. A missing dimension is not a case to default through. It is a product that cannot be shipped correctly, and you would much rather learn that while adding the product than three weeks later on an invoice.&lt;/p&gt;

&lt;p&gt;Two details worth pinning down before you write this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The divisor is per-carrier, and it belongs in config.&lt;/strong&gt; 5000 is common for Indian domestic courier services, but it is a carrier term, not a law of physics. Different carriers and different service classes use different divisors. Hardcode it and you have built the same silent-wrong-number bug one level up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dimensions are the shipped box, not the product.&lt;/strong&gt; A folded garment ships flat, a rolled poster ships in a tube, and multiple items may consolidate into one carton. If your packing logic combines items, the volumetric calculation has to run against the resulting box, not against the sum of the products. Getting the divisor right and the box wrong leaves you exactly where you started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that generalises
&lt;/h2&gt;

&lt;p&gt;The shipping bug is worth fixing. The habit behind it is worth more.&lt;/p&gt;

&lt;p&gt;When a calculator, an API, or an admin form gives you a number you are going to make decisions with, ask one question before you trust it: &lt;strong&gt;which inputs did I leave empty, and what did the system substitute?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is "nothing, it ignores them," fine. If the answer is "zero," or "the default plan," or "the aggregate across all locations," you did not get an answer to your question. You got an answer to an easier one, and the difference between those two questions is where the money goes.&lt;/p&gt;

&lt;p&gt;The rate calculator was never broken. It was doing arithmetic on the numbers it was given. The blank field was the bug.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source / further reading: &lt;a href="https://github.com/mage-os/mageos-magento2/releases/tag/3.3.0" rel="noopener noreferrer"&gt;https://github.com/mage-os/mageos-magento2/releases/tag/3.3.0&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>performance</category>
      <category>magento2</category>
      <category>shipping</category>
    </item>
    <item>
      <title>Three reasons a Tailwind v4 utility silently does nothing in Hyvä</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:18:58 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/three-reasons-a-tailwind-v4-utility-silently-does-nothing-in-hyva-35ef</link>
      <guid>https://dev.to/iamrobindhiman/three-reasons-a-tailwind-v4-utility-silently-does-nothing-in-hyva-35ef</guid>
      <description>&lt;p&gt;I was deep in a Hyvä v3 theme rebuild, running Tailwind v4. One bug kept coming back wearing a different mask each time. I'd write a utility class, reload, and nothing happened. The element ignored me. My first instinct was always the same: "something more specific is overriding it."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn7b3dxtykmte89aj7rt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn7b3dxtykmte89aj7rt.png" alt="Flowchart: a Tailwind utility with no effect branches on whether DevTools shows a declaration; no declaration means the rule was never generated, an overridden declaration in the no-layer group means " width="800" height="975"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It almost never was.&lt;/p&gt;

&lt;p&gt;Over the foundation work and then the homepage, I root-caused this "my class does nothing" symptom three separate times. Three different mechanisms. Two of them look exactly like a specificity fight but are not. Here's how to tell them apart in seconds instead of squinting at the cascade in DevTools for twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The rule was never generated
&lt;/h2&gt;

&lt;p&gt;This is the Tailwind v4 trap that catches everyone coming from v3.&lt;/p&gt;

&lt;p&gt;In v4 you can switch off automatic content detection with &lt;code&gt;source(none)&lt;/code&gt;, then register what Tailwind should scan using &lt;code&gt;@source&lt;/code&gt;. Hyvä leans on this so the compiled CSS stays lean.&lt;/p&gt;

&lt;p&gt;The trap: use a utility in a template that no &lt;code&gt;@source&lt;/code&gt; path covers, and Tailwind never sees the class. It never generates the rule. So &lt;code&gt;class="mt-8"&lt;/code&gt; sits in your markup pointing at CSS that does not exist.&lt;/p&gt;

&lt;p&gt;Open DevTools. The element has the class. There is no &lt;code&gt;margin-top&lt;/code&gt; declaration anywhere, not struck through, just absent. That absence is the tell. A specificity loss shows a crossed-out declaration. A missing rule shows nothing at all.&lt;/p&gt;

&lt;p&gt;Fix: add the directory to &lt;code&gt;@source&lt;/code&gt; and recompile. Most of the time the path glob was simply too narrow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;@source&lt;/span&gt; &lt;span class="s1"&gt;"../../**/*.phtml"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@source&lt;/span&gt; &lt;span class="s1"&gt;"../../**/*.html"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Cascade layers beat specificity
&lt;/h2&gt;

&lt;p&gt;This is the sneaky one.&lt;/p&gt;

&lt;p&gt;Tailwind v4 emits its utilities inside native CSS cascade layers. Layers carry a rule most of us never had to learn before: any unlayered CSS beats any layered CSS, whatever the specificity.&lt;/p&gt;

&lt;p&gt;So a stray rule with no layer (a third-party module's stylesheet, a hand-written block you forgot to wrap) will beat your Tailwind utility. Your selector can be more specific and still lose. That is what makes it read as a specificity bug. You inspect both rules, yours looks like it should win, and it doesn't.&lt;/p&gt;

&lt;p&gt;The tell is in DevTools: the winning rule sits in the no-layer group, your utility sits under a named layer below it. Once you know to read the layer column, it is obvious. Until you do, it is maddening.&lt;/p&gt;

&lt;p&gt;Fix: move the offending CSS into a layer, or stop loading unlayered third-party CSS after Tailwind.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. It actually is specificity
&lt;/h2&gt;

&lt;p&gt;Sometimes the boring answer is correct.&lt;/p&gt;

&lt;p&gt;A base-theme selector genuinely is more specific than your single utility. Or, the v4 twist, two utilities of equal specificity resolve by their order in the compiled stylesheet, not the order you wrote them in &lt;code&gt;class=""&lt;/code&gt;. Writing &lt;code&gt;class="p-2 p-4"&lt;/code&gt; does not guarantee &lt;code&gt;p-4&lt;/code&gt; wins. The winner depends on where those rules land in the generated CSS.&lt;/p&gt;

&lt;p&gt;This is the only one of the three where the old model still holds: compare specificity, later source wins ties. Which is exactly why it pays to rule out the other two first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The triage
&lt;/h2&gt;

&lt;p&gt;When a utility does nothing, before you touch specificity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No declaration at all in DevTools? It is #1. The rule was not generated. Fix &lt;code&gt;@source&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Declaration present but the winner sits in the no-layer group? It is #2. Cascade layers. Move the loser into a layer.&lt;/li&gt;
&lt;li&gt;Declaration present, winner more specific or later in source? It is #3. Real specificity. Now you can fight it honestly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of the three were never a specificity problem. Knowing that turns a twenty-minute DevTools dig into a ten-second glance at the right column.&lt;/p&gt;

</description>
      <category>hyv</category>
      <category>tailwindcss</category>
      <category>magento2</category>
      <category>css</category>
    </item>
    <item>
      <title>The Magento N+1 fix that hides your out-of-stock products</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Fri, 31 Jul 2026 06:50:08 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/the-magento-n1-fix-that-hides-your-out-of-stock-products-1n4</link>
      <guid>https://dev.to/iamrobindhiman/the-magento-n1-fix-that-hides-your-out-of-stock-products-1n4</guid>
      <description>&lt;p&gt;I had a category page doing something expensive. For every product in the grid it called &lt;code&gt;ProductRepositoryInterface::getById()&lt;/code&gt; to read two custom attributes. That is the textbook N+1: on a category with 300 products, 300 separate product loads. The page felt every one of them.&lt;/p&gt;

&lt;p&gt;The fix looks obvious. Stop loading products one at a time and pull them in a single collection query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;collectionFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'custom_a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'custom_b'&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;addIdFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query instead of 300. The page got faster. And a few products quietly vanished from the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  getById() and a collection are not the same query
&lt;/h2&gt;

&lt;p&gt;It is tempting to read the swap as "same rows, fewer queries." In the frontend area, that is not true. The two return different sets of rows.&lt;/p&gt;

&lt;p&gt;Magento's &lt;code&gt;CatalogInventory&lt;/code&gt; module attaches an in-stock filter to product collections in the frontend when the "Display Out of Stock Products" setting is off. Off is the default. So a plain product collection loaded on the storefront quietly excludes every out-of-stock product before your code ever sees it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProductRepositoryInterface::getById()&lt;/code&gt; does no such thing. Ask it for an out-of-stock product by id and you get the product. That asymmetry is the whole bug. The repository answers "does this product exist"; the frontend collection answers "is this product buyable right now," and nobody told you the question changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is silent
&lt;/h2&gt;

&lt;p&gt;There is no exception. Nothing in the logs. The collection simply returns fewer rows than the ids you passed in.&lt;/p&gt;

&lt;p&gt;If your code iterates the collection to build a map keyed by product id, the out-of-stock ids are not wrong values. They are missing keys. Any lookup that expects them falls through to its default branch. An empty label, a skipped badge, a price that never gets set, with no signal that anything went wrong.&lt;/p&gt;

&lt;p&gt;That is what makes it dangerous. Every test written with in-stock fixtures passes. The failure only shows up on a real catalog, where some fraction of products is always out of stock, and it usually arrives weeks later as a vague "why is this product missing its custom label" ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting behind it
&lt;/h2&gt;

&lt;p&gt;The switch lives at Stores &amp;gt; Configuration &amp;gt; Catalog &amp;gt; Inventory &amp;gt; Stock Options &amp;gt; Display Out of Stock Products. Under the hood it is &lt;code&gt;cataloginventory/options/show_out_of_stock&lt;/code&gt;. Most stores leave it off, because most stores do not want to list things nobody can buy. That default is sensible for a category page and surprising inside your custom code.&lt;/p&gt;

&lt;p&gt;The filter itself comes from &lt;code&gt;Magento\CatalogInventory\Helper\Stock::addIsInStockFilterToCollection()&lt;/code&gt;, applied to storefront product collections. Knowing the method name is enough to grep for it and see exactly where the join gets added.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Need every product regardless of stock?&lt;/strong&gt; A frontend product collection is the wrong tool, or you opt out of the stock filter deliberately for that one collection. Do it explicitly and leave a comment, because the next person will assume the filter belongs there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correctness matters more than the N+1?&lt;/strong&gt; Keep the repository. An N+1 across 300 products is a real cost, but returning the wrong data quickly is worse than returning the right data slowly. Measure before you assume the collection is the win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want both?&lt;/strong&gt; Load the ids you actually need with a resource model or a raw select you control, instead of a catalog product collection that Magento has been quietly editing. You get the single-query speed without inheriting the storefront's stock semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;The trap is not specific to inventory. Any time you replace a per-entity repository call with a collection to kill an N+1, you inherit whatever plugins, observers, and area-specific filters that collection carries. The speed-up is real. The "behaviour-preserving" assumption is the part to check.&lt;/p&gt;

&lt;p&gt;A faster query that returns a different answer is not an optimisation. It is a bug with good latency.&lt;/p&gt;

</description>
      <category>magento2</category>
      <category>php</category>
      <category>performance</category>
    </item>
    <item>
      <title>AI made writing code cheap. It did not make systems easier to reason about.</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:44:00 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/ai-made-writing-code-cheap-it-did-not-make-systems-easier-to-reason-about-2g60</link>
      <guid>https://dev.to/iamrobindhiman/ai-made-writing-code-cheap-it-did-not-make-systems-easier-to-reason-about-2g60</guid>
      <description>&lt;p&gt;A question has been going around: if coding has been solved, why does software keep getting worse?&lt;/p&gt;

&lt;p&gt;The usual answer is that AI writes sloppy code and we're all drowning in it. I don't think that's right. The generated code I review is mostly fine. It compiles, it has tests, it follows the conventions in the file it's sitting in. The problem is upstream of the code.&lt;/p&gt;

&lt;p&gt;Writing code was never the expensive part of a mature e-commerce codebase. Reading one was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two costs, and only one of them moved
&lt;/h2&gt;

&lt;p&gt;Every change to a running system has two costs.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;local cost&lt;/strong&gt; is producing the change: writing the class, the observer, the migration, the test. This is what code generation attacks, and it attacks it well. A task that used to take forty minutes of typing and doc-lookup now takes five.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;global cost&lt;/strong&gt; is knowing whether the change is safe. What else fires when this event fires? Which of the eleven modules touching this area also modified this behaviour? Does the interceptor I'm adding run before or after the one the payment extension registered? Is there a cache layer that will serve the old value for the next six hours?&lt;/p&gt;

&lt;p&gt;Code generation does almost nothing for the second cost. It can read files you point it at. It cannot tell you which files to point it at, because that knowledge isn't in any one file. It's distributed across a plugin registry, a set of XML declarations, a dependency-injection graph, and a database column somebody added in 2019.&lt;/p&gt;

&lt;p&gt;When you cut one cost to near-zero and leave the other alone, the ratio between them changes. Changes get made faster than anyone can reason about them. That's the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why extensibility is the thing that hurts
&lt;/h2&gt;

&lt;p&gt;The platforms I work on daily are built for extensibility, and extensibility is precisely the property that makes global reasoning expensive.&lt;/p&gt;

&lt;p&gt;Take a hook system, any hook system: WordPress filters, Magento plugins, Rails callbacks, Django signals, Laravel events. The design promise is the same: you can change behaviour without touching the original code. That promise is real, and it's the reason these ecosystems have tens of thousands of extensions.&lt;/p&gt;

&lt;p&gt;The cost is that the call site no longer tells you what runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;priceCalculator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What does that line do? You can't know from the line. In Magento 2 that method may be wrapped by any number of registered plugins across every installed module, each of which can rewrite the arguments, replace the return value, or skip the original entirely. The execution order is determined by a &lt;code&gt;sortOrder&lt;/code&gt; attribute in XML files scattered across those modules. Reading &lt;code&gt;PriceCalculator::calculate()&lt;/code&gt; tells you what the &lt;em&gt;original author&lt;/em&gt; intended. It does not tell you what happens on this installation on Tuesday.&lt;/p&gt;

&lt;p&gt;This is not a Magento complaint. It's the general shape. The abstraction that makes a platform extensible is the same abstraction that decouples the code you're reading from the code that runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI actually lands in that picture
&lt;/h2&gt;

&lt;p&gt;So picture what an assistant does inside that architecture.&lt;/p&gt;

&lt;p&gt;You ask for a change to how a price is calculated. The assistant reads the class, understands the class, and writes a clean, well-tested, conventional modification to the class. It is right about everything it can see.&lt;/p&gt;

&lt;p&gt;It is not wrong. It is &lt;em&gt;locally&lt;/em&gt; right in a system where local rightness was never the hard part. The eleventh plugin that quietly overrides the return value was never in the context window, because nothing in the file mentions it.&lt;/p&gt;

&lt;p&gt;I've watched this play out on a store where a discount stopped applying. The change that broke it was correct code, correctly tested. It just ran at a point in the chain where a third-party module had already replaced the value it was reading. No test caught it, because every test mocked the collaborators, which is what unit tests do. The mock is a statement of what you &lt;em&gt;believe&lt;/em&gt; the system does.&lt;/p&gt;

&lt;p&gt;That's the loop that makes software worse: cheap local edits, unchanged global comprehension, and a test suite that validates your beliefs rather than the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The move that has worked for me
&lt;/h2&gt;

&lt;p&gt;The fix I've landed on is not "use AI less." It's to spend the time the assistant saved on the cost it didn't reduce.&lt;/p&gt;

&lt;p&gt;Before accepting any change to shared behaviour, I make the global picture explicit: on paper, in the ticket, wherever. Concretely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enumerate the interception points.&lt;/strong&gt; For the method being changed, list every plugin, observer, event listener, or preference registered against it on &lt;em&gt;this&lt;/em&gt; installation. Not the ones you remember. The ones you can produce from the container. If you can't enumerate them, you don't know what your change does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify against the source of truth, not the docs.&lt;/strong&gt; I got burned badly enough by this to make it a rule: the installed vendor source is authoritative, and the documentation describes some other version. When a claim about framework behaviour matters, I read the vendor directory. Every time I've skipped that step to save ten minutes, it cost me an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the data layer separately.&lt;/strong&gt; Mocked tests never validate that a column exists. A query naming a field that lives on a different table passes every unit test in the suite and throws only in production. Read the schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the invariant.&lt;/strong&gt; Before hunting for problems, state what must stay true: "the sum of the line totals equals the order total," "this runs exactly once per order," "a released payment refuses if any refund is open." You can't find a violation of a rule you never named, and a change that preserves an unnamed invariant does so by luck.&lt;/p&gt;

&lt;p&gt;None of that is new. What's new is that it used to be a smaller fraction of the work, because writing the code took long enough to force you to think about the system while you did it. That accidental thinking time is gone. It has to be replaced deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'm less sure about
&lt;/h2&gt;

&lt;p&gt;I don't think this is permanent. An assistant that could walk a dependency-injection graph, resolve the plugin chain, and read the actual schema would attack the global cost directly, and the tooling is clearly heading that way.&lt;/p&gt;

&lt;p&gt;But it's not a code-generation problem, and it won't be fixed by a better code generator. It's a system-comprehension problem. Until the tools model the system rather than the file, the split holds: generation is cheap, comprehension is not, and every hour you save on the first should be treated as an hour you now owe the second.&lt;/p&gt;

&lt;p&gt;Software isn't getting worse because machines write bad code. It's getting worse because we removed the friction from the half of the job that was never the hard half, and mistook that for solving it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source / further reading: &lt;a href="https://ptrchm.com/posts/nothing-works-and-everyone-is-euphoric/" rel="noopener noreferrer"&gt;https://ptrchm.com/posts/nothing-works-and-everyone-is-euphoric/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>php</category>
      <category>aiassisteddevelopment</category>
      <category>magento2</category>
    </item>
    <item>
      <title>The Liquid you lint isn't the Liquid Shopify runs</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Thu, 16 Jul 2026 09:03:12 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/the-liquid-you-lint-isnt-the-liquid-shopify-runs-5bhp</link>
      <guid>https://dev.to/iamrobindhiman/the-liquid-you-lint-isnt-the-liquid-shopify-runs-5bhp</guid>
      <description>&lt;p&gt;&lt;code&gt;shopify theme check&lt;/code&gt; passed clean. The validator I run before every commit passed too. Then I opened the page on the dev store, and a size chart that builds its rows from a metafield rendered as one long, mangled line.&lt;/p&gt;

&lt;p&gt;The Liquid was correct. The tool that checked it wasn't running the same Liquid that Shopify runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two engines, one language
&lt;/h2&gt;

&lt;p&gt;Shopify's storefront renders Liquid with a Ruby engine, Shopify's own implementation, the one that has run the platform for years. Almost nothing you run locally uses it. &lt;code&gt;shopify theme check&lt;/code&gt;, the Shopify MCP validator, and nearly every Node-based Liquid simulator run &lt;strong&gt;LiquidJS&lt;/strong&gt;, a separate JavaScript reimplementation of the language.&lt;/p&gt;

&lt;p&gt;So you have two implementations of one templating language. They agree on the overwhelming majority of what you write. They do not agree on all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they split
&lt;/h2&gt;

&lt;p&gt;The gap I keep hitting is whitespace control. The trimming markers &lt;code&gt;{%-&lt;/code&gt; and &lt;code&gt;-%}&lt;/code&gt; strip surrounding whitespace, and the two engines don't always strip it identically. A few other rendering semantics differ too.&lt;/p&gt;

&lt;p&gt;For what a linter is actually for, LiquidJS is faithful: syntax, undefined objects, unknown filters and tags, schema shape. A green run tells you the template parses and references things that exist. It does not promise byte-for-byte identical output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a few bytes of whitespace become a real bug
&lt;/h2&gt;

&lt;p&gt;Most of the time you never notice, because HTML collapses runs of whitespace and the page looks fine either way. The exception is any code that treats rendered whitespace as data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;rows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;metafields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;size_chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;row_delimiter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;rows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Split a rendered string on a delimiter and the exact whitespace decides where each field begins and ends. The same holds for a &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; block, an inline SVG &lt;code&gt;path&lt;/code&gt; you assemble in Liquid, or a JSON blob you build and hand to JavaScript. In all of those, an extra or missing newline isn't cosmetic. It changes the parse. That's how a table that's correct in my local preview shreds on the live storefront, or the reverse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the linter for what it catches
&lt;/h2&gt;

&lt;p&gt;I still run theme check on every change, and you should. It catches the things it's built to catch: malformed syntax, undefined objects, unknown filters, schema errors. And, increasingly useful, it flags filters an AI assistant hallucinated into a template that don't exist. That's real value, and it's fast.&lt;/p&gt;

&lt;p&gt;What it can't vouch for is whitespace-exact output. So the rule I follow is simple. Lint locally. When whitespace is load-bearing, render it on live Shopify, a dev store or a theme preview, before you trust it. Not the Node simulator. The engine that actually serves customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;This isn't really about Liquid. LiquidJS standing in for Ruby Liquid is one instance of a trap every engineer meets: the local stand-in that isn't the runtime. A mock that isn't the live API. SQLite in dev and Postgres in production. A cloud emulator on your laptop. Each one is close enough to be useful and different enough to lie to you at exactly the wrong moment.&lt;/p&gt;

&lt;p&gt;The move isn't to distrust the tool. It's to know precisely which class of bug it can catch and which it can't, then send the rest to the real thing. For Liquid: lint in Node, render on Shopify.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>liquid</category>
      <category>frontend</category>
    </item>
    <item>
      <title>The docs were right — for a version I didn't have installed</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:17:31 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/the-docs-were-right-for-a-version-i-didnt-have-installed-1hgh</link>
      <guid>https://dev.to/iamrobindhiman/the-docs-were-right-for-a-version-i-didnt-have-installed-1hgh</guid>
      <description>&lt;p&gt;I was building a read-only admin grid over a custom entity. One row per record, two filters, no edit form. The kind of thing Magento's native UI components make you write 200 lines of XML for, so I reached for Loki AdminComponents (&lt;code&gt;loki/magento2-admin-components&lt;/code&gt;), an Alpine.js-based grid framework that does the same job with a fraction of the markup.&lt;/p&gt;

&lt;p&gt;The first filter I added threw on page load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Field type "text" not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had copied that filter config straight from the module's documentation. The docs said &lt;code&gt;field_type="text"&lt;/code&gt; was valid. The installed module disagreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The docs and the code had drifted
&lt;/h2&gt;

&lt;p&gt;Here is what actually happened. The documentation described a version of the module that was not the version sitting in my &lt;code&gt;vendor/&lt;/code&gt; folder. Between the doc's examples and the release Composer had resolved for me (&lt;code&gt;0.6.2&lt;/code&gt;), the accepted filter field types had changed. &lt;code&gt;text&lt;/code&gt; was no longer one of them.&lt;/p&gt;

&lt;p&gt;No amount of re-reading the docs would have surfaced that. They were internally consistent and completely wrong for my install.&lt;/p&gt;

&lt;p&gt;The fix took two minutes once I stopped trusting the documentation and opened the source that was actually going to run:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'field_type'&lt;/span&gt; vendor/loki/magento2-admin-components/src
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installed code enumerates exactly which field types it accepts. I picked one that existed and the exception went away. Then I hit three more gotchas building that grid, and every one resolved the same way: not with a web search, but by reading the class in &lt;code&gt;vendor/&lt;/code&gt; that Magento was about to instantiate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;Third-party Magento modules move faster than their READMEs. A constraint like &lt;code&gt;^0.6&lt;/code&gt; pulls whatever minor the resolver picks, and the published docs usually track the main branch, not the tag you actually got. The gap between what the docs describe and what sits in your &lt;code&gt;vendor/&lt;/code&gt; tree is where the afternoon goes.&lt;/p&gt;

&lt;p&gt;AI in the loop makes this trap deeper, not shallower. A coding assistant that writes a filter config for you is drawing on whatever it absorbed about the module: the public docs at best, a stale blog post at worst. It will hand you &lt;code&gt;field_type="text"&lt;/code&gt; with total confidence, because that used to be correct. It has no idea which minor version is pinned in your lockfile. Your &lt;code&gt;vendor/&lt;/code&gt; folder is the only thing that does.&lt;/p&gt;

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

&lt;p&gt;For any third-party Magento module, the installed source in &lt;code&gt;vendor/&lt;/code&gt; is the authoritative reference. The README is a hint. The docs site is a hint. The assistant is a hint. The class Magento is about to run is the fact.&lt;/p&gt;

&lt;p&gt;Before I trust a config option from a module I don't control:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the class in &lt;code&gt;vendor/&lt;/code&gt; that consumes the option.&lt;/li&gt;
&lt;li&gt;Read what it actually accepts: the constant list, the switch, the constructor signature.&lt;/li&gt;
&lt;li&gt;Match the config to that, not to the example I found online.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It feels slower. It is faster. Mocked tests won't cover for you here either: a unit test with a mocked dependency will happily accept a config the real class rejects at runtime, because the mock doesn't know the constant list moved. The ground truth is the installed code, not the thing standing in for it.&lt;/p&gt;

</description>
      <category>magento2</category>
      <category>php</category>
      <category>debugging</category>
    </item>
    <item>
      <title>You can't find a bug you never named</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:22:39 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/you-cant-find-a-bug-you-never-named-o2j</link>
      <guid>https://dev.to/iamrobindhiman/you-cant-find-a-bug-you-never-named-o2j</guid>
      <description>&lt;p&gt;There's a post going around arguing that every project should ship an &lt;code&gt;invariants.md&lt;/code&gt;. I agree. What convinced me wasn't a blog post, though. It was a SELECT statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that every test approved
&lt;/h2&gt;

&lt;p&gt;In a review of a split-settlement module for a multi-vendor marketplace, a resource model built a query that read &lt;code&gt;base_shipping_refunded&lt;/code&gt; off &lt;code&gt;sales_creditmemo&lt;/code&gt;. That column isn't on the credit memo. It's on &lt;code&gt;sales_order&lt;/code&gt;. The first real refund in production would have thrown.&lt;/p&gt;

&lt;p&gt;The unit tests were green. All of them. The resource model was mocked, and a mock returns whatever you told it to return, including a value for a column that has never existed in any database anywhere.&lt;/p&gt;

&lt;p&gt;Static analysis didn't care either. The column name is a string.&lt;/p&gt;

&lt;p&gt;So the code cleared every gate we had, and the bug was still sitting in the diff, in plain sight, unremarked. Not because anyone was sloppy. Because nothing in the project stated the rule it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  A finding is a violated rule
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that changed how I review code.&lt;/p&gt;

&lt;p&gt;A bug isn't a smell or an ugliness. A bug is a specific statement about the system that is supposed to be true and isn't. "The settlement legs sum to what the customer paid." "The same webhook delivered twice moves money once." "Every column a query names exists on the table it names."&lt;/p&gt;

&lt;p&gt;Which means: &lt;strong&gt;you cannot find a violation of a rule you never wrote down.&lt;/strong&gt; A reviewer reading a diff cold is doing two jobs at once. Inferring what the system is supposed to guarantee, then checking whether this code breaks it. The first job is the hard one, and it gets redone, badly, by every person who opens the file.&lt;/p&gt;

&lt;p&gt;Write the rules down once and reviewing collapses into a smaller task: take each statement, try to break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they actually look like
&lt;/h2&gt;

&lt;p&gt;Not architecture. Not "the code should be clean." Falsifiable statements about state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# invariants.md (settlement)&lt;/span&gt;

CONSERVATION
&lt;span class="p"&gt;-&lt;/span&gt; sum(settlement legs on an order) == the amount the customer actually paid.
&lt;span class="p"&gt;-&lt;/span&gt; No path creates money. No path destroys it. A refund reduces legs; it never mints one.

IDEMPOTENCY
&lt;span class="p"&gt;-&lt;/span&gt; Any gateway callback may be delivered twice. Processing it twice moves money once.
&lt;span class="p"&gt;-&lt;/span&gt; Every write is keyed by the gateway's own reference, unique-constrained in the schema.

ORDERING
&lt;span class="p"&gt;-&lt;/span&gt; A payout release refuses while any refund on that order is open.
&lt;span class="p"&gt;-&lt;/span&gt; A leg never goes from settled back to pending.

ISOLATION
&lt;span class="p"&gt;-&lt;/span&gt; One vendor's failed leg does not block another vendor's payout on the same order.

SCHEMA
&lt;span class="p"&gt;-&lt;/span&gt; Every column a query names exists on the table it names. Verified against db_schema.xml,
  not against a mock.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is testable, arguable, and either true or false of a given code path. That last one exists purely because of the SELECT above. An invariants file is where you keep the scar tissue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Point the review at the list
&lt;/h2&gt;

&lt;p&gt;The list earns its keep at review time, and it earns it twice over when the reviewer is a model.&lt;/p&gt;

&lt;p&gt;"Look for bugs in this diff" produces plausible findings, and you spend the afternoon disproving them. "Prove none of these six statements can be broken by this diff, and give me the exact inputs that break the one that can" produces something you can act on. The rules give the review a shape, and they give you grounds to reject a finding that isn't anchored to one.&lt;/p&gt;

&lt;p&gt;They also make disagreement useful. On that same review, two reviewers reached opposite conclusions about whether a credit memo could over-refund tax. Neither position was checkable against a vibe. Both were checkable against the invariant, and the invariant was checkable against the framework source. Reading the core class that registers a credit-memo item settled it in ten minutes: the refunded amount it tracks is tax-exclusive, so the over-refund was real.&lt;/p&gt;

&lt;p&gt;A model's finding is a lead. The invariant tells you which lead is worth chasing. The source tells you whether it's true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the ones you can into assertions
&lt;/h2&gt;

&lt;p&gt;Some invariants become queries. Conservation is the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_grand_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;legs&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;sales_order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;vendor_settlement_leg&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_grand_total&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_grand_total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_amount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows means the money conserves. Run it nightly against production and a paragraph of intent has become an alarm.&lt;/p&gt;

&lt;p&gt;Idempotency usually becomes a unique key rather than a test, which is stronger. If the gateway reference is unique in &lt;code&gt;db_schema.xml&lt;/code&gt;, a redelivered webhook cannot insert a second leg no matter what the application layer does. Enforce it where it can't be forgotten.&lt;/p&gt;

&lt;p&gt;Others stay prose. "One vendor's failure doesn't freeze another vendor's payout" was one we couldn't fully hold. The credit-memo freeze in that module is order-wide, so a dispute on one vendor's item does hold the rest. We shipped it anyway as a documented trade-off, and it got to be a decision instead of an argument only because it was written down as a named rule with a known blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't fix
&lt;/h2&gt;

&lt;p&gt;An invariants file doesn't tell you the truth. It tells you what to check.&lt;/p&gt;

&lt;p&gt;The schema rule is the clearest case. Writing "every column exists" changes nothing by itself. It becomes real when someone opens &lt;code&gt;db_schema.xml&lt;/code&gt; and reads the table definition instead of trusting a green test. Mocked tests do not validate columns. They never have. That's the gate most reviews skip, and it costs about four minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit
&lt;/h2&gt;

&lt;p&gt;Before the next module goes to review, write the file. Six to ten lines. What must always be true, stated specifically enough to be wrong.&lt;/p&gt;

&lt;p&gt;Then review against it, and treat anything you find that isn't on the list as evidence the list is incomplete.&lt;/p&gt;

&lt;p&gt;The bug I opened with was caught by reading a schema file, not by being clever. That's usually how it goes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source / further reading: &lt;a href="https://twitter.com/PiccoGabriele/status/2076876444760957440" rel="noopener noreferrer"&gt;https://twitter.com/PiccoGabriele/status/2076876444760957440&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>magento2</category>
      <category>php</category>
      <category>codereview</category>
      <category>testing</category>
    </item>
    <item>
      <title>The handshake tax: reuse your HTTP client in Magento integrations</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Fri, 03 Jul 2026 04:25:40 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/the-handshake-tax-reuse-your-http-client-in-magento-integrations-3kk7</link>
      <guid>https://dev.to/iamrobindhiman/the-handshake-tax-reuse-your-http-client-in-magento-integrations-3kk7</guid>
      <description>&lt;p&gt;I had a product export that talked to a third-party pricing API. One product, fast. The full catalog was painfully slow. The database was barely doing anything, so I went looking, and the profiler pointed somewhere I didn't expect: the network, before a single request was even sent.&lt;/p&gt;

&lt;p&gt;The code created a fresh HTTP client on every iteration of the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake tax
&lt;/h2&gt;

&lt;p&gt;Before you send one byte of an HTTPS request, the machine does a lot of quiet work.&lt;/p&gt;

&lt;p&gt;A TCP handshake to open the socket. Then a TLS handshake on top: certificate exchange, key negotiation, several round trips across the wire. Only after all of that does your actual &lt;code&gt;GET&lt;/code&gt; or &lt;code&gt;POST&lt;/code&gt; go out.&lt;/p&gt;

&lt;p&gt;Do it once and reuse the connection, you pay that tax once. Do it inside a loop over 40,000 products, you pay it 40,000 times. The request bodies are tiny. The setup is the whole bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP tricks you into it
&lt;/h2&gt;

&lt;p&gt;PHP is share-nothing. Every web request starts cold, so it feels natural to build a client, use it, and throw it away. For a single web request that hits one API once, that's fine. You were going to pay one handshake anyway.&lt;/p&gt;

&lt;p&gt;The trap is the long-running process. A cron job, a &lt;code&gt;bin/magento&lt;/code&gt; console command, a message-queue consumer syncing records to an ERP or a PIM. Those loop. And inside the loop, a lot of Magento integration code looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&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;\GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// new connection every time&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.example.com/sync'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="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;Every iteration opens a new connection, runs the full TCP + TLS dance, sends a few hundred bytes, and tears the connection down. The handshake runs N times for N products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse one client
&lt;/h2&gt;

&lt;p&gt;Guzzle keeps the underlying connection alive between requests made on the same client instance. So build it once, outside the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$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;\GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'base_uri'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://api.example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'headers'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'keep-alive'&lt;/span&gt;&lt;span class="p"&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;$products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/sync'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same requests, same payloads. But now the socket and the TLS session are reused across the loop. You handshake once, then stream the rest over the open connection.&lt;/p&gt;

&lt;p&gt;In a Magento module, go one step further and don't &lt;code&gt;new&lt;/code&gt; the client at all. Inject a configured client, or a small wrapper service, through the constructor. The same instance gets shared, and the connection survives across the calls that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The louder version
&lt;/h2&gt;

&lt;p&gt;In a long-lived runtime the same mistake gets worse. Create a client per call in a hot path and the cost compounds: on top of the repeated handshakes, you can run the machine out of outbound ports, because closed connections pile up in &lt;code&gt;TIME_WAIT&lt;/code&gt; faster than the OS reclaims them. The service stops being able to open new sockets at all. Same root cause, much louder failure.&lt;/p&gt;

&lt;p&gt;PHP's request model usually saves you from that specific cliff. It does not save you from the latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find it
&lt;/h2&gt;

&lt;p&gt;Grep your integration code for clients built inside loops, or hidden in a method that runs once per record:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"new .*Client("&lt;/span&gt; app/code | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for any &lt;code&gt;new \GuzzleHttp\Client()&lt;/code&gt; (or a raw &lt;code&gt;curl_init()&lt;/code&gt;) sitting inside a &lt;code&gt;foreach&lt;/code&gt;. That's the line paying the handshake tax on every pass.&lt;/p&gt;

&lt;p&gt;Move the client up, out of the loop, and let the connection stay open. It's a one-line change. On a sync that touches thousands of records, it's the cheapest speedup you'll find all day.&lt;/p&gt;

</description>
      <category>magento2</category>
      <category>php</category>
      <category>performance</category>
      <category>apiintegration</category>
    </item>
    <item>
      <title>WooCommerce HPOS: when order sync floods Action Scheduler</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:18:54 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/woocommerce-hpos-when-order-sync-floods-action-scheduler-2ib</link>
      <guid>https://dev.to/iamrobindhiman/woocommerce-hpos-when-order-sync-floods-action-scheduler-2ib</guid>
      <description>&lt;p&gt;A WooCommerce store starts misbehaving over a weekend. The database is swelling. The PHP error log is growing faster than the database. Background processing runs non-stop, and now you're seeing &lt;code&gt;Deadlock found&lt;/code&gt; and &lt;code&gt;INSERT command denied&lt;/code&gt; in the logs.&lt;/p&gt;

&lt;p&gt;The usual suspects get blamed first. Redis. The page cache. That custom plugin you shipped on Friday. A recent server upgrade.&lt;/p&gt;

&lt;p&gt;None of them are it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening
&lt;/h2&gt;

&lt;p&gt;If the store is on &lt;strong&gt;HPOS&lt;/strong&gt; (High-Performance Order Storage, the order tables WooCommerce moved to a couple of years ago), there's a setting most people forget they enabled: compatibility mode.&lt;/p&gt;

&lt;p&gt;HPOS keeps orders in their own tables (&lt;code&gt;wp_wc_orders&lt;/code&gt;, &lt;code&gt;wp_wc_orders_meta&lt;/code&gt;, and friends) instead of the old &lt;code&gt;wp_posts&lt;/code&gt; / &lt;code&gt;wp_postmeta&lt;/code&gt; layout. Compatibility mode keeps both stores in sync so legacy code that still reads &lt;code&gt;wp_postmeta&lt;/code&gt; doesn't break. That sync runs through &lt;strong&gt;Action Scheduler&lt;/strong&gt;, WooCommerce's background job queue.&lt;/p&gt;

&lt;p&gt;Here's the trap. Every order change schedules a sync job. If anything is touching orders in a loop (an importer, a meta-rewriting cron, a plugin that re-saves every order on some hook), each touch enqueues another sync action. Failures get retried. The queue grows faster than the workers drain it, and Action Scheduler stores every one of those rows in your database.&lt;/p&gt;

&lt;p&gt;That's your runaway table. That's your deadlock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop guessing. Query the queue.
&lt;/h2&gt;

&lt;p&gt;You don't debug this by disabling plugins one at a time. The queue table tells you exactly what's being scheduled. Action Scheduler keeps its jobs in &lt;code&gt;wp_actionscheduler_actions&lt;/code&gt;. Group them by hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_actionscheduler_actions&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hook will dwarf the rest. That hook name is your culprit — it tells you which subsystem is enqueuing work in a loop. You go from a vague 'something is wrong' to a named process scheduling hundreds of thousands of jobs, in one query.&lt;/p&gt;

&lt;p&gt;This is the same move I make on Magento when &lt;code&gt;cron_schedule&lt;/code&gt; or the message queue balloons: don't audit the whole stack, read the queue and &lt;code&gt;GROUP BY&lt;/code&gt; what's piling up. The component generating the work always names itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;First, stop the bleeding. Once you've confirmed the store is fully on HPOS and nothing critical still reads the legacy tables, turn &lt;strong&gt;off&lt;/strong&gt; compatibility mode under WooCommerce → Settings → Advanced → Features. You stop paying the sync tax on every order write. Don't flip this blind on a store full of legacy plugins. Verify they read HPOS first.&lt;/p&gt;

&lt;p&gt;Second, clean up the backlog. Action Scheduler retains completed actions for 30 days by default, which is how a short burst leaves a long tail in your database. Lower the window with the &lt;code&gt;action_scheduler_retention_period&lt;/code&gt; filter and let the cleanup task reclaim the space, or purge completed actions from Tools → Scheduled Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that travels
&lt;/h2&gt;

&lt;p&gt;The platform-specific bit is narrow: HPOS compatibility mode is expensive under heavy order writes. Keep that one for WooCommerce.&lt;/p&gt;

&lt;p&gt;The part that travels to every stack with a job queue: when background processing melts your database, the queue is the evidence, not the suspect list. Don't theorize about Redis. Count the rows by hook. Whatever is flooding you is already labelled.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>wordpress</category>
      <category>performance</category>
      <category>actionscheduler</category>
    </item>
    <item>
      <title>A PHP login form that won't get you owned</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Sun, 28 Jun 2026 16:42:47 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/a-php-login-form-that-wont-get-you-owned-n9n</link>
      <guid>https://dev.to/iamrobindhiman/a-php-login-form-that-wont-get-you-owned-n9n</guid>
      <description>&lt;p&gt;A login form is the most-copied, least-reviewed piece of PHP on the internet. Someone needs auth, they paste a tutorial from 2014, it "works," and it ships. Then it leaks.&lt;/p&gt;

&lt;p&gt;I've reviewed a lot of these. The same five mistakes show up every time. None of them are exotic. All of them are one function call away from being fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Hashing passwords by hand
&lt;/h2&gt;

&lt;p&gt;If your code contains &lt;code&gt;md5()&lt;/code&gt;, &lt;code&gt;sha1()&lt;/code&gt;, or a salt you generated yourself, stop. PHP has had a real password API since 5.5.&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;// On signup&lt;/span&gt;
&lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PASSWORD_DEFAULT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// On login&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;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// authenticated&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;password_hash()&lt;/code&gt; picks a strong algorithm (bcrypt by default, Argon2id if you pass &lt;code&gt;PASSWORD_ARGON2ID&lt;/code&gt;), generates the salt for you, and stores the cost inside the hash string. &lt;code&gt;password_verify()&lt;/code&gt; does a constant-time comparison, so you don't leak timing. You never handle a salt again.&lt;/p&gt;

&lt;p&gt;When you raise the cost later, &lt;code&gt;password_needs_rehash()&lt;/code&gt; lets you re-hash transparently on the user's next login.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Building the query with string concatenation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't&lt;/span&gt;
&lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM users WHERE email = '&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's SQL injection, on the front door of your app. Use a prepared statement and let the driver escape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id, password_hash FROM users WHERE email = ?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select only the columns you need. &lt;code&gt;SELECT *&lt;/code&gt; on a user row pulls fields you'll expose by accident later.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Not regenerating the session ID
&lt;/h2&gt;

&lt;p&gt;This one is invisible until someone exploits it. If you attach the logged-in state to the same session ID the visitor arrived with, you're open to session fixation: an attacker who can plant a victim's session ID before login inherits the authenticated session after.&lt;/p&gt;

&lt;p&gt;One line, right after the password checks out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;session_regenerate_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regenerate on login, and again on logout and any privilege change.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Telling attackers which half they got right
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Leaks which emails exist&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;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'No account with that email'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password_hash'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Wrong password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different messages turn your login form into a user-enumeration oracle. An attacker scripts it to learn which emails are registered, then focuses on those.&lt;/p&gt;

&lt;p&gt;Return one message for both cases:&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;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;$user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password_hash'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Invalid email or password'&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;To close the timing gap when the user doesn't exist, verify against a dummy hash so both paths do the same work.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Letting them guess forever
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;password_hash()&lt;/code&gt; is deliberately slow, which buys you a lot. It does not stop someone running a few hundred guesses at one account. That needs rate limiting.&lt;/p&gt;

&lt;p&gt;The cheap version: count failed attempts per email and per IP in a fast store (Redis, or an indexed table), then refuse or delay past a threshold. Reset the counter on success.&lt;/p&gt;

&lt;p&gt;You don't need a library to start. You need a counter and a ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a correct login
&lt;/h2&gt;

&lt;p&gt;Put together, the whole thing is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id, password_hash FROM users WHERE email = ?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;$user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password_hash'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// generic error, bump the rate-limit counter&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid email or password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;session_regenerate_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No custom crypto. No string-built SQL. One error message. A fresh session. A counter on top.&lt;/p&gt;

&lt;p&gt;None of this is new. &lt;code&gt;password_hash()&lt;/code&gt; landed in PHP 5.5, prepared statements are older still, and &lt;code&gt;session_regenerate_id()&lt;/code&gt; has been there the whole time. The tools are old and boring. The mistakes survive because the tutorials never caught up.&lt;/p&gt;

&lt;p&gt;If you maintain a PHP app with hand-rolled auth, read your login controller today. The fix is usually five small edits, not a rewrite.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>Enriching a large Magento catalog without melting the indexer</title>
      <dc:creator>Robin Dhiman</dc:creator>
      <pubDate>Fri, 26 Jun 2026 09:16:09 +0000</pubDate>
      <link>https://dev.to/iamrobindhiman/enriching-a-large-magento-catalog-without-melting-the-indexer-3mk9</link>
      <guid>https://dev.to/iamrobindhiman/enriching-a-large-magento-catalog-without-melting-the-indexer-3mk9</guid>
      <description>&lt;p&gt;Every few weeks the same question shows up in a Magento forum: thousands of SKUs, missing attributes, thin descriptions, no translations. How do I enrich all of it? The replies are always about sources. Icecat for attributes. An LLM for descriptions. A feed for the marketplace fields.&lt;/p&gt;

&lt;p&gt;Sourcing the data has a thousand tutorials. Getting it into the catalog without taking the store down has almost none. That second part is the actual job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake everyone makes first
&lt;/h2&gt;

&lt;p&gt;You write the obvious loop. Load product, set value, save.&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$descriptions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&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;Every &lt;code&gt;save()&lt;/code&gt; runs the full product lifecycle: validation, every save-after observer and plugin, and a reindex trigger. At 50,000 products you've fired that machinery 50,000 times. The script runs for hours, the indexer thrashes, and admin grinds while it does.&lt;/p&gt;

&lt;p&gt;The product save path is built for a human editing one product in the admin. It is the wrong tool for touching the whole catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set shared values in bulk
&lt;/h2&gt;

&lt;p&gt;When you're writing the same value to many products (a marketplace flag, a country of manufacture, a default brand), Magento already ships the right tool:&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;// Magento\Catalog\Model\Product\Action&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productAction&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;updateAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$batchOfIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// 1-2k entity IDs per call&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'country_of_manufacture'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'IN'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nv"&gt;$storeId&lt;/span&gt;                           &lt;span class="c1"&gt;// 0 = default scope&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;updateAttributes()&lt;/code&gt; writes straight to the attribute's backend table for the whole batch and skips the full model save. One operation instead of N lifecycles. For genuinely distinct values per product, like unique descriptions, group your writes and keep them off the &lt;code&gt;productRepository-&amp;gt;save()&lt;/code&gt; path. The moment you're saving the full model in a loop, you've already lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the indexer on schedule before you start
&lt;/h2&gt;

&lt;p&gt;Switch your indexers to &lt;strong&gt;Update by Schedule&lt;/strong&gt; before any bulk run.&lt;/p&gt;

&lt;p&gt;On &lt;em&gt;Update on Save&lt;/em&gt;, every write reindexes synchronously and your enrichment job fights the indexer for the whole run. On schedule, writes drop into the changelog and mview reindexes only the changed rows on cron. You enrich fast, then reindex the delta once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento indexer:set-mode schedule
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Translations live at store-view scope
&lt;/h2&gt;

&lt;p&gt;A translated description isn't a column on the product. It's an attribute value scoped to a store view. Write German to the German store view's id, not to the default scope. And don't clobber the default value with one language while you're at it.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;$storeId&lt;/code&gt; argument on &lt;code&gt;updateAttributes()&lt;/code&gt; is the same lever: pass the store-view id to set the localized value, and leave the global value alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI part, on a short leash
&lt;/h2&gt;

&lt;p&gt;An LLM will draft decent product copy across thousands of SKUs in one pass. It will also state, with total confidence, that a cable is 2 metres, a shirt is 100% cotton, and a case fits a phone it has never heard of.&lt;/p&gt;

&lt;p&gt;So treat generated copy as a draft, never as truth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate into a staging field or a disabled scope, not straight onto the live product page.&lt;/li&gt;
&lt;li&gt;Sample-review a real slice before you trust the batch.&lt;/li&gt;
&lt;li&gt;Keep anything load-bearing (dimensions, materials, compatibility, claims) sourced and verified, not generated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A wrong spec on a product page is a returns problem, and on regulated goods it's a bigger one than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  An order of operations that survives 50k SKUs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Indexers to &lt;strong&gt;scheduled&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enrich into staging: a holding field or a disabled scope nobody can see yet.&lt;/li&gt;
&lt;li&gt;Bulk-apply in batches of 1-2k IDs, off the full save path.&lt;/li&gt;
&lt;li&gt;Reindex the delta, then smoke-test a real sample of product pages.&lt;/li&gt;
&lt;li&gt;Only then flip visibility.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The data sources are the easy 20%. The catalog is a live system with an indexer, a cache, and customers on it. Enrich it like one and 50,000 SKUs is a non-event. Loop over &lt;code&gt;save()&lt;/code&gt; and you'll find out how long an afternoon can be.&lt;/p&gt;

</description>
      <category>magento2</category>
      <category>php</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
