[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"devlog-post-adobe-commerce-licence-cost-correction":3,"devlog-index":158},{"name":4,"created_at":5,"published_at":6,"updated_at":7,"id":8,"uuid":9,"content":10,"slug":147,"full_slug":148,"sort_by_date":149,"position":150,"tag_list":151,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":154,"first_published_at":155,"release_id":149,"lang":156,"path":149,"alternates":157,"default_full_slug":149,"translated_slugs":149},"I got the Adobe Commerce licence number wrong","2026-09-03T12:39:48.730Z","2026-09-03T12:59:32.303Z","2026-09-03T12:59:32.318Z",216062315466711,"9da501bb-1039-43df-8c69-782370693699",{"_uid":11,"body":12,"title":4,"excerpt":144,"component":145,"publish_at":146},"5db0d669-9cd2-40e2-9181-ce8b154d6540",{"type":13,"content":14},"doc",[15,21,25,32,36,40,44,48,53,57,79,83,88,92,96,100,105,131,136,140],{"type":16,"content":17},"paragraph",[18],{"text":19,"type":20},"In August I wrote that Adobe Commerce licensing runs to something like ten thousand euros a month, and used it to argue that a lot of merchants should look at Open Source instead.","text",{"type":16,"content":22},[23],{"text":24,"type":20},"It was the most read thing I have published. It was also, on the number itself, wrong. Two people who work with these contracts far more often than I do said so publicly, in the comments, with their names on it.",{"type":26,"attrs":27,"content":29},"heading",{"level":28},2,[30],{"text":31,"type":20},"What they said",{"type":16,"content":33},[34],{"text":35,"type":20},"Ryan Hoerr of ParadoxLabs, one of the founders of Mage-OS, put it in the region of two to three thousand a month, scaling with revenue rather than sitting at a flat number.",{"type":16,"content":37},[38],{"text":39,"type":20},"Kostas Tsiapalis gave a different shape for the same thing: roughly twelve to twenty two thousand a year, per installation.",{"type":16,"content":41},[42],{"text":43,"type":20},"Zach Stein, an engineering manager on the merchant side, described renewing after negotiating, which is the detail that makes both of the above make sense.",{"type":16,"content":45},[46],{"text":47,"type":20},"I am paraphrasing all three from memory of the thread rather than quoting them. The correction stands either way, and it is theirs, not mine.",{"type":26,"attrs":49,"content":50},{"level":28},[51],{"text":52,"type":20},"Why the range is so wide",{"type":16,"content":54},[55],{"text":56,"type":20},"Three things make a single figure close to meaningless.",{"type":58,"content":59},"bullet_list",[60,67,73],{"type":61,"content":62},"list_item",[63],{"type":16,"content":64},[65],{"text":66,"type":20},"The licence is banded on revenue. A merchant doing two million and one doing forty million are not being quoted the same thing, so any single number is only true for one band.",{"type":61,"content":68},[69],{"type":16,"content":70},[71],{"text":72,"type":20},"It is per installation. If you run several storefronts as separate installations, you are having a different conversation than somebody running several store views on one.",{"type":61,"content":74},[75],{"type":16,"content":76},[77],{"text":78,"type":20},"It is negotiated. The number on the first quote is not the number people end up paying, particularly at renewal, and particularly if leaving is visibly on the table.",{"type":16,"content":80},[81],{"text":82,"type":20},"So when I quoted one figure as though it were the price, I was describing the top of a range as the middle of it. That is the kind of mistake that makes an argument feel stronger than it is.",{"type":26,"attrs":84,"content":85},{"level":28},[86],{"text":87,"type":20},"The argument I actually wanted to make",{"type":16,"content":89},[90],{"text":91,"type":20},"Here is the part I still stand behind, stated properly.",{"type":16,"content":93},[94],{"text":95,"type":20},"The licence is rarely the interesting cost. The interesting question is whether you are using the thing you are paying for. Adobe Commerce earns its money on B2B contract pricing, company accounts, shared catalogs, advanced staging and the rest. Merchants who lean on those are usually getting value. Merchants who bought it because it was the safe choice, and who then run a fairly ordinary B2C catalog on it, are paying a real premium for features nobody in the building has opened.",{"type":16,"content":97},[98],{"text":99,"type":20},"That is a question you can answer without knowing anyone else's licence figure. Look at what your team actually touches in the admin. If the answer is products, orders and CMS pages, the platform is not the reason your shop works and you are free to price the alternative honestly.",{"type":26,"attrs":101,"content":102},{"level":28},[103],{"text":104,"type":20},"What to do before your renewal",{"type":58,"content":106},[107,113,119,125],{"type":61,"content":108},[109],{"type":16,"content":110},[111],{"text":112,"type":20},"Get your own number. Not a benchmark, not a figure from a blog post, including this one. Your quote is the only figure that describes your situation.",{"type":61,"content":114},[115],{"type":16,"content":116},[117],{"text":118,"type":20},"List the Commerce-only features you genuinely use, and be strict. \"We might use it next year\" is not use.",{"type":61,"content":120},[121],{"type":16,"content":122},[123],{"text":124,"type":20},"Price the alternative properly, including the migration, the extensions you would replace, and the year of instability that follows any replatform. A migration that saves licence fees and costs you a peak season has not saved anything.",{"type":61,"content":126},[127],{"type":16,"content":128},[129],{"text":130,"type":20},"Then negotiate. Everyone who has been through it says the first number moves.",{"type":26,"attrs":132,"content":133},{"level":28},[134],{"text":135,"type":20},"Why I am writing this down",{"type":16,"content":137},[138],{"text":139,"type":20},"The post did well precisely because people disagreed with it in public, which is worth more than a post nobody argues with. Quietly reusing a number after two credible people have corrected it is how you lose the benefit of that.",{"type":16,"content":141},[142],{"text":143,"type":20},"So: two to three thousand a month scaling with revenue, or twelve to twenty two thousand a year per installation, from people who see more of these contracts than I do. Not ten thousand a month. If your own quote says something different again, I would genuinely like to hear it.","I put a number on Adobe Commerce licensing in public. Two people who deal with those contracts weekly told me it was too high. Here is the better answer, and why the range is so wide.","blog_post","2026-08-19 09:00","adobe-commerce-licence-cost-correction","blog\u002Fadobe-commerce-licence-cost-correction",null,-10,[],false,215738016545577,"38a6aa2f-9751-439d-8b3e-7787b26bf5ba","2026-09-03T12:39:48.767Z","default",[],[159,341,644,758,852,957,1135,1235,1339,1493,1613,1727,1815,1922,2025,2134,2246,2363],{"name":160,"created_at":161,"published_at":162,"updated_at":163,"id":164,"uuid":165,"content":166,"slug":335,"full_slug":336,"sort_by_date":149,"position":337,"tag_list":338,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":339,"first_published_at":162,"release_id":149,"lang":156,"path":149,"alternates":340,"default_full_slug":149,"translated_slugs":149},"Adobe patched StyleSmuggler. That is step 1 of 14.","2026-09-08T06:30:30.326Z","2026-09-08T06:30:30.374Z","2026-09-08T06:30:30.386Z",217741026640609,"1de7ede1-ae59-40be-8cbf-d6099b35703c",{"_uid":167,"body":168,"title":160,"excerpt":333,"component":145,"publish_at":334},"dd305e2e-983c-4149-8716-fbd8d5516a91",{"type":13,"content":169},[170,185,189,194,211,215,220,224,274,278,283,287,298,302,307,311,316,320,325,329],{"type":16,"content":171},[172,174,183],{"text":173,"type":20},"Adobe shipped the StyleSmuggler fix on 7 September as ",{"text":175,"type":20,"marks":176},"APSB26-146",[177],{"type":178,"attrs":179},"link",{"href":180,"target":181,"linktype":182},"https:\u002F\u002Fhelpx.adobe.com\u002Fsecurity\u002Fproducts\u002Fmagento\u002Fapsb26-146.html","_blank","url",{"text":184,"type":20},". It is CVE-2026-75650, CVSS 10.0, and Adobe confirms it was exploited in the wild before the fix existed.",{"type":16,"content":186},[187],{"text":188,"type":20},"Apply it today. Then read the rest of this, because the patch is step 1 of 14.",{"type":26,"attrs":190,"content":191},{"level":28},[192],{"text":193,"type":20},"It is a hotfix, not a release",{"type":16,"content":195},[196,198,203,205,209],{"text":197,"type":20},"There is no new 2.4.x version to upgrade to. Adobe ships this as a composer patch, ",{"text":199,"type":20,"marks":200},"VULN-39341-composer-patches.zip",[201],{"type":202},"code",{"text":204,"type":20},", from ",{"text":206,"type":20,"marks":207},"repo.magento.com",[208],{"type":202},{"text":210,"type":20},". Everything from 2.4.4 to 2.4.9 is affected, Open Source and Commerce, plus B2B 1.3.3 through 1.5.3.",{"type":16,"content":212},[213],{"text":214,"type":20},"If you put a community patch in place last week, this replaces it. Take yours back out once Adobe’s is live, or you will be carrying two guards that do the same job and one of them will surprise you at the next upgrade.",{"type":26,"attrs":216,"content":217},{"level":28},[218],{"text":219,"type":20},"The eight rotations nobody is going to do",{"type":16,"content":221},[222],{"text":223,"type":20},"Adobe’s own remediation list is fourteen steps. Four of them are maintenance mode and cron. One is the patch. The other eight are credential rotations:",{"type":58,"content":225},[226,232,238,244,250,256,262,268],{"type":61,"content":227},[228],{"type":16,"content":229},[230],{"text":231,"type":20},"the encryption key",{"type":61,"content":233},[234],{"type":16,"content":235},[236],{"text":237,"type":20},"every Admin panel user password",{"type":61,"content":239},[240],{"type":16,"content":241},[242],{"text":243,"type":20},"all REST, SOAP and GraphQL integration tokens, deactivated and regenerated",{"type":61,"content":245},[246],{"type":16,"content":247},[248],{"text":249,"type":20},"OAuth client secrets for connected third-party applications",{"type":61,"content":251},[252],{"type":16,"content":253},[254],{"text":255,"type":20},"payment gateway API credentials, at the provider",{"type":61,"content":257},[258],{"type":16,"content":259},[260],{"text":261,"type":20},"database credentials",{"type":61,"content":263},[264],{"type":16,"content":265},[266],{"text":267,"type":20},"SSH and deploy keys, plus any cron or system service account",{"type":61,"content":269},[270],{"type":16,"content":271},[272],{"text":273,"type":20},"API keys for shipping, tax and other integrated extensions",{"type":16,"content":275},[276],{"text":277,"type":20},"That is a real afternoon of work, and it touches things that break deployments when you get them wrong. Which is exactly why most shops will apply the patch, see a clean scan, and stop.",{"type":26,"attrs":279,"content":280},{"level":28},[281],{"text":282,"type":20},"Why the rotations are the point",{"type":16,"content":284},[285],{"text":286,"type":20},"The bug gave an unauthenticated attacker code execution on your server. Anything that server could read, they could read. That includes the encryption key, and the encryption key is what protects everything else on the list.",{"type":16,"content":288},[289,291,296],{"text":290,"type":20},"Sansec, who found it, put it plainly: ",{"text":292,"type":20,"marks":293},"patching closes the hole but does not clean a store that was already hit",[294],{"type":295},"bold",{"text":297,"type":20},". Stores were being attacked for three days before the hotfix existed. A patch applied today does nothing about a credential taken on Friday.",{"type":16,"content":299},[300],{"text":301,"type":20},"So the question is not whether you are patched. It is whether anyone had a shell on your server between 4 and 7 September, and if you cannot answer that with evidence, you rotate.",{"type":26,"attrs":303,"content":304},{"level":28},[305],{"text":306,"type":20},"Do this in order",{"type":16,"content":308},[309],{"text":310,"type":20},"Scan first. If you patch before you look, you lose the easiest evidence.",{"type":312,"content":313},"code_block",[314],{"text":315,"type":20},"grep -ril 'x_trace_' var\u002Freport\u002F",{"type":16,"content":317},[318],{"text":319,"type":20},"Then apply the hotfix, then rotate. Maintenance mode on and cron off for the middle of it, as Adobe sets out. And if the scan finds anything at all, stop and treat it as an incident rather than a patch job.",{"type":26,"attrs":321,"content":322},{"level":28},[323],{"text":324,"type":20},"The short version",{"type":16,"content":326},[327],{"text":328,"type":20},"Adobe fixed it. That closes the door. It does not tell you who came through while it was open, and it does not take back the keys they picked up on the way.",{"type":16,"content":330},[331],{"text":332,"type":20},"Patch today. Rotate this week. Scan before both.","CVE-2026-75650 is fixed, but Adobe’s own remediation list runs to fourteen steps and eight of them are credential rotations. The patch is the easy half.","2026-09-08 06:28","adobe-patched-stylesmuggler-step-1-of-14","blog\u002Fadobe-patched-stylesmuggler-step-1-of-14",-170,[],"a956fc8f-740d-4eae-ad9d-73f13801558f",[],{"name":342,"created_at":343,"published_at":344,"updated_at":345,"id":346,"uuid":347,"content":348,"slug":637,"full_slug":638,"sort_by_date":149,"position":639,"tag_list":640,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":641,"first_published_at":642,"release_id":149,"lang":156,"path":149,"alternates":643,"default_full_slug":149,"translated_slugs":149},"StyleSmuggler: what to do while there is no Magento patch","2026-09-07T11:38:07.745Z","2026-09-08T06:30:43.528Z","2026-09-08T06:30:43.542Z",217462733907353,"190df6b1-cedc-418a-88f6-b9fce5f422cd",{"_uid":349,"body":350,"title":342,"excerpt":635,"component":145,"publish_at":636},"03d3d0a6-da36-40ed-8cbb-c5fb1f91ffc6",{"type":13,"content":351},[352,368,372,376,381,393,397,413,418,422,425,429,433,438,442,446,450,527,531,536,540,556,560,570,574,602,618,622,631],{"type":16,"content":353},[354,358,360,366],{"text":355,"type":20,"marks":356},"Update, 8 September 2026. ",[357],{"type":295},{"text":359,"type":20},"Adobe has shipped the fix as APSB26-146 (CVE-2026-75650). Apply that first. Patching is not the whole job though, and what to do after it is here: ",{"text":160,"type":20,"marks":361},[362],{"type":178,"attrs":363},{"href":364,"target":365,"linktype":182},"https:\u002F\u002Fgunnie.nl\u002Fblog\u002Fadobe-patched-stylesmuggler-step-1-of-14","_self",{"text":367,"type":20}," The steps below still stand if you cannot patch straight away.",{"type":16,"content":369},[370],{"text":371,"type":20},"The first shop confirmed backdoored by StyleSmuggler was fully patched. July and August security updates applied, a clean patch-status report, and an attacker running their own code on it anyway.",{"type":16,"content":373},[374],{"text":375,"type":20},"So let us skip the part where you check your patch level and go straight to what you can actually do today.",{"type":26,"attrs":377,"content":378},{"level":28},[379],{"text":380,"type":20},"What StyleSmuggler is",{"type":16,"content":382},[383,385,391],{"text":384,"type":20},"StyleSmuggler is an unauthenticated remote code execution flaw in Magento Open Source and Adobe Commerce, found and named by ",{"text":386,"type":20,"marks":387},"Sansec",[388],{"type":178,"attrs":389},{"href":390,"target":181,"linktype":182},"https:\u002F\u002Fsansec.io\u002Fresearch\u002Fstylesmuggler",{"text":392,"type":20},", exploited against live stores since 4 September. Unauthenticated means no account, no session, and no help from anyone inside your business.",{"type":16,"content":394},[395],{"text":396,"type":20},"Every current version is affected, including 2.4.9. As I write this there is no CVE, no Adobe advisory and no patch.",{"type":16,"content":398},[399,401,405,407,411],{"text":400,"type":20},"The attack smuggles PHP into Magento’s template system through a ",{"text":402,"type":20,"marks":403},"styles",[404],{"type":202},{"text":406,"type":20}," parameter, and gets it executed through the payment failed reminder email. It lands in ",{"text":408,"type":20,"marks":409},"var\u002Freport\u002F",[410],{"type":202},{"text":412,"type":20},", so it survives a cache flush.",{"type":26,"attrs":414,"content":415},{"level":28},[416],{"text":417,"type":20},"Step 1. Check whether it already happened",{"type":16,"content":419},[420],{"text":421,"type":20},"From your Magento root on production:",{"type":312,"content":423},[424],{"text":315,"type":20},{"type":16,"content":426},[427],{"text":428,"type":20},"Nothing back is good news. Something back is a conversation with your hosting party today, not next week. Sansec publish a fuller indicator list, including the process names and lock files the payload leaves behind. Check theirs before you rely on this one, because indicators move.",{"type":16,"content":430},[431],{"text":432,"type":20},"If you cannot run that yourself, that is fine. Ask the person who can.",{"type":26,"attrs":434,"content":435},{"level":28},[436],{"text":437,"type":20},"Step 2. Block it at the edge",{"type":16,"content":439},[440],{"text":441,"type":20},"Most shops sit behind Cloudflare. This is a custom rule, action Block, and it takes about ten minutes.",{"type":312,"content":443},[444],{"text":445,"type":20},"(lower(http.request.uri.query) contains \"styles[\")\nor (lower(http.request.uri.query) contains \"styles%5b\")\nor (lower(http.request.uri.query) contains \"\u003C?\")\nor (lower(http.request.uri.query) contains \"%3c%3f\")\nor (lower(http.request.uri.query) contains \"generatorclass\")\nor (lower(http.request.uri.query) contains \"with_resolved\")\nor (lower(http.request.uri.query) matches \"([{][{]|%7b%7b)([ +]|%20)*(block|config|trans|var|depend)\")\nor (lower(http.user_agent) contains \"\u003C?\")",{"type":16,"content":447},[448],{"text":449,"type":20},"Four things worth knowing before you paste that in.",{"type":58,"content":451},[452,483,493,517],{"type":61,"content":453},[454],{"type":16,"content":455},[456,460,464,468,471,475,477,481],{"text":457,"type":20,"marks":458},"Match ",[459],{"type":295},{"text":461,"type":20,"marks":462},"styles[",[463],{"type":202},{"text":465,"type":20,"marks":466}," with the bracket, never ",[467],{"type":295},{"text":402,"type":20,"marks":469},[470],{"type":202},{"text":472,"type":20,"marks":473}," alone.",[474],{"type":295},{"text":476,"type":20}," Magento’s store locator ships a GraphQL query containing ",{"text":478,"type":20,"marks":479},"store_locator_advanced_custom_map_styles",[480],{"type":202},{"text":482,"type":20},". Match the bare word and you block a query that runs on every page load. That one would have taken the site down, not protected it.",{"type":61,"content":484},[485],{"type":16,"content":486},[487,491],{"text":488,"type":20,"marks":489},"Put it at the top of your custom ruleset.",[490],{"type":295},{"text":492,"type":20}," If you already have skip rules for verified bots or for an office IP range, and they skip the current ruleset, then anything below them can be walked straight past.",{"type":61,"content":494},[495],{"type":16,"content":496},[497,501,505,509,511,515],{"text":498,"type":20,"marks":499},"The ",[500],{"type":295},{"text":502,"type":20,"marks":503},"matches",[504],{"type":202},{"text":506,"type":20,"marks":507}," line needs a Business plan or higher.",[508],{"type":295},{"text":510,"type":20}," On Free or Pro, drop that line. The rest is plain ",{"text":512,"type":20,"marks":513},"contains",[514],{"type":202},{"text":516,"type":20}," and works everywhere.",{"type":61,"content":518},[519],{"type":16,"content":520},[521,525],{"text":522,"type":20,"marks":523},"Cover the User-Agent, not just the query string.",[524],{"type":295},{"text":526,"type":20}," One variant carries its payload there and aims it at a poisoned log line. That is the clause most of the published rules are missing.",{"type":16,"content":528},[529],{"text":530,"type":20},"Then check your own firewall event log for false positives before you walk away.",{"type":26,"attrs":532,"content":533},{"level":28},[534],{"text":535,"type":20},"Step 3. Block it in the application",{"type":16,"content":537},[538],{"text":539,"type":20},"The edge rule stops opportunistic scanning, which is most of what is out there. It does not stop a payload in a POST body, and it does nothing at all if your origin server can be reached without going through Cloudflare. A surprising number can. That is worth finding out about your own shop regardless of this flaw.",{"type":16,"content":541},[542,544,548,550,554],{"text":543,"type":20},"So there is a second layer, and it is the one that actually holds. Three classes in Magento ",{"text":545,"type":20,"marks":546},"include",[547],{"type":202},{"text":549,"type":20}," or ",{"text":551,"type":20,"marks":552},"require",[553],{"type":202},{"text":555,"type":20}," a path handed to them by the caller:",{"type":312,"content":557},[558],{"text":559,"type":20},"setup\u002Fsrc\u002FMagento\u002FSetup\u002FModule\u002FDi\u002FCode\u002FReader\u002FClassesScanner.php\nsetup\u002Fsrc\u002FMagento\u002FSetup\u002FModule\u002FDi\u002FCode\u002FScanner\u002FArrayScanner.php\nsetup\u002Fsrc\u002FMagento\u002FSetup\u002FModule\u002FDi\u002FCode\u002FScanner\u002FXmlInterceptorScanner.php",{"type":16,"content":561},[562,564,568],{"text":563,"type":20},"They exist to serve ",{"text":565,"type":20,"marks":566},"bin\u002Fmagento setup:di:compile",[567],{"type":202},{"text":569,"type":20}," and nothing else. They are never legitimately reached over HTTP. So refuse to run them over HTTP:",{"type":312,"content":571},[572],{"text":573,"type":20},"if (PHP_SAPI !== 'cli') {\n    throw new \\RuntimeException('Magento DI code scanners are CLI-only.');\n}",{"type":16,"content":575},[576,578,582,584,588,590,594,596,600],{"text":577,"type":20},"That guard sits at the top of ",{"text":579,"type":20,"marks":580},"collectEntities",[581],{"type":202},{"text":583,"type":20},", ",{"text":585,"type":20,"marks":586},"includeClass",[587],{"type":202},{"text":589,"type":20}," and ",{"text":591,"type":20,"marks":592},"_handleControllerClassName",[593],{"type":202},{"text":595,"type":20},". It closes the sink rather than the entry point, so it holds whatever route the payload took. Disrex and proxiblue have both published this as a ready-made composer patch, which is the sane way to apply it, because ",{"text":597,"type":20,"marks":598},"composer install",[599],{"type":202},{"text":601,"type":20}," will otherwise overwrite a hand edit.",{"type":16,"content":603},[604,606,610,612,616],{"text":605,"type":20},"Apply it as a patch on ",{"text":607,"type":20,"marks":608},"magento\u002Fmagento2-base",[609],{"type":202},{"text":611,"type":20}," and check that ",{"text":613,"type":20,"marks":614},"setup:di:compile",[615],{"type":202},{"text":617,"type":20}," still succeeds afterwards. Mine takes 44 seconds and passes.",{"type":26,"attrs":619,"content":620},{"level":28},[621],{"text":324,"type":20},{"type":16,"content":623},[624,626,629],{"text":625,"type":20},"Check ",{"text":408,"type":20,"marks":627},[628],{"type":202},{"text":630,"type":20},". Put a rule at the edge today. Put the guard in the application this week. Take the guard back out when Adobe ships the real fix, and not a day before.",{"type":16,"content":632},[633],{"text":634,"type":20},"And if your answer to “are we exposed” was going to be “we are fully patched”, StyleSmuggler is the reason that is not an answer.","The first shop confirmed backdoored by StyleSmuggler was fully patched. The check to run today, a Cloudflare rule that stops the scanning, and the application guard that actually holds.","2026-09-07 11:36","stylesmuggler-what-to-do-while-there-is-no-patch","blog\u002Fstylesmuggler-what-to-do-while-there-is-no-patch",-160,[],"deb27909-ce38-4bea-8d09-cc72f3594c2a","2026-09-07T11:38:07.821Z",[],{"name":645,"created_at":646,"published_at":647,"updated_at":648,"id":649,"uuid":650,"content":651,"slug":751,"full_slug":752,"sort_by_date":149,"position":753,"tag_list":754,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":755,"first_published_at":756,"release_id":149,"lang":156,"path":149,"alternates":757,"default_full_slug":149,"translated_slugs":149},"Keeping a shop readable by AI assistants","2026-09-04T07:52:02.422Z","2026-09-04T08:07:05.642Z","2026-09-04T08:07:05.656Z",216345487071412,"af173eef-20ea-41a6-97c2-a255eab4d8b7",{"_uid":652,"body":653,"title":645,"excerpt":749,"component":145,"publish_at":750},"38fb4b30-073a-48b9-a1a8-355c1bfe7d6b",{"type":13,"content":654},[655,659,663,668,672,676,680,684,689,693,697,702,706,711,715,741,745],{"type":16,"content":656},[657],{"text":658,"type":20},"People increasingly ask an assistant rather than a search engine. If the answer names three suppliers in your category and you are not one of them, that is a channel you are absent from, and no report in your analytics will tell you.",{"type":16,"content":660},[661],{"text":662,"type":20},"Getting readable is not hard. Staying readable is the part nobody does.",{"type":26,"attrs":664,"content":665},{"level":28},[666],{"text":667,"type":20},"Start by checking you are not blocking them",{"type":16,"content":669},[670],{"text":671,"type":20},"This is the one that catches people, because the block is often not something anyone chose.",{"type":16,"content":673},[674],{"text":675,"type":20},"Fetch your own robots.txt over the network and compare it to the copy in your repository. On a site I audited recently the live file carried Disallow rules for nine AI crawlers that appear nowhere in the project. The CDN was rewriting the file at the edge, with the option enabled by default.",{"type":312,"content":677},[678],{"text":679,"type":20},"curl -s https:\u002F\u002Fyour-domain.com\u002Frobots.txt",{"type":16,"content":681},[682],{"text":683,"type":20},"If the live file has rules your repository does not, something in front of your origin is writing them. That is a legitimate choice if your content is the product. It is a bad accident if you were hoping to be cited.",{"type":26,"attrs":685,"content":686},{"level":28},[687],{"text":688,"type":20},"Then make the content readable without JavaScript",{"type":16,"content":690},[691],{"text":692,"type":20},"Most assistants read the HTML you serve. If your product data assembles client-side, an assistant sees an empty shell, exactly as an old-fashioned crawler would.",{"type":16,"content":694},[695],{"text":696,"type":20},"Server-render the parts that carry meaning: name, price, availability, specifications, the description. This is the same work as ordinary SEO, which is the good news.",{"type":26,"attrs":698,"content":699},{"level":28},[700],{"text":701,"type":20},"Structured data does more work here than it used to",{"type":16,"content":703},[704],{"text":705,"type":20},"Product, Offer, availability, price, currency, and review data if you have it. Structured data has always been a hint for search engines. For an assistant trying to answer a specific question it is closer to the primary source, because it is the part of the page that does not require interpretation.",{"type":26,"attrs":707,"content":708},{"level":28},[709],{"text":710,"type":20},"And then the actual job",{"type":16,"content":712},[713],{"text":714,"type":20},"Everything above is a day, maybe two. Here is why it does not hold.",{"type":58,"content":716},[717,723,729,735],{"type":61,"content":718},[719],{"type":16,"content":720},[721],{"text":722,"type":20},"A CDN or security setting changes a default and starts blocking crawlers, without a deploy and without a line in your codebase.",{"type":61,"content":724},[725],{"type":16,"content":726},[727],{"text":728,"type":20},"A redesign moves content into a client-side component and the server-rendered version quietly thins out.",{"type":61,"content":730},[731],{"type":16,"content":732},[733],{"text":734,"type":20},"A structured data field stops being populated because an import changed, and nothing validates it.",{"type":61,"content":736},[737],{"type":16,"content":738},[739],{"text":740,"type":20},"A new consent or bot-protection layer challenges non-browser traffic, which includes every assistant.",{"type":16,"content":742},[743],{"text":744,"type":20},"None of these produce an error. Each of them removes you from the channel silently, and you find out months later when somebody asks the assistant a question about your category and you are not in the answer.",{"type":16,"content":746},[747],{"text":748,"type":20},"So the practical advice is dull: put the robots.txt check and a structured-data check into whatever already runs against production on a schedule. Readability is not a project you finish. It is a thing that decays unless something is watching it.","Making a shop readable to AI assistants is about a day of work. Keeping it readable is the actual job, and the defaults are quietly working against you.","2026-09-04 07:00","keeping-a-shop-readable-by-ai-assistants","blog\u002Fkeeping-a-shop-readable-by-ai-assistants",-150,[],"af6f81a4-7d95-462b-9e8f-8c984dd38c8f","2026-09-04T07:52:02.462Z",[],{"name":759,"created_at":760,"published_at":761,"updated_at":762,"id":763,"uuid":764,"content":765,"slug":846,"full_slug":847,"sort_by_date":149,"position":848,"tag_list":849,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":850,"first_published_at":761,"release_id":149,"lang":156,"path":149,"alternates":851,"default_full_slug":149,"translated_slugs":149},"A CSP in report-only mode protects nothing","2026-09-04T07:52:01.822Z","2026-09-04T07:52:01.860Z","2026-09-04T07:52:01.873Z",216345484609704,"8705c983-c606-4951-84f2-77b0d5471053",{"_uid":766,"body":767,"title":759,"excerpt":844,"component":145,"publish_at":845},"8eab1f99-ae7c-4214-802d-332c34686fcc",{"type":13,"content":768},[769,773,777,782,786,790,795,799,803,823,828,832,836,840],{"type":16,"content":770},[771],{"text":772,"type":20},"Content-Security-Policy has a report-only mode. You ship the policy, the browser tells you what it would have blocked, and nothing actually breaks. It is the correct way to start.",{"type":16,"content":774},[775],{"text":776,"type":20},"It is not a security control. It is a measurement, and a measurement nobody reads is just a header.",{"type":26,"attrs":778,"content":779},{"level":28},[780],{"text":781,"type":20},"The state most shops are in",{"type":16,"content":783},[784],{"text":785,"type":20},"Report-only was switched on during a hardening pass, a reporting endpoint was configured or perhaps was not, and then attention moved elsewhere. Two years later the header is still there, still report-only, and the answer to \"do we have a CSP\" is technically yes.",{"type":16,"content":787},[788],{"text":789,"type":20},"An attacker injecting a script is not slowed down by a policy running in report-only. The browser executes the script and files a report. If nothing is reading the reports, the only outcome is that the attack was documented and permitted.",{"type":26,"attrs":791,"content":792},{"level":28},[793],{"text":794,"type":20},"Enforcing it is where the work is",{"type":16,"content":796},[797],{"text":798,"type":20},"The reason it stays in report-only is that turning it on breaks things, and I would rather be honest about that than pretend it is a config flag.",{"type":16,"content":800},[801],{"text":802,"type":20},"From switching a policy from permissive to enforcing on this site in the last few days, three things I would tell anyone starting:",{"type":58,"content":804},[805,811,817],{"type":61,"content":806},[807],{"type":16,"content":808},[809],{"text":810,"type":20},"Every directive is a separate decision, and missing one fails silently. A blocked script does not raise an error the user or the log will show you. It simply does not run, and the feature it powered is quietly gone.",{"type":61,"content":812},[813],{"type":16,"content":814},[815],{"text":816,"type":20},"Third parties need naming individually, in more than one directive. An embedded tool typically needs its script origin, its connect origin, sometimes its frame origin and its image origin. Get four out of five right and it still does not work.",{"type":61,"content":818},[819],{"type":16,"content":820},[821],{"text":822,"type":20},"The legacy header interacts badly. X-Frame-Options has no allowlist form, so it cannot express \"only this one origin may frame us\". Where both headers are present, frame-ancestors is the one browsers obey, so leaving the old one set is at best noise and at worst a broken embed you spend an afternoon on.",{"type":26,"attrs":824,"content":825},{"level":28},[826],{"text":827,"type":20},"Where the policy lives matters too",{"type":16,"content":829},[830],{"text":831,"type":20},"One that cost me a deploy: on some hosting the CSP is not just a runtime header, it is compiled into a static configuration file that the platform validates. A value that is fine in your application config can be rejected outright at deploy time, after a successful build, with an error pointing at a line number in a file you did not write.",{"type":16,"content":833},[834],{"text":835,"type":20},"Worth knowing where your headers actually come from before you start changing them.",{"type":26,"attrs":837,"content":838},{"level":28},[839],{"text":324,"type":20},{"type":16,"content":841},[842],{"text":843,"type":20},"Report-only is a stage, not a destination. Read the reports for a fortnight, fix what they show, then enforce. If you are not going to do the second half, the header is costing you a small amount of bandwidth and buying you nothing.","Report-only is a measuring tool, not a control. If nobody reads the reports it is a header that does nothing, and switching it on later is where the real work starts.","2026-09-03 09:00","a-csp-in-report-only-mode-protects-nothing","blog\u002Fa-csp-in-report-only-mode-protects-nothing",-140,[],"85628023-76a8-4498-ad14-989dd726e267",[],{"name":853,"created_at":854,"published_at":855,"updated_at":856,"id":857,"uuid":858,"content":859,"slug":951,"full_slug":952,"sort_by_date":149,"position":953,"tag_list":954,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":955,"first_published_at":855,"release_id":149,"lang":156,"path":149,"alternates":956,"default_full_slug":149,"translated_slugs":149},"What a Magento store actually costs to run","2026-09-04T07:52:01.237Z","2026-09-04T07:52:01.275Z","2026-09-04T07:52:01.290Z",216345482217639,"bb20450b-7964-4831-bfa8-5e788f01031c",{"_uid":860,"body":861,"title":853,"excerpt":949,"component":145,"publish_at":950},"bc304015-87e4-46c5-8fea-b8a7869bbc84",{"type":13,"content":862},[863,867,871,876,880,884,889,893,897,902,906,910,915,919,923,928,932,936,941,945],{"type":16,"content":864},[865],{"text":866,"type":20},"\"What does a Magento store cost to run?\" is the question I get asked most often, and the honest first answer is that nobody will hand you the full bill in one place. The pieces sit with different suppliers, get invoiced on different cycles, and several of them are not invoiced at all.",{"type":16,"content":868},[869],{"text":870,"type":20},"Here is the whole list, roughly in the order people underestimate it.",{"type":26,"attrs":872,"content":873},{"level":28},[874],{"text":875,"type":20},"The licence, if you are on Commerce",{"type":16,"content":877},[878],{"text":879,"type":20},"Open Source has none. Adobe Commerce does, it is banded on revenue, it is per installation, and it is negotiated. Anybody quoting you a single figure is describing one band of one contract. Get your own number.",{"type":16,"content":881},[882],{"text":883,"type":20},"It is also the line people fixate on while the others quietly cost more.",{"type":26,"attrs":885,"content":886},{"level":28},[887],{"text":888,"type":20},"Hosting, and the part of it that is not hosting",{"type":16,"content":890},[891],{"text":892,"type":20},"The server bill is easy to find. What sits next to it is less visible: the search cluster, the cache layer, the queue workers, the CDN, the staging environments, the backups you hope you never restore.",{"type":16,"content":894},[895],{"text":896,"type":20},"Magento is not a cheap application to run well. An underprovisioned stack does not fail loudly, it just gets slower under load, which is the most expensive way for infrastructure to be wrong.",{"type":26,"attrs":898,"content":899},{"level":28},[900],{"text":901,"type":20},"Extensions, plus the tax they charge later",{"type":16,"content":903},[904],{"text":905,"type":20},"A licence per extension per year is the visible cost. The real one arrives at upgrade time. Every third-party module is a thing that might not be compatible with the next platform version, and the more of them you have, the more of your upgrade budget goes on waiting for other people to release a patch.",{"type":16,"content":907},[908],{"text":909,"type":20},"Count them. Then ask which ones you would genuinely miss.",{"type":26,"attrs":911,"content":912},{"level":28},[913],{"text":914,"type":20},"Integrations, which nobody budgets as a running cost",{"type":16,"content":916},[917],{"text":918,"type":20},"The ERP link, the PIM sync, the payment provider, the carrier, the accounting export. Each was a project once and each is now a permanent maintenance commitment, because the other side changes on its own schedule and does not ask you first.",{"type":16,"content":920},[921],{"text":922,"type":20},"This is where a lot of unplanned developer time goes, and it almost never appears in anyone's cost model.",{"type":26,"attrs":924,"content":925},{"level":28},[926],{"text":927,"type":20},"Maintenance, which is not optional",{"type":16,"content":929},[930],{"text":931,"type":20},"Security patches arrive on a schedule. Platform upgrades arrive whether you want them or not. Somebody has to apply them, test them, and deal with what breaks.",{"type":16,"content":933},[934],{"text":935,"type":20},"Skipping this does not save money, it defers it. A shop that has not been upgraded in three years is not cheaper to run. It is carrying a large bill with no date on it yet, and the bill grows.",{"type":26,"attrs":937,"content":938},{"level":28},[939],{"text":940,"type":20},"How to get a real number",{"type":16,"content":942},[943],{"text":944,"type":20},"Add up twelve months of every line above, including the developer days spent on integrations and patching, then divide by orders. That figure is usually a surprise, and it is usually not the licence that caused it.",{"type":16,"content":946},[947],{"text":948,"type":20},"It is also the only version of this number that is worth comparing against anything.","Nobody hands you the full bill in one place. Licence, hosting, extensions, integrations and the maintenance nobody budgets for, and which of them is usually the biggest.","2026-09-02 09:00","what-a-magento-store-actually-costs","blog\u002Fwhat-a-magento-store-actually-costs",-130,[],"21dd0926-ca3d-41d6-8bbd-8d53ad49638c",[],{"name":958,"created_at":959,"published_at":960,"updated_at":961,"id":962,"uuid":963,"content":964,"slug":1128,"full_slug":1129,"sort_by_date":149,"position":1130,"tag_list":1131,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1132,"first_published_at":1133,"release_id":149,"lang":156,"path":149,"alternates":1134,"default_full_slug":149,"translated_slugs":149},"The robots.txt you did not write","2026-09-03T12:39:48.565Z","2026-09-03T12:59:33.165Z","2026-09-03T12:59:33.178Z",216062314778581,"d3d25fc7-3b63-43db-b05d-a1e9fe919f67",{"_uid":965,"body":966,"title":958,"excerpt":1126,"component":145,"publish_at":1127},"d23817ac-a382-4789-bd92-97c198c3e1a1",{"type":13,"content":967},[968,978,982,987,991,995,1011,1015,1020,1024,1028,1033,1043,1065,1075,1079,1089,1094,1098,1102,1106,1111,1115,1118,1122],{"type":16,"content":969},[970,972,976],{"text":971,"type":20},"I was auditing my own site for AI search visibility. Part of that is boring: check that nothing is telling crawlers to go away. I opened ",{"text":973,"type":20,"marks":974},"public\u002Frobots.txt",[975],{"type":202},{"text":977,"type":20}," in the repository, saw four permissive lines, and nearly moved on.",{"type":16,"content":979},[980],{"text":981,"type":20},"Then I fetched the live file instead. It was not the same file.",{"type":26,"attrs":983,"content":984},{"level":28},[985],{"text":986,"type":20},"What was actually being served",{"type":16,"content":988},[989],{"text":990,"type":20},"The version on the edge carried a block of rules that exist nowhere in the project:",{"type":312,"content":992},[993],{"text":994,"type":20},"User-agent: ClaudeBot\nDisallow: \u002F\n\nUser-agent: GPTBot\nDisallow: \u002F\n\nUser-agent: Google-Extended\nDisallow: \u002F\n\nUser-agent: CCBot\nDisallow: \u002F",{"type":16,"content":996},[997,999,1003,1005,1009],{"text":998,"type":20},"The full list ran to nine crawlers: ClaudeBot, GPTBot, Google-Extended, CCBot, Bytespider, Amazonbot, Applebot-Extended, meta-externalagent and CloudflareBrowserRenderingCrawler. There was also a ",{"text":1000,"type":20,"marks":1001},"Content-Signal",[1002],{"type":202},{"text":1004,"type":20}," header declaring ",{"text":1006,"type":20,"marks":1007},"search=yes,ai-train=no,use=reference",[1008],{"type":202},{"text":1010,"type":20},".",{"type":16,"content":1012},[1013],{"text":1014,"type":20},"I had not written any of it. Cloudflare had, and it was on by default.",{"type":26,"attrs":1016,"content":1017},{"level":28},[1018],{"text":1019,"type":20},"Why this one is easy to miss",{"type":16,"content":1021},[1022],{"text":1023,"type":20},"Every instinct says robots.txt is a static file. It is in the repository, it is in the deploy, and if you want to know what it says you open it. That instinct is exactly what hides this.",{"type":16,"content":1025},[1026],{"text":1027,"type":20},"The rewrite happens at the edge, after your origin has already returned the correct file. Your build is fine. Your deploy is fine. Nothing errors, nothing warns, and no log line anywhere mentions it. The only way to see it is to ask the internet rather than your editor.",{"type":26,"attrs":1029,"content":1030},{"level":28},[1031],{"text":1032,"type":20},"The two switches",{"type":16,"content":1034},[1035,1037,1041],{"text":1036,"type":20},"Both live on the same zone endpoint, ",{"text":1038,"type":20,"marks":1039},"GET\u002FPUT \u002Fzones\u002F{zone}\u002Fbot_management",[1040],{"type":202},{"text":1042,"type":20},":",{"type":58,"content":1044},[1045,1055],{"type":61,"content":1046},[1047],{"type":16,"content":1048},[1049,1053],{"text":1050,"type":20,"marks":1051},"ai_bots_protection: \"block\"",[1052],{"type":202},{"text":1054,"type":20}," turns on the AI bot blocking and the Disallow rules that advertise it.",{"type":61,"content":1056},[1057],{"type":16,"content":1058},[1059,1063],{"text":1060,"type":20,"marks":1061},"is_robots_txt_managed: true",[1062],{"type":202},{"text":1064,"type":20}," lets Cloudflare rewrite robots.txt at the edge.",{"type":16,"content":1066},[1067,1069,1073],{"text":1068,"type":20},"Turning off the first one is not enough, which cost me a round trip. With ",{"text":1070,"type":20,"marks":1071},"ai_bots_protection",[1072],{"type":202},{"text":1074,"type":20}," disabled the served file was unchanged. Both settings have to go off, and then the cached copy has to be purged:",{"type":312,"content":1076},[1077],{"text":1078,"type":20},"curl -X POST \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002F$ZONE\u002Fpurge_cache\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  --data '{\"files\":[\"https:\u002F\u002Fexample.com\u002Frobots.txt\"]}'",{"type":16,"content":1080},[1081,1083,1087],{"text":1082,"type":20},"With ",{"text":1084,"type":20,"marks":1085},"is_robots_txt_managed",[1086],{"type":202},{"text":1088,"type":20}," set to false, the file from your repository is served verbatim again. That is the state worth keeping. Robots policy belongs in git, where it can be reviewed, not in a dashboard toggle somebody flipped once.",{"type":26,"attrs":1090,"content":1091},{"level":28},[1092],{"text":1093,"type":20},"This is a real choice, not just a bug",{"type":16,"content":1095},[1096],{"text":1097,"type":20},"Blocking AI crawlers is a legitimate position. If your content is the product, you may not want it in a training set, and Cloudflare turning it on by default is defensible.",{"type":16,"content":1099},[1100],{"text":1101,"type":20},"The problem is that it is silently incompatible with the opposite goal. If you want to be the answer when somebody asks ChatGPT or Claude or Perplexity about your niche, you have to be readable. Those two goals are in direct conflict, and the default picks one for you without telling you.",{"type":16,"content":1103},[1104],{"text":1105,"type":20},"That is fine when it is a decision. It is not fine when nobody in the team knows it happened.",{"type":26,"attrs":1107,"content":1108},{"level":28},[1109],{"text":1110,"type":20},"The two minute check",{"type":16,"content":1112},[1113],{"text":1114,"type":20},"Fetch your own robots.txt and compare it to the one in your repository:",{"type":312,"content":1116},[1117],{"text":679,"type":20},{"type":16,"content":1119},[1120],{"text":1121,"type":20},"If the live file has rules your repository does not, something in front of your origin is writing them. On Cloudflare it is almost certainly these two settings. Check it on every site where being found by AI search matters, because nothing in the codebase will ever reveal it.",{"type":16,"content":1123},[1124],{"text":1125,"type":20},"On this site the two files match again. That took a settings change and a cache purge, and no amount of reading the repository would have told me it was needed.","Cloudflare rewrites robots.txt at the edge and blocks AI crawlers by default. None of it lives in your repository, so reading the file in git tells you nothing.","2026-08-31 09:00","robots-txt-you-did-not-write","blog\u002Frobots-txt-you-did-not-write",0,[],"bee8d07e-97a9-4bc3-ac23-2a7bb249e1df","2026-09-03T12:39:48.600Z",[],{"name":1136,"created_at":1137,"published_at":1138,"updated_at":1139,"id":1140,"uuid":1141,"content":1142,"slug":1229,"full_slug":1230,"sort_by_date":149,"position":1231,"tag_list":1232,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1233,"first_published_at":1138,"release_id":149,"lang":156,"path":149,"alternates":1234,"default_full_slug":149,"translated_slugs":149},"B2B buyers do not want your webshop","2026-09-04T07:52:00.658Z","2026-09-04T07:52:00.697Z","2026-09-04T07:52:00.713Z",216345479841958,"86dd2486-652e-452b-b9c0-7dd035e108cf",{"_uid":1143,"body":1144,"title":1136,"excerpt":1227,"component":145,"publish_at":1228},"bef5f231-6601-412e-9c93-20b095b8e792",{"type":13,"content":1145},[1146,1150,1154,1159,1163,1167,1172,1210,1215,1219,1223],{"type":16,"content":1147},[1148],{"text":1149,"type":20},"A B2B customer who orders roughly the same forty products every month does not want to browse your catalogue. They want the ordering to be over.",{"type":16,"content":1151},[1152],{"text":1153,"type":20},"That sounds obvious written down. It is not what most B2B storefronts are built for, because most of them are a B2C storefront with a login in front and prices that change per customer.",{"type":26,"attrs":1155,"content":1156},{"level":28},[1157],{"text":1158,"type":20},"Discovery is the wrong default",{"type":16,"content":1160},[1161],{"text":1162,"type":20},"A consumer store is optimised for finding something. Faceted search, category pages, recommendations, rich product pages. All of it exists to move somebody from not knowing what they want to knowing.",{"type":16,"content":1164},[1165],{"text":1166,"type":20},"The repeat B2B buyer already knows. They knew before they opened the browser. Every screen between them and a submitted order is friction, and friction here does not lose you the sale. It sends the order back to email, or to the phone, or to whoever has the sales rep's mobile number. The order still happens. It just costs you a person to process it, which is the cost the webshop was supposed to remove.",{"type":26,"attrs":1168,"content":1169},{"level":28},[1170],{"text":1171,"type":20},"What that buyer actually needs",{"type":58,"content":1173},[1174,1180,1186,1192,1198,1204],{"type":61,"content":1175},[1176],{"type":16,"content":1177},[1178],{"text":1179,"type":20},"Order history that can be reordered directly, in whole or in part, without rebuilding a basket by hand.",{"type":61,"content":1181},[1182],{"type":16,"content":1183},[1184],{"text":1185,"type":20},"Upload or paste a list of SKUs and quantities. Their ERP or their spreadsheet already has it, so let them bring it.",{"type":61,"content":1187},[1188],{"type":16,"content":1189},[1190],{"text":1191,"type":20},"Saved lists per site, per project, per van. The unit of repetition is rarely \"the whole last order\".",{"type":61,"content":1193},[1194],{"type":16,"content":1195},[1196],{"text":1197,"type":20},"Their own article numbers as well as yours. They do not think in your SKUs and should not have to translate.",{"type":61,"content":1199},[1200],{"type":16,"content":1201},[1202],{"text":1203,"type":20},"Their contract price, visible and correct, without asking. A price that needs a phone call to confirm is not a price.",{"type":61,"content":1205},[1206],{"type":16,"content":1207},[1208],{"text":1209,"type":20},"Stock and a realistic delivery date, because that is what decides whether they order from you or from whoever can deliver Thursday.",{"type":26,"attrs":1211,"content":1212},{"level":28},[1213],{"text":1214,"type":20},"The measurement that tells you the truth",{"type":16,"content":1216},[1217],{"text":1218,"type":20},"If you want to know whether your B2B storefront works, do not look at conversion rate. Look at how many orders still arrive by email and phone from customers who have an account.",{"type":16,"content":1220},[1221],{"text":1222,"type":20},"That number is the honest one. It is the proportion of your buyers who looked at the tool you built and decided the old way was faster. Nobody files a support ticket saying your reorder flow is tedious. They just do not use it.",{"type":16,"content":1224},[1225],{"text":1226,"type":20},"The fix is usually not a redesign. It is a reorder page, a SKU paste box, and prices that are right the first time.","A customer ordering the same forty products every month is not shopping. Most B2B storefronts are built for discovery, which is the one thing that buyer never needs.","2026-08-26 09:00","b2b-buyers-do-not-want-your-webshop","blog\u002Fb2b-buyers-do-not-want-your-webshop",-120,[],"a026365d-b332-4038-81b2-66e057069542",[],{"name":4,"created_at":5,"published_at":6,"updated_at":7,"id":8,"uuid":9,"content":1236,"slug":147,"full_slug":148,"sort_by_date":149,"position":150,"tag_list":1337,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":154,"first_published_at":155,"release_id":149,"lang":156,"path":149,"alternates":1338,"default_full_slug":149,"translated_slugs":149},{"_uid":11,"body":1237,"title":4,"excerpt":144,"component":145,"publish_at":146},{"type":13,"content":1238},[1239,1242,1245,1249,1252,1255,1258,1261,1265,1268,1285,1288,1292,1295,1298,1301,1305,1327,1331,1334],{"type":16,"content":1240},[1241],{"text":19,"type":20},{"type":16,"content":1243},[1244],{"text":24,"type":20},{"type":26,"attrs":1246,"content":1247},{"level":28},[1248],{"text":31,"type":20},{"type":16,"content":1250},[1251],{"text":35,"type":20},{"type":16,"content":1253},[1254],{"text":39,"type":20},{"type":16,"content":1256},[1257],{"text":43,"type":20},{"type":16,"content":1259},[1260],{"text":47,"type":20},{"type":26,"attrs":1262,"content":1263},{"level":28},[1264],{"text":52,"type":20},{"type":16,"content":1266},[1267],{"text":56,"type":20},{"type":58,"content":1269},[1270,1275,1280],{"type":61,"content":1271},[1272],{"type":16,"content":1273},[1274],{"text":66,"type":20},{"type":61,"content":1276},[1277],{"type":16,"content":1278},[1279],{"text":72,"type":20},{"type":61,"content":1281},[1282],{"type":16,"content":1283},[1284],{"text":78,"type":20},{"type":16,"content":1286},[1287],{"text":82,"type":20},{"type":26,"attrs":1289,"content":1290},{"level":28},[1291],{"text":87,"type":20},{"type":16,"content":1293},[1294],{"text":91,"type":20},{"type":16,"content":1296},[1297],{"text":95,"type":20},{"type":16,"content":1299},[1300],{"text":99,"type":20},{"type":26,"attrs":1302,"content":1303},{"level":28},[1304],{"text":104,"type":20},{"type":58,"content":1306},[1307,1312,1317,1322],{"type":61,"content":1308},[1309],{"type":16,"content":1310},[1311],{"text":112,"type":20},{"type":61,"content":1313},[1314],{"type":16,"content":1315},[1316],{"text":118,"type":20},{"type":61,"content":1318},[1319],{"type":16,"content":1320},[1321],{"text":124,"type":20},{"type":61,"content":1323},[1324],{"type":16,"content":1325},[1326],{"text":130,"type":20},{"type":26,"attrs":1328,"content":1329},{"level":28},[1330],{"text":135,"type":20},{"type":16,"content":1332},[1333],{"text":139,"type":20},{"type":16,"content":1335},[1336],{"text":143,"type":20},[],[],{"name":1340,"created_at":1341,"published_at":1342,"updated_at":1343,"id":1344,"uuid":1345,"content":1346,"slug":1487,"full_slug":1488,"sort_by_date":149,"position":1489,"tag_list":1490,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1491,"first_published_at":1342,"release_id":149,"lang":156,"path":149,"alternates":1492,"default_full_slug":149,"translated_slugs":149},"npm pruned your lockfile to your laptop","2026-09-03T12:59:31.419Z","2026-09-03T12:59:31.459Z","2026-09-03T12:59:31.473Z",216067159759048,"548636cd-226f-4bcf-b35a-b1e385ae90b2",{"_uid":1347,"body":1348,"title":1340,"excerpt":1485,"component":145,"publish_at":1486},"b1f845bf-4f20-44f8-a648-a59b362f3188",{"type":13,"content":1349},[1350,1354,1358,1368,1373,1383,1387,1392,1396,1422,1426,1431,1441,1446,1462,1466,1470],{"type":16,"content":1351},[1352],{"text":1353,"type":20},"A green build, one dependency added, and then this on CI:",{"type":312,"content":1355},[1356],{"text":1357,"type":20},"npm error `npm ci` can only install packages when your package.json\nnpm error and package-lock.json are in sync.\nnpm error Missing: @emnapi\u002Fcore@1.11.3 from lock file\nnpm error Missing: @emnapi\u002Fruntime@1.11.3 from lock file",{"type":16,"content":1359},[1360,1362,1366],{"text":1361,"type":20},"I had never heard of ",{"text":1363,"type":20,"marks":1364},"@emnapi\u002Fcore",[1365],{"type":202},{"text":1367,"type":20},". I had not installed it. It is a transitive dependency of a WebAssembly build of a native tool, several levels down.",{"type":26,"attrs":1369,"content":1370},{"level":28},[1371],{"text":1372,"type":20},"What npm had done",{"type":16,"content":1374},[1375,1377,1381],{"text":1376,"type":20},"Native packages ship one build per platform as optional dependencies. Your lockfile is supposed to list all of them, so an install on any machine can pick the right one. When ",{"text":1378,"type":20,"marks":1379},"npm install",[1380],{"type":202},{"text":1382,"type":20}," runs on a Mac it resolves the tree for a Mac, and it writes that pruned tree back to the lockfile. The Linux and Windows branches are dropped.",{"type":16,"content":1384},[1385],{"text":1386,"type":20},"Commit it, and CI on Linux asks for a package the lockfile no longer mentions.",{"type":26,"attrs":1388,"content":1389},{"level":28},[1390],{"text":1391,"type":20},"The damage is cumulative",{"type":16,"content":1393},[1394],{"text":1395,"type":20},"This is the part that surprised me. I counted the platform-specific entries for one package as I went:",{"type":58,"content":1397},[1398,1404,1410,1416],{"type":61,"content":1399},[1400],{"type":16,"content":1401},[1402],{"text":1403,"type":20},"The last green build had 18.",{"type":61,"content":1405},[1406],{"type":16,"content":1407},[1408],{"text":1409,"type":20},"After a couple of ordinary installs on my Mac, 16.",{"type":61,"content":1411},[1412],{"type":16,"content":1413},[1414],{"text":1415,"type":20},"After running the fix everybody recommends, still 16.",{"type":61,"content":1417},[1418],{"type":16,"content":1419},[1420],{"text":1421,"type":20},"After deleting the lockfile and regenerating it from scratch, 1.",{"type":16,"content":1423},[1424],{"text":1425,"type":20},"Deleting the lockfile is the worst available option, not the best one. It resolves cleanly for the machine you are sitting at and discards everything else.",{"type":26,"attrs":1427,"content":1428},{"level":28},[1429],{"text":1430,"type":20},"The fix that everyone documents does not work",{"type":16,"content":1432},[1433,1435,1439],{"text":1434,"type":20},"The standard advice is ",{"text":1436,"type":20,"marks":1437},"npm install --package-lock-only",[1438],{"type":202},{"text":1440,"type":20},", on the grounds that it resolves from registry metadata rather than from your installed tree. I had this written down in my own notes as the answer. It did not restore anything.",{"type":26,"attrs":1442,"content":1443},{"level":28},[1444],{"text":1445,"type":20},"What actually worked",{"type":16,"content":1447},[1448,1450,1454,1456,1460],{"text":1449,"type":20},"Generate the lockfile on the platform CI uses. Mount a temporary directory containing only ",{"text":1451,"type":20,"marks":1452},"package.json",[1453],{"type":202},{"text":1455,"type":20}," and the last known good lockfile, so your real ",{"text":1457,"type":20,"marks":1458},"node_modules",[1459],{"type":202},{"text":1461,"type":20}," is not touched:",{"type":312,"content":1463},[1464],{"text":1465,"type":20},"T=$(mktemp -d); cp package.json \"$T\"\u002F\ngit show \u003Clast-green-commit>:package-lock.json > \"$T\"\u002Fpackage-lock.json\ndocker run --rm --platform linux\u002Famd64 -v \"$T\":\u002Fwork -w \u002Fwork node:24 \\\n  bash -lc 'npm install --package-lock-only && npm ci --ignore-scripts'\ncp \"$T\"\u002Fpackage-lock.json .",{"type":16,"content":1467},[1468],{"text":1469,"type":20},"That restored the full set. Then verify on your own machine too, because a lockfile that fixes CI can just as easily break the laptop, and only running both proves it.",{"type":16,"content":1471},[1472,1474,1478,1480,1483],{"text":1473,"type":20},"One warning worth the paragraph. Do not mount your project directory into that container. ",{"text":1475,"type":20,"marks":1476},"npm ci",[1477],{"type":202},{"text":1479,"type":20}," wipes and rebuilds ",{"text":1457,"type":20,"marks":1481},[1482],{"type":202},{"text":1484,"type":20},", and it will happily fill your working copy with Linux binaries. I did that, and spent the next ten minutes wondering why nothing would build locally.","CI died on a package nobody had heard of. npm had quietly removed every platform variant except the one my Mac needed, and the fix everybody documents makes it worse.","2026-08-12 09:00","npm-pruned-your-lockfile-to-your-laptop","blog\u002Fnpm-pruned-your-lockfile-to-your-laptop",-90,[],"7d903312-de86-4759-9ae9-fcb14c6c50e9",[],{"name":1494,"created_at":1495,"published_at":1496,"updated_at":1497,"id":1498,"uuid":1499,"content":1500,"slug":1607,"full_slug":1608,"sort_by_date":149,"position":1609,"tag_list":1610,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1611,"first_published_at":1496,"release_id":149,"lang":156,"path":149,"alternates":1612,"default_full_slug":149,"translated_slugs":149},"Four systems and no catalogue","2026-09-04T07:52:00.071Z","2026-09-04T07:52:00.108Z","2026-09-04T07:52:00.127Z",216345477437605,"3f9bb30c-37de-4985-8a97-84deeab126e5",{"_uid":1501,"body":1502,"title":1494,"excerpt":1605,"component":145,"publish_at":1606},"8b47b8c1-2f6e-47ae-bc52-dae21526bbb1",{"type":13,"content":1503},[1504,1508,1512,1517,1521,1525,1529,1534,1538,1560,1570,1575,1601],{"type":16,"content":1505},[1506],{"text":1507,"type":20},"Most mid-sized shops I see have product data in at least three places. A PIM that marketing owns. An ERP that finance owns. The shop database itself, which developers own. And a spreadsheet somebody maintains because none of the other three does the thing they need.",{"type":16,"content":1509},[1510],{"text":1511,"type":20},"Each is authoritative for something. None is authoritative for everything. The import decides who wins, usually without anyone having chosen the rules.",{"type":26,"attrs":1513,"content":1514},{"level":28},[1515],{"text":1516,"type":20},"The import that reports success and does nothing",{"type":16,"content":1518},[1519],{"text":1520,"type":20},"Start with whether the sync ran at all, because the answer is less obvious than it looks.",{"type":16,"content":1522},[1523],{"text":1524,"type":20},"One connector I work with exits 0 when it refuses to run. If a previous job is still flagged as processing, it prints a message saying it cannot start a second job, and returns success. Every wrapper checking the exit code records a twelve hour import as complete in under a second.",{"type":16,"content":1526},[1527],{"text":1528,"type":20},"The same connector honours a leftover filter. Somebody scopes an import to one product family while testing, the filter is configuration so it persists, and the next full import quietly runs against that one family. Eighty seconds instead of eighteen hours, clean job row, success status, both timestamps moved. Nothing distinguishes it from a real run except the duration, and only if you know what the duration should be.",{"type":26,"attrs":1530,"content":1531},{"level":28},[1532],{"text":1533,"type":20},"Then whether it wrote what you think",{"type":16,"content":1535},[1536],{"text":1537,"type":20},"Two examples from the same install.",{"type":16,"content":1539},[1540,1542,1546,1548,1552,1554,1558],{"text":1541,"type":20},"Every attribute in the admin was labelled ",{"text":1543,"type":20,"marks":1544},"Unknown",[1545],{"type":202},{"text":1547,"type":20},". Not blank, the literal word, on nearly two thousand attributes. The connector writes a fallback whenever the PIM has no label in the *admin* locale, the admin locale was English, and the PIM only carried Dutch. The real labels were in the store label table the whole time. Anything rendering ",{"text":1549,"type":20,"marks":1550},"getFrontendLabel()",[1551],{"type":202},{"text":1553,"type":20}," showed the fallback; anything using ",{"text":1555,"type":20,"marks":1556},"getStoreLabel()",[1557],{"type":202},{"text":1559,"type":20}," was fine.",{"type":16,"content":1561},[1562,1564,1568],{"text":1563,"type":20},"And a request to disable one product on one website turned out to be unstorable, because ",{"text":1565,"type":20,"marks":1566},"status",[1567],{"type":202},{"text":1569,"type":20}," had been changed to global scope years earlier. A global attribute has one value and nowhere to put a second. The import went further and actively deleted store-scoped status rows on every run.",{"type":26,"attrs":1571,"content":1572},{"level":28},[1573],{"text":1574,"type":20},"The questions that sort this out",{"type":58,"content":1576},[1577,1583,1589,1595],{"type":61,"content":1578},[1579],{"type":16,"content":1580},[1581],{"text":1582,"type":20},"For each field, which system is authoritative, and does the import agree with your answer?",{"type":61,"content":1584},[1585],{"type":16,"content":1586},[1587],{"text":1588,"type":20},"What does a failed sync look like? If the answer is \"the same as a successful one\", that is the first thing to fix.",{"type":61,"content":1590},[1591],{"type":16,"content":1592},[1593],{"text":1594,"type":20},"What is a normal run time, and does anyone check it? Duration is often the only honest signal you have.",{"type":61,"content":1596},[1597],{"type":16,"content":1598},[1599],{"text":1600,"type":20},"Which columns does a connector own? Anything it owns cannot be repaired in the database, because the next run puts it back.",{"type":16,"content":1602},[1603],{"text":1604,"type":20},"None of this is a data problem in the way people mean when they say data problem. It is an ownership problem, and it shows up as data.","When product data lives in a PIM, an ERP, the shop and a spreadsheet, you do not have a catalogue. You have four opinions, and the import quietly picks a winner.","2026-08-05 09:00","four-systems-and-no-catalogue","blog\u002Ffour-systems-and-no-catalogue",-110,[],"ef0fe871-5461-4bf6-b8f5-404708523a0c",[],{"name":1614,"created_at":1615,"published_at":1616,"updated_at":1617,"id":1618,"uuid":1619,"content":1620,"slug":1721,"full_slug":1722,"sort_by_date":149,"position":1723,"tag_list":1724,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1725,"first_published_at":1616,"release_id":149,"lang":156,"path":149,"alternates":1726,"default_full_slug":149,"translated_slugs":149},"The checkout failures nobody reports","2026-09-04T07:51:59.467Z","2026-09-04T07:51:59.516Z","2026-09-04T07:51:59.532Z",216345474959523,"6bf2ff69-d6c0-4bd1-b256-4f6342409d69",{"_uid":1621,"body":1622,"title":1614,"excerpt":1719,"component":145,"publish_at":1720},"a98fd517-8b83-4493-81ce-f0e16ec3573d",{"type":13,"content":1623},[1624,1628,1632,1637,1641,1645,1649,1653,1657,1662,1666,1676,1686,1690,1695,1715],{"type":16,"content":1625},[1626],{"text":1627,"type":20},"The checkout problems that cost real money are almost never the ones in your error log. An exception gets noticed. Somebody sees a stack trace, files a ticket, and it gets fixed inside a week.",{"type":16,"content":1629},[1630],{"text":1631,"type":20},"The expensive ones return 200.",{"type":26,"attrs":1633,"content":1634},{"level":28},[1635],{"text":1636,"type":20},"A stalled dependency looks like a slow site",{"type":16,"content":1638},[1639],{"text":1640,"type":20},"One shop had reports of a slow cart. Sometimes a timeout, sometimes a 503, sometimes a 524, nothing consistent enough to reproduce on demand. Nothing in any log.",{"type":16,"content":1642},[1643],{"text":1644,"type":20},"The integration client that fetched live prices from the ERP had no timeout configured. Not a long one. None. It ran on library defaults, which mean wait forever, and the PHP pool above it was equally patient.",{"type":16,"content":1646},[1647],{"text":1648,"type":20},"So when the ERP went quiet, a worker sat there holding the request until some proxy in front gave up and showed the customer an error page. The 503 at sixty seconds and the 524 at a hundred were two different proxies abandoning the same hung worker, not two separate faults.",{"type":16,"content":1650},[1651],{"text":1652,"type":20},"The logs were empty because the client never timed out, so it never threw, so nothing was ever written. An empty error log was the symptom, not the reassurance everyone read it as.",{"type":16,"content":1654},[1655],{"text":1656,"type":20},"The diagnostic that actually worked: only the queries touching the pricing backend hung. Everything else on the same page stayed under half a second. A sick server slows everything. A stalled dependency slows exactly the calls that use it.",{"type":26,"attrs":1658,"content":1659},{"level":28},[1660],{"text":1661,"type":20},"Two writes to the same cart, one of them lost",{"type":16,"content":1663},[1664],{"text":1665,"type":20},"The other kind is quieter still, because the customer sees nothing at all.",{"type":16,"content":1667},[1668,1670,1674],{"text":1669,"type":20},"Magento does not write the column you changed. ",{"text":1671,"type":20,"marks":1672},"cartRepository->save()",[1673],{"type":202},{"text":1675,"type":20}," serialises the whole in-memory object into the UPDATE. So two requests that each load the cart, set one different field, and save produce a lost update. Whichever commits last writes back the value it read before the other one committed.",{"type":16,"content":1677},[1678,1680,1684],{"text":1679,"type":20},"A front end firing two mutations in one ",{"text":1681,"type":20,"marks":1682},"Promise.all",[1683],{"type":202},{"text":1685,"type":20}," hits this perhaps one time in three. Both calls return success. The field is simply empty afterwards, and the order goes through with something missing that nobody notices until a human does.",{"type":16,"content":1687},[1688],{"text":1689,"type":20},"It is also hard to reproduce, because the repository caches loaded quotes by id. Ask for the same cart twice in one process and you get the same object back, so both halves of your test mutate one instance and the bug politely disappears.",{"type":26,"attrs":1691,"content":1692},{"level":28},[1693],{"text":1694,"type":20},"What to actually check",{"type":58,"content":1696},[1697,1703,1709],{"type":61,"content":1698},[1699],{"type":16,"content":1700},[1701],{"text":1702,"type":20},"Every outbound client in the checkout path needs an explicit timeout, and a decision about what the page does when it fires. A dependency that can hang forever eventually will, usually during a sale.",{"type":61,"content":1704},[1705],{"type":16,"content":1706},[1707],{"text":1708,"type":20},"Any two operations that write the same row need to be serialised, merged into one write, or locked. Different fields of the same row is not safe here.",{"type":61,"content":1710},[1711],{"type":16,"content":1712},[1713],{"text":1714,"type":20},"Treat an empty error log as a question rather than an answer, particularly when the complaint is about speed.",{"type":16,"content":1716},[1717],{"text":1718,"type":20},"None of this shows up in a conversion report as a bug. It shows up as a slightly worse number that everybody explains away.","A checkout that loses an order does not usually throw an error. It succeeds, returns 200, and quietly writes the wrong thing. Two ways I have watched that happen.","2026-07-29 09:00","the-checkout-failures-nobody-reports","blog\u002Fthe-checkout-failures-nobody-reports",-100,[],"a35f0137-8b8f-47cb-8304-7208f75d6fe3",[],{"name":1728,"created_at":1729,"published_at":1730,"updated_at":1731,"id":1732,"uuid":1733,"content":1734,"slug":1809,"full_slug":1810,"sort_by_date":149,"position":1811,"tag_list":1812,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1813,"first_published_at":1730,"release_id":149,"lang":156,"path":149,"alternates":1814,"default_full_slug":149,"translated_slugs":149},"The import that exits 0 and imports nothing","2026-09-03T12:59:30.836Z","2026-09-03T12:59:30.876Z","2026-09-03T12:59:30.890Z",216067157379233,"6a2d7f42-8bff-43a2-8d07-b18d3ff4d707",{"_uid":1735,"body":1736,"title":1728,"excerpt":1807,"component":145,"publish_at":1808},"f6b91829-83f5-4a45-8720-ae3b6a99105b",{"type":13,"content":1737},[1738,1742,1746,1751,1755,1759,1764,1768,1772,1777,1803],{"type":16,"content":1739},[1740],{"text":1741,"type":20},"A full product import normally takes most of a day on the install I am thinking of. Roughly 190,000 products. One run finished in eighty seconds, exited 0, wrote a clean job row, and moved both timestamps.",{"type":16,"content":1743},[1744],{"text":1745,"type":20},"It had imported one family out of about 1,600.",{"type":26,"attrs":1747,"content":1748},{"level":28},[1749],{"text":1750,"type":20},"Failure one: it refuses, and still exits 0",{"type":16,"content":1752},[1753],{"text":1754,"type":20},"If a previous job is still marked as processing, the import command prints a message saying it cannot run two jobs at once, and then exits with status 0. Any wrapper, cron entry or deploy script that checks the exit code records a twelve hour import as having completed successfully in under a second.",{"type":16,"content":1756},[1757],{"text":1758,"type":20},"The lock is a status column on the job table. A crashed run leaves it set, and every subsequent import politely declines while reporting success.",{"type":26,"attrs":1760,"content":1761},{"level":28},[1762],{"text":1763,"type":20},"Failure two: a leftover filter scopes the whole run",{"type":16,"content":1765},[1766],{"text":1767,"type":20},"The connector supports an advanced filter, which is how you import one family while testing. The filter is configuration, so it persists. Nobody clears it.",{"type":16,"content":1769},[1770],{"text":1771,"type":20},"A later \"full\" import honours that filter. It exits 0, sets the job status to success, updates the timestamps and logs a clean run. Nothing in the exit code, the job row or the log distinguishes it from a real full import. The only tell is the duration, and only if you know what the duration is supposed to be.",{"type":26,"attrs":1773,"content":1774},{"level":28},[1775],{"text":1776,"type":20},"What to check instead",{"type":58,"content":1778},[1779,1785,1791,1797],{"type":61,"content":1780},[1781],{"type":16,"content":1782},[1783],{"text":1784,"type":20},"Never trust the exit code on this command. It is not lying about the command, it is telling you the command ran, which is a different question from whether the import happened.",{"type":61,"content":1786},[1787],{"type":16,"content":1788},[1789],{"text":1790,"type":20},"Compare the duration against a known-good run. Eighty seconds against eighteen hours is not a performance improvement.",{"type":61,"content":1792},[1793],{"type":16,"content":1794},[1795],{"text":1796,"type":20},"Clear the advanced filter after any scoped test, and check it before any run you intend to be complete.",{"type":61,"content":1798},[1799],{"type":16,"content":1800},[1801],{"text":1802,"type":20},"Check the job status before starting, and know which value means processing, because that is what silently blocks the next run.",{"type":16,"content":1804},[1805],{"text":1806,"type":20},"The theme, and it is the same theme as most things I end up writing down: the failure is quiet, the reporting is confidently wrong, and the only defence is knowing what the numbers should look like when it works.","Two different ways a product import reports complete success while doing none of the work, and neither of them shows up in the exit code or the job table.","2026-07-22 09:00","the-import-that-exits-zero-and-imports-nothing","blog\u002Fthe-import-that-exits-zero-and-imports-nothing",-80,[],"14a3ca0a-ed7b-4f68-b92c-8f1885e9173e",[],{"name":1816,"created_at":1817,"published_at":1818,"updated_at":1819,"id":1820,"uuid":1821,"content":1822,"slug":1916,"full_slug":1917,"sort_by_date":149,"position":1918,"tag_list":1919,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":1920,"first_published_at":1818,"release_id":149,"lang":156,"path":149,"alternates":1921,"default_full_slug":149,"translated_slugs":149},"The SOAP client with no timeout","2026-09-03T12:59:30.269Z","2026-09-03T12:59:30.303Z","2026-09-03T12:59:30.314Z",216067155044512,"9cad4585-849e-4eba-974c-34c5e7022ce0",{"_uid":1823,"body":1824,"title":1816,"excerpt":1914,"component":145,"publish_at":1915},"c2b95134-bc8b-452d-9bff-e2d8d6687d64",{"type":13,"content":1825},[1826,1830,1834,1839,1860,1870,1875,1879,1883,1887,1892,1896,1910],{"type":16,"content":1827},[1828],{"text":1829,"type":20},"The reports were the vague kind. The cart is slow. Sometimes it times out. Sometimes a 503, sometimes a 524. Nothing in the logs.",{"type":16,"content":1831},[1832],{"text":1833,"type":20},"That last part was the useful bit, though it took me a while to treat it as evidence rather than as an absence of evidence.",{"type":26,"attrs":1835,"content":1836},{"level":28},[1837],{"text":1838,"type":20},"Nothing capped the request",{"type":16,"content":1840},[1841,1843,1847,1848,1852,1854,1858],{"text":1842,"type":20},"The integration client called the ERP through Guzzle with no request options at all, and nothing in the dependency injection configured the HTTP client either. Guzzle defaults ",{"text":1844,"type":20,"marks":1845},"timeout",[1846],{"type":202},{"text":589,"type":20},{"text":1849,"type":20,"marks":1850},"connect_timeout",[1851],{"type":202},{"text":1853,"type":20}," to ",{"text":1855,"type":20,"marks":1856},"0",[1857],{"type":202},{"text":1859,"type":20},", which means wait forever.",{"type":16,"content":1861},[1862,1864,1868],{"text":1863,"type":20},"Nothing further up cared either. The PHP-FPM pool had ",{"text":1865,"type":20,"marks":1866},"max_execution_time = 0",[1867],{"type":202},{"text":1869,"type":20}," and a terminate timeout of four hours. So a single stalled call from the ERP occupied a worker for as long as the ERP felt like being quiet.",{"type":26,"attrs":1871,"content":1872},{"level":28},[1873],{"text":1874,"type":20},"Why the logs were empty",{"type":16,"content":1876},[1877],{"text":1878,"type":20},"This is the part I want to underline, because it inverts a normal instinct.",{"type":16,"content":1880},[1881],{"text":1882,"type":20},"Guzzle never timed out. Because it never timed out, it never threw. Because it never threw, nothing was ever logged. There was not a single cURL or Guzzle error anywhere, and the ERP looked healthy in every dashboard because it was not returning errors, it was returning nothing at all, slowly.",{"type":16,"content":1884},[1885],{"text":1886,"type":20},"An empty error log is not proof that the dependency was fine. Sometimes it is the symptom.",{"type":26,"attrs":1888,"content":1889},{"level":28},[1890],{"text":1891,"type":20},"The signature that identifies it",{"type":16,"content":1893},[1894],{"text":1895,"type":20},"Two things made this diagnosable without access to the ERP.",{"type":58,"content":1897},[1898,1904],{"type":61,"content":1899},[1900],{"type":16,"content":1901},[1902],{"text":1903,"type":20},"Only the queries that touched the pricing backend hung. Everything else in the same page load stayed under about 400 milliseconds. A sick server slows everything down; a stalled dependency slows exactly the calls that use it. That split is the diagnosis.",{"type":61,"content":1905},[1906],{"type":16,"content":1907},[1908],{"text":1909,"type":20},"The 503 at around sixty seconds and the 524 at a hundred were not two faults. They were two different proxies giving up on the same hung worker at their own configured limits.",{"type":16,"content":1911},[1912],{"text":1913,"type":20},"Once you see it that way the fix is unglamorous. Set an explicit timeout on the client, decide what the page should do when the dependency is slow, and make sure that decision produces a log line. A dependency that can hang forever will eventually hang forever, usually during a sale.","Slow carts, 503s, 524s, and not one error in any log. The integration client was configured to wait forever, and nothing else in the stack was willing to stop it.","2026-07-08 09:00","the-soap-client-with-no-timeout","blog\u002Fthe-soap-client-with-no-timeout",-70,[],"62d4b50d-85d0-45ef-adb8-4f7b35871603",[],{"name":1923,"created_at":1924,"published_at":1925,"updated_at":1926,"id":1927,"uuid":1928,"content":1929,"slug":2019,"full_slug":2020,"sort_by_date":149,"position":2021,"tag_list":2022,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":2023,"first_published_at":1925,"release_id":149,"lang":156,"path":149,"alternates":2024,"default_full_slug":149,"translated_slugs":149},"You cannot disable a product on one website","2026-09-03T12:59:29.668Z","2026-09-03T12:59:29.725Z","2026-09-03T12:59:29.739Z",216067152586910,"a8686b64-4407-4c98-aa8b-1dd4f9e8363c",{"_uid":1930,"body":1931,"title":1923,"excerpt":2017,"component":145,"publish_at":2018},"7bb426bc-90b8-48f0-9e4d-ad2b349040ab",{"type":13,"content":1932},[1933,1937,1941,1946,1971,1975,1980,1984,1988,1993,1997,2001],{"type":16,"content":1934},[1935],{"text":1936,"type":20},"The request was ordinary: this product should be visible on one website and not the other. Same catalog, several storefronts, disable it on one of them.",{"type":16,"content":1938},[1939],{"text":1940,"type":20},"It could not be stored. Not \"it was overwritten later\" or \"the import undid it\". There was nowhere to put the value.",{"type":26,"attrs":1942,"content":1943},{"level":28},[1944],{"text":1945,"type":20},"status had been made global",{"type":16,"content":1947},[1948,1950,1953,1955,1958,1959,1963,1965,1969],{"text":1949,"type":20},"Stock Magento ships ",{"text":1565,"type":20,"marks":1951},[1952],{"type":202},{"text":1954,"type":20}," as website-scoped, which is what makes the usual answer work. On this install both ",{"text":1565,"type":20,"marks":1956},[1957],{"type":202},{"text":589,"type":20},{"text":1960,"type":20,"marks":1961},"visibility",[1962],{"type":202},{"text":1964,"type":20}," had ",{"text":1966,"type":20,"marks":1967},"is_global = 1",[1968],{"type":202},{"text":1970,"type":20},". Nothing in the project code did that, so it was changed at some point years ago and nobody recorded why.",{"type":16,"content":1972},[1973],{"text":1974,"type":20},"A global attribute stores one value on store 0. Setting a different value for store 3 has nowhere to live, so the admin appears to accept the change and the value is simply not persisted anywhere it can be read back.",{"type":26,"attrs":1976,"content":1977},{"level":28},[1978],{"text":1979,"type":20},"The import enforces it too",{"type":16,"content":1981},[1982],{"text":1983,"type":20},"Reading the connector made it worse in an interesting way. Because status is global, the import takes a branch that reads only the unscoped column. And it calls a cleanup routine that deletes every status row with a non-zero store id for imported products. So even if you did get a store-scoped value in there, the next import would remove it deliberately.",{"type":16,"content":1985},[1986],{"text":1987,"type":20},"On the install I checked, every one of the roughly 190,000 status rows sat on store 0 and there were zero store-scoped rows. Not \"few\". Zero. Which is at least consistent.",{"type":26,"attrs":1989,"content":1990},{"level":28},[1991],{"text":1992,"type":20},"What this changes about the estimate",{"type":16,"content":1994},[1995],{"text":1996,"type":20},"This is the part worth generalising. The request sounded like a content task. Somebody toggles a switch on a product. It was actually a schema change: convert the attribute to website scope, verify the import does not fight it, and confirm nothing downstream assumes a single global value.",{"type":16,"content":1998},[1999],{"text":2000,"type":20},"The saving grace was that there was nothing to migrate. With only store 0 rows in existence, changing the scope does not have to reconcile conflicting values, because none exist.",{"type":16,"content":2002},[2003,2005,2009,2011,2015],{"text":2004,"type":20},"Before promising a per-website behaviour on any attribute, check ",{"text":2006,"type":20,"marks":2007},"is_global",[2008],{"type":202},{"text":2010,"type":20}," in ",{"text":2012,"type":20,"marks":2013},"catalog_eav_attribute",[2014],{"type":202},{"text":2016,"type":20},". It takes one query, and it is the difference between a config change and a data migration.","A request to hide a product on one of several websites turned out to be unstorable. status and visibility had been changed to global scope, and the import deletes store-scoped values anyway.","2026-06-24 09:00","you-cannot-disable-a-product-on-one-website","blog\u002Fyou-cannot-disable-a-product-on-one-website",-60,[],"69f515d7-4020-4458-9dc0-512ba3d9864a",[],{"name":2026,"created_at":2027,"published_at":2028,"updated_at":2029,"id":2030,"uuid":2031,"content":2032,"slug":2128,"full_slug":2129,"sort_by_date":149,"position":2130,"tag_list":2131,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":2132,"first_published_at":2028,"release_id":149,"lang":156,"path":149,"alternates":2133,"default_full_slug":149,"translated_slugs":149},"Every attribute label says Unknown","2026-09-03T12:59:29.084Z","2026-09-03T12:59:29.120Z","2026-09-03T12:59:29.133Z",216067150190749,"84009083-671f-4c81-8f5c-053a899ede86",{"_uid":2033,"body":2034,"title":2026,"excerpt":2126,"component":145,"publish_at":2127},"3edc2ca1-eadc-46b2-b76b-e9c09f5d5cd3",{"type":13,"content":2035},[2036,2051,2056,2066,2070,2075,2095,2100,2104,2113,2117,2122],{"type":16,"content":2037},[2038,2040,2043,2045,2049],{"text":2039,"type":20},"The admin attribute grid was full of a product named ",{"text":1543,"type":20,"marks":2041},[2042],{"type":202},{"text":2044,"type":20},". Not blank, not the attribute code. The literal string. On production, 1,943 rows had ",{"text":2046,"type":20,"marks":2047},"frontend_label = 'Unknown'",[2048],{"type":202},{"text":2050,"type":20},", and some of them were being rendered to the storefront.",{"type":26,"attrs":2052,"content":2053},{"level":28},[2054],{"text":2055,"type":20},"Where the real labels were",{"type":16,"content":2057},[2058,2060,2064],{"text":2059,"type":20},"They were never missing. They were in ",{"text":2061,"type":20,"marks":2062},"eav_attribute_label",[2063],{"type":202},{"text":2065,"type":20},", the store-scoped label table, correctly filled in for every store view. Only the default admin label was wrong.",{"type":16,"content":2067},[2068],{"text":2069,"type":20},"The cause is a locale mismatch in the PIM connector. When it imports an attribute it looks for a label in the *admin* locale, and writes a translated \"Unknown\" when it does not find one. The admin locale at default scope was English. The PIM only carried Dutch labels. So every single attribute got the fallback, every single time.",{"type":26,"attrs":2071,"content":2072},{"level":28},[2073],{"text":2074,"type":20},"The fix is one method name",{"type":16,"content":2076},[2077,2079,2082,2084,2087,2089,2093],{"text":2078,"type":20},"Anything rendering an attribute label to a customer should call ",{"text":1555,"type":20,"marks":2080},[2081],{"type":202},{"text":2083,"type":20},", not ",{"text":1549,"type":20,"marks":2085},[2086],{"type":202},{"text":2088,"type":20},". It resolves the store-scoped label and falls back to ",{"text":2090,"type":20,"marks":2091},"frontend_label",[2092],{"type":202},{"text":2094,"type":20}," on its own, so it is strictly better in every case. One resolver in the codebase did this correctly and had never had a complaint. Another did not, and that was the bug report.",{"type":26,"attrs":2096,"content":2097},{"level":28},[2098],{"text":2099,"type":20},"The tempting fix that does not last",{"type":16,"content":2101},[2102],{"text":2103,"type":20},"The obvious move is an UPDATE. Copy the Dutch labels over the \"Unknown\" values and the admin grid is readable again by lunchtime.",{"type":16,"content":2105},[2106,2108,2111],{"text":2107,"type":20},"It will survive until the next attribute import, which rewrites ",{"text":2090,"type":20,"marks":2109},[2110],{"type":202},{"text":2112,"type":20}," from the PIM and puts every \"Unknown\" straight back. Anybody looking at it a month later sees a bug that was supposedly fixed and is somehow back, with a commit history that says nothing useful.",{"type":16,"content":2114},[2115],{"text":2116,"type":20},"The durable options are a code fix, which is small, or filling in the English labels in the PIM, which is tedious but removes the cause. A database fix is neither.",{"type":26,"attrs":2118,"content":2119},{"level":28},[2120],{"text":2121,"type":20},"The general version",{"type":16,"content":2123},[2124],{"text":2125,"type":20},"When a connector owns a column, you do not own that column. Any repair you apply to it is a countdown, not a fix. The question to ask before writing the UPDATE is simply: what writes this value, and when does it next run?","Nearly two thousand attributes had the literal admin label \"Unknown\". The real labels existed the whole time, one table over, and fixing it in the database would have lasted until the next import.","2026-06-10 09:00","every-attribute-label-says-unknown","blog\u002Fevery-attribute-label-says-unknown",-50,[],"a2506578-7554-4caa-a7a3-f1dea98f2af1",[],{"name":2135,"created_at":2136,"published_at":2137,"updated_at":2138,"id":2139,"uuid":2140,"content":2141,"slug":2240,"full_slug":2241,"sort_by_date":149,"position":2242,"tag_list":2243,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":2244,"first_published_at":2137,"release_id":149,"lang":156,"path":149,"alternates":2245,"default_full_slug":149,"translated_slugs":149},"Your foreign keys are not protecting your EAV data","2026-09-03T12:59:28.488Z","2026-09-03T12:59:28.537Z","2026-09-03T12:59:28.551Z",216067147757723,"fc72325e-7747-4998-aa5d-e07a8e39d852",{"_uid":2142,"body":2143,"title":2135,"excerpt":2238,"component":145,"publish_at":2239},"e5e6e0e9-675c-4b01-952a-42d68273fc79",{"type":13,"content":2144},[2145,2149,2153,2157,2162,2189,2193,2198,2202,2207,2211,2215,2219,2224,2234],{"type":16,"content":2146},[2147],{"text":2148,"type":20},"A data patch removed nine customer attributes. The patch's own DocBlock said the setup helper \"cascades the stored values\", which sounded right, because every EAV value table does declare the constraint:",{"type":312,"content":2150},[2151],{"text":2152,"type":20},"FOREIGN KEY (attribute_id) REFERENCES eav_attribute (attribute_id)\n  ON DELETE CASCADE",{"type":16,"content":2154},[2155],{"text":2156,"type":20},"After it ran in production there were 922,986 orphaned rows across three tables.",{"type":26,"attrs":2158,"content":2159},{"level":28},[2160],{"text":2161,"type":20},"Why the constraint did nothing",{"type":16,"content":2163},[2164,2166,2170,2172,2175,2177,2181,2183,2187],{"text":2165,"type":20},"The patch runs inside ",{"text":2167,"type":20,"marks":2168},"setup:upgrade",[2169],{"type":202},{"text":2171,"type":20},", and ",{"text":2167,"type":20,"marks":2173},[2174],{"type":202},{"text":2176,"type":20}," disables ",{"text":2178,"type":20,"marks":2179},"FOREIGN_KEY_CHECKS",[2180],{"type":202},{"text":2182,"type":20}," for the duration. With checks off, the cascade is not enforced. The parent rows in ",{"text":2184,"type":20,"marks":2185},"eav_attribute",[2186],{"type":202},{"text":2188,"type":20}," were deleted, the value rows were not, and MySQL raised nothing because it had been told not to look.",{"type":16,"content":2190},[2191],{"text":2192,"type":20},"The constraints were present and unchanged the entire time. They were simply not in force at the one moment they mattered.",{"type":26,"attrs":2194,"content":2195},{"level":28},[2196],{"text":2197,"type":20},"The shape of the damage",{"type":16,"content":2199},[2200],{"text":2201,"type":20},"It was not spread evenly. The entity-id side was clean, product value tables were clean, address value tables were clean. Only the attribute side of the customer value tables was affected, and in one of them well over half the rows were orphans. That lopsidedness is a clue in itself: this is specifically what attribute removal leaves behind, not general database rot.",{"type":26,"attrs":2203,"content":2204},{"level":28},[2205],{"text":2206,"type":20},"Count them, do not reason about them",{"type":16,"content":2208},[2209],{"text":2210,"type":20},"The lesson I would want someone to take from this is narrow. Do not argue from the presence of a constraint. Go and count:",{"type":312,"content":2212},[2213],{"text":2214,"type":20},"SELECT COUNT(*) FROM customer_entity_int v\nWHERE NOT EXISTS (\n  SELECT 1 FROM eav_attribute a\n  WHERE a.attribute_id = v.attribute_id\n);",{"type":16,"content":2216},[2217],{"text":2218,"type":20},"Run it per value table. It takes a minute and it either returns zero or it does not.",{"type":26,"attrs":2220,"content":2221},{"level":28},[2222],{"text":2223,"type":20},"And the ticket is not done when the patch runs",{"type":16,"content":2225},[2226,2228,2232],{"text":2227,"type":20},"An attribute removal needs the value cleanup written into the release instructions as well, plus an ",{"text":2229,"type":20,"marks":2230},"OPTIMIZE TABLE",[2231],{"type":202},{"text":2233,"type":20}," afterwards. Deleting the rows logically does not give the space back: InnoDB reported only a few megabytes of free space per table even after nearly a million rows were gone.",{"type":16,"content":2235},[2236],{"text":2237,"type":20},"None of this is exotic. It is just that \"there is a foreign key\" feels like an answer, and here it was not one.","The constraint says ON DELETE CASCADE. Removing nine attributes still left nearly a million orphaned rows, because setup:upgrade turns foreign key checks off.","2026-05-27 09:00","your-foreign-keys-are-not-protecting-your-eav-data","blog\u002Fyour-foreign-keys-are-not-protecting-your-eav-data",-40,[],"5979825c-a234-4724-a788-921f11ccdcda",[],{"name":2247,"created_at":2248,"published_at":2249,"updated_at":2250,"id":2251,"uuid":2252,"content":2253,"slug":2357,"full_slug":2358,"sort_by_date":149,"position":2359,"tag_list":2360,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":2361,"first_published_at":2249,"release_id":149,"lang":156,"path":149,"alternates":2362,"default_full_slug":149,"translated_slugs":149},"Two saves, one cart, and one of them is lost","2026-09-03T12:59:10.653Z","2026-09-03T12:59:10.694Z","2026-09-03T12:59:10.709Z",216067074713746,"320d9737-1972-435c-8d06-b5d4c2139490",{"_uid":2254,"body":2255,"title":2247,"excerpt":2355,"component":145,"publish_at":2356},"88ebe47b-8b1e-4059-96fe-02aa7d53a8f1",{"type":13,"content":2256},[2257,2261,2270,2275,2279,2293,2319,2323,2328,2338,2342,2347,2351],{"type":16,"content":2258},[2259],{"text":2260,"type":20},"A field on the cart kept ending up empty. Not always. Maybe one time in three, and never when anyone was watching closely enough to catch it.",{"type":16,"content":2262},[2263,2265,2268],{"text":2264,"type":20},"The front end fired two mutations at once, in a single ",{"text":1681,"type":20,"marks":2266},[2267],{"type":202},{"text":2269,"type":20},". One set an identifier on the quote. The other saved some custom field data on the same quote. Different fields, different mutations, no obvious reason for them to interfere.",{"type":26,"attrs":2271,"content":2272},{"level":28},[2273],{"text":2274,"type":20},"The cause",{"type":16,"content":2276},[2277],{"text":2278,"type":20},"Magento does not write the column you changed. It writes all of them.",{"type":16,"content":2280},[2281,2285,2287,2291],{"text":2282,"type":20,"marks":2283},"cartRepository->save($quote)",[2284],{"type":202},{"text":2286,"type":20}," builds its UPDATE from the whole in-memory object, because ",{"text":2288,"type":20,"marks":2289},"AbstractDb::prepareDataForUpdate",[2290],{"type":202},{"text":2292,"type":20}," serialises every field it is holding rather than tracking which ones you touched. So the sequence is the classic lost update:",{"type":58,"content":2294},[2295,2301,2307,2313],{"type":61,"content":2296},[2297],{"type":16,"content":2298},[2299],{"text":2300,"type":20},"Request A loads the quote. Field X is null, field Y is null.",{"type":61,"content":2302},[2303],{"type":16,"content":2304},[2305],{"text":2306,"type":20},"Request B loads the quote. Same state.",{"type":61,"content":2308},[2309],{"type":16,"content":2310},[2311],{"text":2312,"type":20},"Request A sets X and saves. The row now has X, and Y is still null.",{"type":61,"content":2314},[2315],{"type":16,"content":2316},[2317],{"text":2318,"type":20},"Request B sets Y and saves, writing the whole row it loaded, in which X was null. X is gone.",{"type":16,"content":2320},[2321],{"text":2322,"type":20},"Whichever commits last wins, and it wins with data it read before the other one committed. Nothing errors. Both mutations return success, because from each request's point of view the save worked perfectly.",{"type":26,"attrs":2324,"content":2325},{"level":28},[2326],{"text":2327,"type":20},"The part that made it hard to reproduce",{"type":16,"content":2329},[2330,2332,2336],{"text":2331,"type":20},"I tried to write a test for it in a single process and could not make it fail. The repository caches loaded quotes by id, so calling ",{"text":2333,"type":20,"marks":2334},"get()",[2335],{"type":202},{"text":2337,"type":20}," twice hands you the same object, and both \"requests\" then mutate one shared instance. The bug vanishes precisely because the test is not doing what production does.",{"type":16,"content":2339},[2340],{"text":2341,"type":20},"You have to force two separate repository instances to see it. That is worth knowing generally: an object cache in a repository will hide every concurrency bug you try to reproduce through that repository.",{"type":26,"attrs":2343,"content":2344},{"level":28},[2345],{"text":2346,"type":20},"What to do about it",{"type":16,"content":2348},[2349],{"text":2350,"type":20},"The reflex is a lock or a retry. Usually there is something simpler available. In this case the two writes did not need to be concurrent at all. One of them could happen earlier, on the same row, before the other request existed, which made it atomic by construction rather than by coordination.",{"type":16,"content":2352},[2353],{"text":2354,"type":20},"When that is not available, the options are the ordinary ones: serialise the calls, write both fields in one mutation, or take a row lock. What you cannot do is assume that writing different fields of the same row is safe. In Magento it is not.","Two API calls fired in parallel, each writing a different field on the same cart. One field kept coming back empty, and the cause is in how Magento builds its UPDATE.","2026-05-06 09:00","two-saves-one-cart-and-one-of-them-is-lost","blog\u002Ftwo-saves-one-cart-and-one-of-them-is-lost",-30,[],"8241179b-48ad-4999-8710-545e7a9ddea8",[],{"name":2364,"created_at":2365,"published_at":2366,"updated_at":2367,"id":2368,"uuid":2369,"content":2370,"slug":2460,"full_slug":2461,"sort_by_date":149,"position":2462,"tag_list":2463,"is_startpage":152,"parent_id":153,"meta_data":149,"group_id":2464,"first_published_at":2366,"release_id":149,"lang":156,"path":149,"alternates":2465,"default_full_slug":149,"translated_slugs":149},"The cache lifetime your config.xml is lying about","2026-09-03T12:59:10.469Z","2026-09-03T12:59:10.508Z","2026-09-03T12:59:10.521Z",216067073951889,"573dce0a-ddad-42ee-a263-8e2461885bba",{"_uid":2371,"body":2372,"title":2364,"excerpt":2458,"component":145,"publish_at":2459},"7adec49a-0e8f-44ef-b468-384290c82647",{"type":13,"content":2373},[2374,2384,2388,2393,2408,2412,2416,2421,2430,2434,2454],{"type":16,"content":2375},[2376,2378,2382],{"text":2377,"type":20},"Somebody asked a reasonable question about a shop I work on: how fresh are the prices on the product page? The module had a cache, the cache had a lifetime, and the lifetime was in the module's own ",{"text":2379,"type":20,"marks":2380},"config.xml",[2381],{"type":202},{"text":2383,"type":20}," as 600 seconds. Ten minutes. That was the answer I nearly gave.",{"type":16,"content":2385},[2386],{"text":2387,"type":20},"The real answer was up to twenty four hours.",{"type":26,"attrs":2389,"content":2390},{"level":28},[2391],{"text":2392,"type":20},"Two things were wrong",{"type":16,"content":2394},[2395,2397,2400,2402,2406],{"text":2396,"type":20},"The first is ordinary and everyone knows it in the abstract. A value in ",{"text":2379,"type":20,"marks":2398},[2399],{"type":202},{"text":2401,"type":20}," is a default, and defaults lose to whatever is in the database. Somebody had set the lifetime to 86400 in ",{"text":2403,"type":20,"marks":2404},"core_config_data",[2405],{"type":202},{"text":2407,"type":20}," long enough ago that nobody remembered. The XML said one thing, production did another, and reading the module told you nothing useful.",{"type":312,"content":2409},[2410],{"text":2411,"type":20},"SELECT path, value FROM core_config_data\nWHERE path LIKE 'yourmodule\u002Fcache%';",{"type":16,"content":2413},[2414],{"text":2415,"type":20},"The second is worse. I went looking for the code that clears this cache when a price import runs. There is none. The cache type was referenced in exactly two places, both inside the caching classes themselves, and the import never touched either. So a price change from the ERP did not reach the storefront when the import finished. It reached the storefront when the day rolled over, or when somebody flushed that cache type by hand.",{"type":26,"attrs":2417,"content":2418},{"level":28},[2419],{"text":2420,"type":20},"Why nobody noticed",{"type":16,"content":2422},[2423,2425,2428],{"text":2424,"type":20},"Because it mostly looks fine. Prices do update, just later than anyone thinks. On a shop where prices move once a week, a twenty four hour lag is invisible. On a shop where somebody is on the phone asking why the site still shows the old price, it is the whole problem, and the person debugging it will read the same ",{"text":2379,"type":20,"marks":2426},[2427],{"type":202},{"text":2429,"type":20}," I did and conclude the cache cannot possibly be the cause.",{"type":26,"attrs":2431,"content":2432},{"level":28},[2433],{"text":1694,"type":20},{"type":58,"content":2435},[2436,2442,2448],{"type":61,"content":2437},[2438],{"type":16,"content":2439},[2440],{"text":2441,"type":20},"Read the value from the database, never from the XML. The XML is the default, not the setting.",{"type":61,"content":2443},[2444],{"type":16,"content":2445},[2446],{"text":2447,"type":20},"Grep for the cache type identifier across the whole codebase, not just your module. If the only hits are the class that writes the cache and the class that reads it, nothing invalidates it.",{"type":61,"content":2449},[2450],{"type":16,"content":2451},[2452],{"text":2453,"type":20},"Then ask what is supposed to invalidate it, and go and find that code. If you cannot find it, it does not exist, and your cache lifetime is your data freshness guarantee.",{"type":16,"content":2455},[2456],{"text":2457,"type":20},"The honest answer to \"how fresh are our prices\" turned out to be \"up to a day, and we cannot tell you when it last changed\". That is a fine answer. It just was not the one anybody expected, and it changed the design decision it was blocking.","A module documented a ten minute price cache. It was really running at twenty four hours, and nothing invalidated it when prices changed.","2026-04-15 09:00","the-cache-lifetime-your-config-xml-is-lying-about","blog\u002Fthe-cache-lifetime-your-config-xml-is-lying-about",-20,[],"16dc5803-0067-4758-b0f7-8cc1de84073d",[]]