<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Marco Orta — Blog</title><description>What breaks when you upgrade your stack, AI for developers, and tech for businesses in Mexico. Checked against the source.</description><link>https://ortamarco.me/</link><language>en-US</language><atom:link href="https://ortamarco.me/en/rss.xml" rel="self" type="application/rss+xml"/><item><title>Meta Banned General-Purpose AI Chatbots on WhatsApp: What It Means for Your Business (and What It Doesn&apos;t)</title><link>https://ortamarco.me/en/blog/meta-whatsapp-ai-chatbot-ban-businesses/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/meta-whatsapp-ai-chatbot-ban-businesses/</guid><description>ChatGPT left WhatsApp outside Europe and Brazil, but your customer-service bot can stay. What the clause says, what is still allowed, and what changes on Oct 1.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Since January 15, 2026, you can no longer talk to ChatGPT, Copilot or Perplexity on WhatsApp from most of the world. A lot of people read that as “Meta banned AI bots”, and articles are circulating that warn businesses their numbers will be suspended for using AI. That isn’t what happened. Meta’s rule targets companies that offer a general-purpose AI assistant &lt;em&gt;as the product&lt;/em&gt;, not businesses that use AI to serve their own customers. That is still allowed, and Meta’s own documentation says so.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What does change for a business are three other things, none of them about the ban. From October 1, Meta charges for customer-service replies, AI replies included, beyond 1,000 a month. Meta now sells its own AI agent for businesses. And the terms are being reorganized on September 23. Each one is below with its source. One caveat up front: in the European Economic Area and Brazil, regulators forced Meta to carve out an exception, so the ban applies everywhere else, including Mexico, the US and the rest of Latin America.&lt;/p&gt;
&lt;h2 id=&quot;what-the-rule-says-word-for-word&quot;&gt;What the rule says, word for word&lt;/h2&gt;
&lt;p&gt;The clause is in the &lt;a href=&quot;https://www.whatsapp.com/legal/business-solution-terms&quot;&gt;WhatsApp Business Solution Terms&lt;/a&gt; (last modified March 6, 2026), under “AI Providers”:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Providers and developers of artificial intelligence or machine learning technologies, including but not limited to large language models, generative artificial intelligence platforms, general-purpose artificial intelligence assistants, or similar technologies as determined by Meta in its sole discretion (“AI Providers”), are strictly prohibited from accessing or using the WhatsApp Business Solution, whether directly or indirectly, for the purposes of providing, delivering, offering, selling, or otherwise making available such technologies &lt;strong&gt;when such technologies are the primary (rather than incidental or ancillary) functionality&lt;/strong&gt; being made available for use.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And two sentences later comes the one that matters to a business:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Notwithstanding the foregoing, &lt;strong&gt;you may retain an AI Provider as your Third Party Service Provider&lt;/strong&gt; in accordance with these WhatsApp Business Solution Terms.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In other words: OpenAI can’t use WhatsApp to offer you ChatGPT. Your business can hire OpenAI (or anyone else) so its model answers &lt;em&gt;your&lt;/em&gt; customers about &lt;em&gt;your&lt;/em&gt; business. &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages&quot;&gt;Meta’s pricing documentation&lt;/a&gt; is blunt about it: service messages “can be powered by a person, like a customer service representative, or by a 3rd-party AI solution.” And the European Commission, when it opened its investigation, put it the same way: businesses may still use AI tools “for ancillary or support functions, such as automated customer support offered via WhatsApp.”&lt;/p&gt;
&lt;p&gt;The rule applied to new providers from October 15, 2025 and to existing ones from January 15, 2026, according to the &lt;a href=&quot;https://ec.europa.eu/commission/presscorner/api/documents?reference=IP/25/2896&amp;amp;language=en&quot;&gt;European Commission&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;who-left-whatsapp-and-who-came-back&quot;&gt;Who left WhatsApp (and who came back)&lt;/h2&gt;





























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Assistant&lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;ChatGPT&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;It had more than 50 million users on WhatsApp. It left on January 15; OpenAI asked people to link their accounts because conversations wouldn’t transfer (&lt;a href=&quot;https://openai.com/index/chatgpt-whatsapp-transition/&quot;&gt;OpenAI&lt;/a&gt;). &lt;strong&gt;It came back on July 13, for European numbers only&lt;/strong&gt; (&lt;a href=&quot;https://help.openai.com/en/articles/6825453-chatgpt-release-notes&quot;&gt;release notes&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Microsoft Copilot&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Left on January 15, without keeping chat history&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Perplexity&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Announced it was shutting down its WhatsApp number&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Luzia&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Big in Spain and Latin America. Moved users to its own app, and on June 30 launched a new WhatsApp assistant on a Spanish number, which fits the European exception&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Meta AI&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Stays. Since January 15 it has been the only AI assistant available inside WhatsApp, according to the European Commission&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;why-chatgpt-is-back-in-europe-and-brazil&quot;&gt;Why ChatGPT is back in Europe and Brazil&lt;/h2&gt;
&lt;p&gt;Because regulators there made Meta back down, and the current version of the clause now includes the exception: general-purpose AI may be offered to users whose numbers have a European Economic Area or Brazil country code.&lt;/p&gt;

































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Date&lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Dec 24, 2025&lt;/td&gt;&lt;td&gt;Italy’s competition authority (AGCM) orders the terms suspended in Italy (&lt;a href=&quot;https://en.agcm.it/en/media/press-releases/2025/12/A576&quot;&gt;AGCM&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Jan 12, 2026&lt;/td&gt;&lt;td&gt;Brazil’s CADE opens an inquiry and suspends the terms as a preventive measure (&lt;a href=&quot;https://www.gov.br/cade/pt-br/assuntos/noticias/cade-abre-inquerito-contra-meta-e-aplica-medida-preventiva-suspendendo-novos-termos-do-whatsapp-sobre-ia&quot;&gt;CADE&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Feb 9, 2026&lt;/td&gt;&lt;td&gt;The European Commission, investigating since December 4, sends its Statement of Objections&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mar 4, 2026&lt;/td&gt;&lt;td&gt;Meta lets rivals back in within Europe, but charges them per message&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Apr 23, 2026&lt;/td&gt;&lt;td&gt;CADE upholds a daily fine of 250,000 reais because Meta was charging chatbots (&lt;a href=&quot;https://www.gov.br/cade/en/matters/news/cade-upholds-daily-fine-against-meta-and-whatsapp-for-failure-to-comply-with-interim-measure&quot;&gt;CADE&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Jun 9, 2026&lt;/td&gt;&lt;td&gt;The European Commission orders free access restored within five working days (&lt;a href=&quot;https://ec.europa.eu/commission/presscorner/api/documents?reference=IP/26/1276&amp;amp;language=en&quot;&gt;Commission&lt;/a&gt;); Meta says it will appeal&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Outside those two regions nothing comparable has happened. In Mexico, the new National Antitrust Commission, which replaced COFECE in October 2025, hasn’t published any investigation of these terms.&lt;/p&gt;
&lt;h2 id=&quot;what-your-business-can-still-do-with-ai-on-whatsapp&quot;&gt;What your business can still do with AI on WhatsApp&lt;/h2&gt;
&lt;p&gt;Everything that is serving your customers about your business: answering hours and prices, recommending products from your catalogue, booking appointments, taking orders, sending a payment link, following up. With three conditions that come from Meta’s own documents:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;There has to be a way to reach a person.&lt;/strong&gt; The &lt;a href=&quot;https://whatsappbusiness.com/policy/&quot;&gt;WhatsApp Business Messaging Policy&lt;/a&gt; allows automated replies but requires “prompt, clear, and direct escalation paths”: a human agent in the chat, a phone number, an email, a support form or a store visit. A bot that never lets anyone talk to a human breaks the policy, even though that has nothing to do with the AI clause.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Conversations can’t be used to train other people’s models.&lt;/strong&gt; The terms forbid using your customers’ data to create or improve AI systems, except a model fine-tuned for your exclusive use. Ask your vendor whether it trains on your data: OpenAI, for instance, says that on its API it does &lt;a href=&quot;https://openai.com/enterprise-privacy/&quot;&gt;not train on it by default&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The bot talks about your business.&lt;/strong&gt; The grey zone is a number whose main offering is “ask me anything”, even if a shop runs it. Meta hasn’t published where “primary” ends and “ancillary” begins, and it reserves the call to its “sole discretion”. A bot that answers about your store is clearly on the allowed side; a ChatGPT with your logo that also helps with homework is not.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A note on what’s circulating: I found no documented case of a business number being suspended under this clause. The articles that talk about “mass suspensions” don’t cite any.&lt;/p&gt;
&lt;p&gt;And if what you want is more customers messaging you, that doesn’t depend on AI: a QR code that opens your chat with a message already typed, on the counter or the receipt, works from day one.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; QR Code Generator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Create QR codes for URLs, WhatsApp, WiFi, vCard contacts, email, phone or SMS, with your logo in the centre. PNG or SVG, no watermark or signup, and nothing leaves your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/qr-code-generator/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;generador-qr&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;what-does-change-for-you-and-has-nothing-to-do-with-the-ban&quot;&gt;What does change for you (and has nothing to do with the ban)&lt;/h2&gt;
&lt;h3 id=&quot;1-from-october-1-service-replies-are-billed&quot;&gt;1. From October 1, service replies are billed&lt;/h3&gt;
&lt;p&gt;According to &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing&quot;&gt;Meta’s pricing page&lt;/a&gt;, from October 1, 2026 service messages (your replies when the customer writes first) are charged at the same rate as utility messages, with &lt;strong&gt;1,000 free per phone number per month&lt;/strong&gt;, not rolled over. AI replies are service messages, so they count exactly like a person’s. And if your account has no payment method on file by September 30, Meta stops delivering your replies from the 1,001st of the month. The technical detail, including what changes in the webhook, is in &lt;a href=&quot;https://ortamarco.me/en/blog/whatsapp-api-october-1-billing-webhook/&quot;&gt;what changes in your code on October 1&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;2-meta-now-sells-its-own-ai-agent&quot;&gt;2. Meta now sells its own AI agent&lt;/h3&gt;
&lt;p&gt;On June 3 Meta announced &lt;a href=&quot;https://about.fb.com/news/2026/06/meta-business-agent/&quot;&gt;Meta Business Agent&lt;/a&gt;, and it promises a lot: answering questions about your business, recommending products from your catalogue, &lt;strong&gt;booking appointments, qualifying leads and closing sales&lt;/strong&gt;. Getting started is free, but Meta has said access will move to paid subscriptions. On the API it has been billed since August 1 at $2 per million tokens, which Meta estimates at about 4 to 5 US cents per message. And on September 15 it introduced &lt;a href=&quot;https://about.fb.com/news/2026/09/introducing-meta-one-subscription-service-more-features-ai/&quot;&gt;Meta One&lt;/a&gt;, with a plan starting at $14.99 a month that includes more use of the agent; pricing and availability vary by region.&lt;/p&gt;
&lt;p&gt;The question isn’t whether it’s good, but whether you want your customer service to depend on the rules and prices of the same company that owns the channel, which is exactly what regulators in Europe are investigating.&lt;/p&gt;
&lt;h3 id=&quot;3-the-terms-are-being-reorganized-on-september-23&quot;&gt;3. The terms are being reorganized on September 23&lt;/h3&gt;
&lt;p&gt;That day WhatsApp splits its terms into two documents, one for the WhatsApp Business app and one for the platform (the API). Its &lt;a href=&quot;https://faq.whatsapp.com/1017485114093363&quot;&gt;help center&lt;/a&gt; describes it as a reorganization, but the new text isn’t published yet. I’ll check it when it is and update this post if the AI clause changes.&lt;/p&gt;
&lt;h2 id=&quot;metas-agent-or-one-connected-to-your-business&quot;&gt;Meta’s agent, or one connected to your business?&lt;/h2&gt;
&lt;p&gt;It depends on what you need it to do:&lt;/p&gt;



































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Meta’s agent&lt;/th&gt;&lt;th&gt;Your own agent on the API&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Setup&lt;/td&gt;&lt;td&gt;From the app, in minutes&lt;/td&gt;&lt;td&gt;Days or weeks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Where its answers come from&lt;/td&gt;&lt;td&gt;Your catalogue and what you configure in Meta&lt;/td&gt;&lt;td&gt;Your systems: inventory, calendar, payments&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Which model&lt;/td&gt;&lt;td&gt;Meta’s&lt;/td&gt;&lt;td&gt;The one you choose, with a no-training guarantee&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;What you pay&lt;/td&gt;&lt;td&gt;Meta’s tokens or subscription, plus messages&lt;/td&gt;&lt;td&gt;Meta’s messages plus the model you use&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Rules and prices&lt;/td&gt;&lt;td&gt;Meta decides&lt;/td&gt;&lt;td&gt;Messages: Meta. Everything else: you&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;For a business that sells what’s in its catalogue and answers repeated questions, Meta’s agent may be enough. When the answer depends on something that lives outside Meta (whether it’s in stock today, whether the doctor has a free slot, what shipping costs to your neighbourhood), you need one connected to your own systems. That’s how &lt;a href=&quot;https://ortamarco.me/en/ai-business-automation-service/&quot;&gt;the AI automation I build for businesses&lt;/a&gt; works, and I covered the technical side in &lt;a href=&quot;https://ortamarco.me/en/blog/ai-whatsapp-agent-for-smbs-laravel-openai/&quot;&gt;building an AI WhatsApp agent for small businesses with Laravel and OpenAI&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;five-questions-to-ask-your-bot-vendor&quot;&gt;Five questions to ask your bot vendor&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Does my number use the official WhatsApp API or an unofficial app? (Unofficial ones can be blocked by Meta even without AI.)&lt;/li&gt;
&lt;li&gt;Does the bot answer only about my business, or anything at all?&lt;/li&gt;
&lt;li&gt;How does a conversation get handed to a person, and how fast?&lt;/li&gt;
&lt;li&gt;Are my conversations used to train any model?&lt;/li&gt;
&lt;li&gt;Does my Meta account have a payment method before September 30, and who pays for messages beyond 1,000 a month?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any answer is “I don’t know”, that’s the one to fix first.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Did Meta ban AI chatbots on WhatsApp? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It banned AI providers from offering general-purpose assistants (like ChatGPT, Copilot or Perplexity) through the WhatsApp Business API when AI is the primary functionality of the service. It applies from January 15, 2026 for existing providers and from October 15, 2025 for new ones. It did not ban businesses from using AI to serve their own customers. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can my business use an AI chatbot on WhatsApp in 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. WhatsApp&amp;#39;s terms let a business retain an AI provider as its third-party service provider, and Meta&amp;#39;s pricing documentation says service messages can be powered by a person or by a third-party AI solution. The conditions: a clear way to reach a person, conversations not used to train other companies&amp;#39; models, and a bot that serves customers about your business. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does ChatGPT work on WhatsApp in Europe but not in Mexico or the US? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because Italy&amp;#39;s competition authority, the European Commission and Brazil&amp;#39;s CADE ordered Meta to suspend the restriction, and the current terms (dated March 6, 2026) allow general-purpose AI for numbers with a European Economic Area or Brazil country code. ChatGPT returned to WhatsApp in Europe on July 13, 2026. Everywhere else the restriction still applies. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can my WhatsApp number be suspended for using AI? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Meta can close accounts that break its terms, and it decides at its sole discretion where AI stops being ancillary and becomes the primary functionality. A bot that serves customers about your business is on the allowed side; a number whose main offering is a general question-answering assistant is at risk. There are no documented public cases of business numbers suspended under this clause. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much does Meta&amp;#39;s AI agent for businesses cost? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Meta Business Agent was announced on June 3, 2026 and getting started is free, but Meta has said access will move to subscriptions. On the API it has been billed since August 1, 2026 at $2 per million tokens, about 4 to 5 US cents per message according to Meta. The Meta One plan introduced on September 15 starts at $14.99 a month and includes more use of the agent; price and availability vary by region. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Are AI chatbot replies on WhatsApp billed? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; From October 1, 2026, yes, like any service message: Meta gives 1,000 free per phone number per month and charges the rest at each country&amp;#39;s utility rate. It makes no difference whether a person or an AI replies. If the account has no payment method, Meta stops delivering replies from the 1,001st of the month. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>News</category><category>WhatsApp</category><category>AI</category><category>Chatbots</category><category>Small Business</category><category>Meta</category><author>Marco Orta</author></item><item><title>Why Online Stores Crash During Mexico&apos;s Buen Fin (and How to Prevent It): The 2026 Technical Guide</title><link>https://ortamarco.me/en/blog/online-store-crash-buen-fin-mexico/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/online-store-crash-buen-fin-mexico/</guid><description>Shared hosting has a written ceiling, gateways rate-limit, and an OXXO cash voucher can lock stock for days. What to check before November 13 in Mexico.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;El Buen Fin, Mexico’s answer to Black Friday, runs from Friday November 13 to Tuesday November 17, 2026. Almost everything published about getting a store ready for it is about banners, coupons and email. This guide is the other half: what breaks technically when the visitors you paid to attract all show up at once, and what to do beforehand. Nearly all of it is written down in your host’s, your payment gateway’s and your platform’s documentation, and almost nobody reads it until the store is already down.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Last year’s numbers show why it matters. According to Mexico’s &lt;a href=&quot;https://www.gob.mx/se/prensa/supera-la-meta-la-xv-edicion-de-el-buen-fin-2025?idiom=es-MX&quot;&gt;Ministry of Economy&lt;/a&gt;, the 2025 edition sold 219.2 billion pesos, and digital commerce was &lt;strong&gt;21% of the total, growing 31% year over year&lt;/strong&gt;. The &lt;a href=&quot;https://blog.amvo.org.mx/publicaciones/reporte-de-resultados-el-buen-fin-2025&quot;&gt;public AMVO report&lt;/a&gt; (Mexico’s online sales association) counted 1.686 billion page views on e-commerce sites and traffic &lt;strong&gt;56% higher&lt;/strong&gt; than the previous edition (which went from four days to five). The retailers’ association ANTAD reports that the last day alone took 26.1% of online sales. The peak doesn’t spread out: it arrives, and it arrives all at once.&lt;/p&gt;
&lt;p&gt;If you sell into Mexico from abroad, or build stores for Mexican clients, the local details are what catch people out: the cash-at-OXXO payment method, the Mexican shared-hosting plans small businesses actually run on, and a gateway landscape where Mercado Pago matters as much as Stripe. I’ve built WooCommerce stores with catalogues in the thousands, Mercado Pago payments and WhatsApp checkout. This is what I check on each one before a date like this, with a source for every limit.&lt;/p&gt;
&lt;h2 id=&quot;what-those-five-days-look-like&quot;&gt;What those five days look like&lt;/h2&gt;








































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Buen Fin 2025&lt;/th&gt;&lt;th&gt;Figure&lt;/th&gt;&lt;th&gt;Source&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Total sales&lt;/td&gt;&lt;td&gt;219.2 billion pesos&lt;/td&gt;&lt;td&gt;Ministry of Economy&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Online sales&lt;/td&gt;&lt;td&gt;21% of the total, +31% YoY (45.9 billion per AMVO)&lt;/td&gt;&lt;td&gt;Economy / AMVO&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Traffic to online stores&lt;/td&gt;&lt;td&gt;+56% over the 5 days, +24% on comparable days&lt;/td&gt;&lt;td&gt;AMVO (Similarweb data)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Average ticket&lt;/td&gt;&lt;td&gt;$1,063 MXN online vs $780 overall&lt;/td&gt;&lt;td&gt;Mexican Banking Association, cited by Economy&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;How people paid online&lt;/td&gt;&lt;td&gt;Credit 48%, debit 43%, store cards 17%, card-free instalments 12%, cash at retail chains 12%&lt;/td&gt;&lt;td&gt;AMVO&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Consumer agency complaints&lt;/td&gt;&lt;td&gt;220 filed, 205 settled; mostly unhonoured prices and refused delivery&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://www.gob.mx/profeco/prensa/profeco-intermedia-la-devolucion-de-mas-de-un-millon-de-pesos-a-personas-consumidoras-en-el-buen-fin?idiom=es-MX&quot;&gt;Profeco&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two rows matter for the technical side. One in eight online buyers &lt;strong&gt;paid cash at a retail chain&lt;/strong&gt; (OXXO and similar), which has a consequence for your inventory that I explain below. And 37% of consumers, per the AIMX survey the ministry cites, &lt;strong&gt;fear a data leak&lt;/strong&gt;: an expired-certificate warning or a checkout page that looks off on your busiest day costs sales even if the site stays up.&lt;/p&gt;
&lt;p&gt;Not everything that fails is yours, either. During Buen Fin 2024, Santander and BBVA customers reported card payment failures; during Hot Sale in May 2026 BBVA confirmed on X that its systems were down, with over five hours of Downdetector reports. And on November 18, 2025, the day after Buen Fin ended, Cloudflare had &lt;a href=&quot;https://blog.cloudflare.com/18-november-2025-outage/&quot;&gt;a global outage&lt;/a&gt; of more than three hours. You can’t stop your customer’s bank from going down. You can have a second payment method ready when it does.&lt;/p&gt;
&lt;h2 id=&quot;1-shared-hosting-has-a-ceiling-and-its-in-writing&quot;&gt;1. Shared hosting has a ceiling, and it’s in writing&lt;/h2&gt;
&lt;p&gt;The shared hosting plan that costs a few hundred pesos a year isn’t “slow”: it has exact limits, and when you hit them it doesn’t slow down, &lt;strong&gt;it returns errors&lt;/strong&gt;. Hostinger publishes its limits on its &lt;a href=&quot;https://www.hostinger.com/support/6976044-parameters-and-limits-of-hosting-plans-in-hostinger/&quot;&gt;plan parameters page&lt;/a&gt; and says reaching them produces “503 – Service temporarily unavailable”:&lt;/p&gt;

































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Hostinger plan&lt;/th&gt;&lt;th&gt;CPU&lt;/th&gt;&lt;th&gt;RAM&lt;/th&gt;&lt;th&gt;Concurrent PHP workers&lt;/th&gt;&lt;th&gt;MySQL connections&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Web Single&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;1 GB&lt;/td&gt;&lt;td&gt;20&lt;/td&gt;&lt;td&gt;25&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Web Premium&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;2 GB&lt;/td&gt;&lt;td&gt;40&lt;/td&gt;&lt;td&gt;50&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Unlimited (formerly Business)&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;3 GB&lt;/td&gt;&lt;td&gt;60&lt;/td&gt;&lt;td&gt;75&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;HostGator Mexico puts it differently in &lt;a href=&quot;https://soporte.hostgator.mx/hc/es-419/articles/28440886639635&quot;&gt;its help centre&lt;/a&gt;: a cap of 25% CPU sustained for 90 seconds or more, and &lt;strong&gt;25 concurrent processes per cPanel account&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Do the maths with your own store. An uncached WooCommerce product page holds a PHP worker while it renders. If it takes one second and you have 20 workers, your ceiling is about 20 pages per second; visitor number 21 doesn’t wait in line, they get an error. That’s why caching, next, isn’t an optimization: it decides whether those limits ever reach you.&lt;/p&gt;
&lt;h2 id=&quot;2-caching-what-can-be-cached-and-what-cant&quot;&gt;2. Caching: what can be cached and what can’t&lt;/h2&gt;
&lt;p&gt;The idea is simple: most visitors, the ones just browsing, should get a prebuilt copy of the page and cost your server nothing. Only the cart, checkout and account pages need to reach the server every time.&lt;/p&gt;
&lt;p&gt;What almost nobody knows is that &lt;strong&gt;Cloudflare doesn’t cache HTML by default&lt;/strong&gt;, on free or paid plans. Its &lt;a href=&quot;https://developers.cloudflare.com/cache/concepts/default-cache-behavior/&quot;&gt;documentation&lt;/a&gt; is explicit: “The Cloudflare CDN does not cache HTML or JSON by default.” If you put your store behind Cloudflare and configured nothing else, images, CSS and JavaScript are cached, but every page still hits your host. The free plan gives you up to 10 Cache Rules and one is enough. This expression is for a Spanish-language WooCommerce store (adjust the paths to yours):&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;(http.host eq &amp;quot;tutienda.mx&amp;quot;
 and not starts_with(http.request.uri.path, &amp;quot;/wp-admin&amp;quot;)
 and not http.request.uri.path contains &amp;quot;/carrito&amp;quot;
 and not http.request.uri.path contains &amp;quot;/finalizar-compra&amp;quot;
 and not http.request.uri.path contains &amp;quot;/mi-cuenta&amp;quot;
 and not http.cookie contains &amp;quot;woocommerce_items_in_cart&amp;quot;
 and not http.cookie contains &amp;quot;wordpress_logged_in&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the “Eligible for cache” action and a short edge TTL (5 to 10 minutes), browsers get the cached copy, and anyone with something in the cart or a logged-in session goes straight to your server. Two warnings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Purge the cache when prices change.&lt;/strong&gt; If your discounts start at midnight on the 13th, a copy cached at 11:58 pm still shows the old price, and “the price wasn’t the advertised one” is among the complaints Profeco receives most. Purge when you switch the offers on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A cookie on every page breaks everything.&lt;/strong&gt; Cloudflare won’t cache a response carrying &lt;code&gt;Set-Cookie&lt;/code&gt;, and some plugins (currency switchers, geolocation, popups) send one on every visit. Check it like this:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-sI&lt;/span&gt; https://tutienda.mx/producto/tu-mas-vendido/ &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-iE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;set-cookie|cf-cache-status&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you want to see, from the second request on, is &lt;code&gt;cf-cache-status: HIT&lt;/code&gt; and no &lt;code&gt;set-cookie&lt;/code&gt;. If you see &lt;code&gt;DYNAMIC&lt;/code&gt; or &lt;code&gt;BYPASS&lt;/code&gt;, that page is still hitting your server.&lt;/p&gt;
&lt;p&gt;Two things look like safety nets and aren’t. &lt;strong&gt;Always Online&lt;/strong&gt; is on every plan, but it only kicks in on 52x errors, serves Internet Archive snapshots and &lt;a href=&quot;https://developers.cloudflare.com/cache/how-to/always-online/&quot;&gt;can’t serve dynamic content&lt;/a&gt;: nobody checks out from an archived copy. And Cloudflare’s &lt;strong&gt;Waiting Room&lt;/strong&gt;, the virtual queue big retailers use, &lt;a href=&quot;https://developers.cloudflare.com/waiting-room/plans/&quot;&gt;only exists on Business and Enterprise plans&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On WooCommerce, also check &lt;em&gt;cart fragments&lt;/em&gt;: the script that updates the cart counter by calling the server on every page. &lt;a href=&quot;https://developer.woocommerce.com/2023/06/16/best-practices-for-the-use-of-the-cart-fragments-api/&quot;&gt;WooCommerce acknowledges&lt;/a&gt; that on heavily trafficked stores “this could severely impact the load on the server”, and since version 7.8 it no longer loads on every page, but plenty of themes and plugins turn it back on. Open a product page with your browser’s dev tools and look for a request to &lt;code&gt;?wc-ajax=get_refreshed_fragments&lt;/code&gt;. If it fires on pages without a mini cart, that’s one server request per visit that caching can’t remove.&lt;/p&gt;
&lt;p&gt;And slim down your product photos: they’re most of a store’s page weight, and on mobile, where most Mexican shoppers buy, every megabyte shows.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #06b6d4&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#06b6d41a;color:#06b6d4&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Image Compressor &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Compress and optimize JPG, PNG or WebP images without visible quality loss, with quality control, format change and resizing. All in your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/image-compressor/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;compresor-imagenes&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;3-your-payment-gateway-has-a-limit-too&quot;&gt;3. Your payment gateway has a limit too&lt;/h2&gt;
&lt;p&gt;The server isn’t the only thing that can say no. Stripe publishes its &lt;a href=&quot;https://docs.stripe.com/rate-limits&quot;&gt;rate limits&lt;/a&gt;: &lt;strong&gt;100 requests per second&lt;/strong&gt; in live mode, with most individual endpoints capped at 25. And it says plainly that “a sudden increase in charge volume, such as a flash sale, might result in rate limiting”; if you expect a spike, it asks you to contact support &lt;strong&gt;beforehand&lt;/strong&gt;. Mercado Pago doesn’t publish a number, but its Orders API answers &lt;code&gt;429 Too Many Requests&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header when you exceed it.&lt;/p&gt;
&lt;p&gt;For a small business, 100 charges a second is plenty. The problem shows up when the integration makes several calls per purchase (create customer, create payment intent, poll status) or retries without backing off: a badly handled 429 becomes “we couldn’t process your payment” for a customer who had the funds.&lt;/p&gt;
&lt;p&gt;Bank verification (3-D Secure, the “confirm in your app” screen) changes the flow too. Mexico has no regulatory mandate for it, but Mercado Pago &lt;a href=&quot;https://www.mercadopago.com.mx/developers/es/docs/checkout-api-payments/how-tos/integrate-3ds&quot;&gt;recommends enabling it&lt;/a&gt; in optional mode, and when the bank asks for a challenge the payment sits in &lt;code&gt;pending&lt;/code&gt; with detail &lt;code&gt;pending_challenge&lt;/code&gt;. If your integration treats “pending” as “declined”, you’re cancelling good orders. AMVO lists “payment method rejected” among the barriers to buying online.&lt;/p&gt;
&lt;h2 id=&quot;4-the-problem-nobody-mentions-cash-at-oxxo-locks-your-inventory&quot;&gt;4. The problem nobody mentions: cash at OXXO locks your inventory&lt;/h2&gt;
&lt;p&gt;This is what worries me most for a store with limited stock, and I didn’t see it in any Buen Fin guide I read. When a customer chooses to pay cash, the gateway issues a voucher with an expiry date, and until they pay, the item is in limbo:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Gateway&lt;/th&gt;&lt;th&gt;Voucher validity&lt;/th&gt;&lt;th&gt;If they pay late&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Stripe (OXXO)&lt;/td&gt;&lt;td&gt;5 days by default, configurable from 1 to 7; confirmation arrives the next business day&lt;/td&gt;&lt;td&gt;The voucher expires (&lt;a href=&quot;https://docs.stripe.com/payments/oxxo&quot;&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mercado Pago (OXXO, Paycash)&lt;/td&gt;&lt;td&gt;Configurable from 1 to 30 days; they recommend 3. Crediting takes up to 2 business hours&lt;/td&gt;&lt;td&gt;“The amount will be refunded” (&lt;a href=&quot;https://www.mercadopago.com.mx/developers/es/docs/checkout-api-orders/payment-integration/other-payment-methods&quot;&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Conekta&lt;/td&gt;&lt;td&gt;Set by your integration with &lt;code&gt;expires_at&lt;/code&gt;; signalled by the &lt;code&gt;order.expired&lt;/code&gt; webhook&lt;/td&gt;&lt;td&gt;Up to your integration (&lt;a href=&quot;https://developers.conekta.com/docs/order&quot;&gt;docs&lt;/a&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Now the WooCommerce side. Its “Hold stock” setting reserves items for orders &lt;strong&gt;pending payment&lt;/strong&gt; for as long as you configure (60 minutes is the recommended value) and then cancels them, according to &lt;a href=&quot;https://woocommerce.com/document/managing-orders/troubleshooting-orders/&quot;&gt;its guide&lt;/a&gt;. But the same guide says delayed payment methods leave the order &lt;strong&gt;on hold&lt;/strong&gt;, where that automatic cancellation doesn’t apply. If your gateway plugin doesn’t cancel the order when the voucher expires, the item stays reserved for days by someone who may never pay, while you tell the customer holding a card that it’s sold out.&lt;/p&gt;
&lt;p&gt;Before the 13th:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Shorten voucher validity for the campaign.&lt;/strong&gt; One or two days instead of five or thirty.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check that your plugin cancels the order on expiry.&lt;/strong&gt; Place a test cash order with the minimum validity, don’t pay it, and check the next day that the order was cancelled and the stock came back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you sell over WhatsApp with payment links&lt;/strong&gt;, give them a short expiry. Clip, for example, lets a link &lt;a href=&quot;https://developer.clip.mx/discuss/6480ac8b182697101358f435&quot;&gt;expire the same day&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;5-overselling-two-people-buy-the-last-unit&quot;&gt;5. Overselling: two people buy the last unit&lt;/h2&gt;
&lt;p&gt;Since version 4.3, WooCommerce reserves stock during checkout to stop two simultaneous purchases taking the same unit (&lt;a href=&quot;https://github.com/woocommerce/woocommerce/pull/26395&quot;&gt;PR #26395&lt;/a&gt;). Even so, &lt;a href=&quot;https://github.com/woocommerce/woocommerce/issues/44273&quot;&gt;issue #44273&lt;/a&gt;, where the team confirms that “a race condition like this is possible”, is still open today. On a normal day it almost never happens. During Buen Fin, with hundreds of people on your best seller, is when it does.&lt;/p&gt;
&lt;p&gt;For low-stock products: keep a buffer (list 18 if you have 20), cap quantity per customer, and decide in advance what you’ll do if it happens, with an apology and a fast refund. Refused delivery was among the complaints that dominated Profeco’s 2025 figures. Shopify’s &lt;a href=&quot;https://help.shopify.com/en/manual/promoting-marketing/sales/flash-sales&quot;&gt;flash sale guide&lt;/a&gt; recommends switching to manual payment capture for the same reason: charge only for what you can actually ship.&lt;/p&gt;
&lt;h2 id=&quot;6-what-runs-in-the-background&quot;&gt;6. What runs in the background&lt;/h2&gt;
&lt;p&gt;Order confirmation emails, payment webhooks and stock updates in WooCommerce go through Action Scheduler, which by default &lt;a href=&quot;https://actionscheduler.org/perf/&quot;&gt;processes batches of 25 actions&lt;/a&gt; for 30 seconds, triggered by WP-Cron. And WP-Cron only runs when someone visits the site. With caching done right, hardly anyone reaches the server, and tasks can pile up. The result: customers who paid and don’t get their confirmation, and message you asking whether the order went through.&lt;/p&gt;
&lt;p&gt;The fix is a real server cron instead of the visit-driven one: set &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; in &lt;code&gt;wp-config.php&lt;/code&gt; and schedule a call to &lt;code&gt;wp-cron.php&lt;/code&gt; every minute from your hosting panel. Almost every shared host allows it.&lt;/p&gt;
&lt;h2 id=&quot;7-test-it-first-with-fake-traffic&quot;&gt;7. Test it first with fake traffic&lt;/h2&gt;
&lt;p&gt;All of the above can be checked without waiting for the 13th. A load test simulates many visitors at once and shows you where errors start. Two free options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;k6&lt;/strong&gt;, by Grafana: free on your own machine, and its free cloud tier includes &lt;a href=&quot;https://grafana.com/pricing/&quot;&gt;500 virtual user hours a month&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loader.io&lt;/strong&gt;: the free plan allows &lt;a href=&quot;https://loader.io/pricing&quot;&gt;10,000 clients per test&lt;/a&gt; for one minute, run from US data centres.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A minimal k6 script that visits the home page and your best seller:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot; data-language=&quot;javascript&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; http &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;k6/http&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; check&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; sleep &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;k6&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; options &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;2m&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;50&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// ramp to 50 concurrent visitors&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;5m&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;50&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// hold&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;1m&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;thresholds&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;http_req_failed&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;rate&amp;lt;0.01&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;// under 1% errors&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;http_req_duration&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;p(95)&amp;lt;2000&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// 95% under 2 seconds&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;http&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;https://tutienda.mx/&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token function-variable function&quot;&gt;home&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;status &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;200&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;http&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;https://tutienda.mx/producto/tu-mas-vendido/&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token function-variable function&quot;&gt;product&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;status &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;200&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it with &lt;code&gt;k6 run test.js&lt;/code&gt;. Three rules: test pages, &lt;strong&gt;never your payment gateway&lt;/strong&gt;; on shared hosting, tell your provider first, because a load test looks a lot like an attack; and aim at three to five times your normal peak hour. There’s no public figure for how much a small store’s traffic rises during Buen Fin compared with an ordinary day; if your analytics kept last year’s, use that. If the test fails at 50 visitors, you know what to fix, and you have seven weeks to do it.&lt;/p&gt;
&lt;h2 id=&quot;if-your-store-runs-on-shopify-or-tiendanube&quot;&gt;If your store runs on Shopify or Tiendanube&lt;/h2&gt;
&lt;p&gt;Infrastructure isn’t your problem: Shopify handled &lt;a href=&quot;https://www.shopify.com/investors/press-releases/shopify-merchants-achieve-record-breaking-14-6-billion-in-black-friday-cyber-monday-sales&quot;&gt;489 million requests per minute&lt;/a&gt; at its edge over Black Friday and Cyber Monday 2025, and its guide says special preparation is only needed if you expect “tens of thousands of customers” starting checkout within minutes. Yours are the other sections: theme weight and the apps you load on every page, the gateway and its pending states, cash voucher validity, and your stock buffer.&lt;/p&gt;
&lt;h2 id=&quot;the-two-weeks-before-checklist&quot;&gt;The two-weeks-before checklist&lt;/h2&gt;





















































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;What&lt;/th&gt;&lt;th&gt;How you verify it&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;HTML caching on for browsers&lt;/td&gt;&lt;td&gt;&lt;code&gt;cf-cache-status: HIT&lt;/code&gt; on the second request to a product page&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;No page sends &lt;code&gt;Set-Cookie&lt;/code&gt; to visitors&lt;/td&gt;&lt;td&gt;The same &lt;code&gt;curl -sI&lt;/code&gt; as above&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cart, checkout and account excluded from cache&lt;/td&gt;&lt;td&gt;Add something to the cart in another window and check the counter updates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;New prices visible&lt;/td&gt;&lt;td&gt;Purge the cache when offers go live and check in a private window&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Short cash-voucher validity&lt;/td&gt;&lt;td&gt;Test OXXO order you don’t pay: on expiry, order cancelled and stock back&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;“Pending” payments treated as pending&lt;/td&gt;&lt;td&gt;Test purchase with 3-D Secure that you don’t complete&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Real server cron&lt;/td&gt;&lt;td&gt;&lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; in &lt;code&gt;wp-config.php&lt;/code&gt; and a task every minute&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Buffer on best sellers&lt;/td&gt;&lt;td&gt;Listed stock below real stock&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SSL certificate valid past November 17&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://ortamarco.me/en/tools/ssl-lookup/&quot;&gt;SSL lookup&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Load test passed&lt;/td&gt;&lt;td&gt;k6 or Loader.io with no errors at three times your peak&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Plan B for payments&lt;/td&gt;&lt;td&gt;Payment link and WhatsApp ready in case the gateway or the bank fails&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;If you need a store or site in Mexico built to survive a date like this, that’s &lt;a href=&quot;https://ortamarco.me/en/web-development-service/&quot;&gt;what I do&lt;/a&gt;.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When is Buen Fin 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; From Friday November 13 to Tuesday November 17, 2026. It is the 16th edition and runs five days because it includes Monday November 16, the observed Revolution Day holiday. Business registration is free at elbuenfin.org, from September 8 to November 12. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why do online stores crash during Buen Fin? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Usually because every visit reaches the server uncached and the host runs out of the processes it allows. Shared plans have fixed limits (20 PHP workers on Hostinger&amp;#39;s entry plan, 25 processes per cPanel account on HostGator Mexico) and return a 503 error past them. The payment gateway can also fail when it rate-limits, or the customer&amp;#39;s bank, which is out of your hands. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does Cloudflare&amp;#39;s free plan protect a store from Buen Fin traffic? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It helps, but not just by switching it on. Cloudflare does not cache HTML by default, so without a Cache Rule every page still hits your host. The free plan allows up to 10 rules, and one is enough to cache pages for browsers while excluding cart, checkout and account. The virtual queue (Waiting Room) only exists on Business and Enterprise plans. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does a pending OXXO payment lock my inventory? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It can, for days. Stripe OXXO vouchers expire after 5 days by default (configurable from 1 to 7) and Mercado Pago&amp;#39;s after 1 to 30. In WooCommerce, delayed-payment orders go on hold and the 60-minute hold-stock setting only cancels orders pending payment, so the item stays reserved until the gateway plugin cancels the order when the voucher expires. Shorten validity for the campaign and test it with an order you do not pay. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I load test an online store? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; With k6, free and open source, or Loader.io, whose free plan runs up to 10,000 clients in a one-minute test. Simulate visitors on the home page and best sellers, never on the payment gateway, aim at three to five times your peak hour, and warn your shared hosting provider first, because a load test looks like an attack. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Buen Fin</category><category>E-commerce</category><category>WooCommerce</category><category>Hosting</category><category>Payments</category><category>Mexico</category><author>Marco Orta</author></item><item><title>Laravel Octane + FrankenPHP: What Actually Breaks (I Reproduced Every Leak)</title><link>https://ortamarco.me/en/blog/what-breaks-laravel-octane-frankenphp/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-laravel-octane-frankenphp/</guid><description>I ran Laravel 13 on Octane + FrankenPHP and reproduced each leak. The famous singleton warning is misstated, and --max-requests=0 won&apos;t even start.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Octane keeps your Laravel application in memory between requests. That is where the speed comes from, and it is where everything that breaks comes from. Almost every “Octane gotchas” article repeats the same warning: a singleton that receives the request in its constructor captures the first request forever. I built a Laravel 13 app on Octane with FrankenPHP in Docker and reproduced each failure with &lt;code&gt;curl&lt;/code&gt;, and that warning is wrong as usually stated. The leaks that do happen come from somewhere else.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three results surprised me. A request-capturing singleton resolved during a request does &lt;strong&gt;not&lt;/strong&gt; leak, because Octane handles every request on a clone of the application. &lt;code&gt;--max-requests=0&lt;/code&gt;, which means “unlimited” on Swoole and RoadRunner, stops FrankenPHP from &lt;strong&gt;starting at all&lt;/strong&gt;. And the features the docs mark as Swoole-only don’t throw on FrankenPHP: most of them silently do something else. Everything below comes with the command and the output.&lt;/p&gt;
&lt;p&gt;If you haven’t decided whether Octane is worth it for your app, start with &lt;a href=&quot;https://ortamarco.me/en/blog/laravel-performance-optimization-guide/&quot;&gt;the Laravel performance guide&lt;/a&gt;; this post is for the step after “let’s turn it on”.&lt;/p&gt;
&lt;h2 id=&quot;the-lab&quot;&gt;The lab&lt;/h2&gt;





























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Component&lt;/th&gt;&lt;th&gt;Version&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Laravel&lt;/td&gt;&lt;td&gt;13.32.0 (skeleton requires PHP ^8.3)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;laravel/octane&lt;/td&gt;&lt;td&gt;2.19.1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;FrankenPHP&lt;/td&gt;&lt;td&gt;1.12.7, PHP 8.5.10 ZTS, Caddy 2.11.4&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Image&lt;/td&gt;&lt;td&gt;&lt;code&gt;dunglas/frankenphp:latest&lt;/code&gt; (Debian 13) + &lt;code&gt;pcntl&lt;/code&gt; + Composer&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Load tool&lt;/td&gt;&lt;td&gt;&lt;code&gt;wrk&lt;/code&gt;, pinned to separate CPUs&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;A fresh skeleton, nine small classes and a route file. Octane runs with &lt;code&gt;php artisan octane:frankenphp&lt;/code&gt;; the comparison mode is FrankenPHP’s classic &lt;code&gt;php-server&lt;/code&gt;, which boots Laravel on every request the way PHP-FPM does.&lt;/p&gt;
&lt;h2 id=&quot;before-it-even-starts-pcntl-zip-and-the-musl-binary&quot;&gt;Before it even starts: pcntl, zip and the musl binary&lt;/h2&gt;
&lt;p&gt;Three things got in the way before the first request:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The official FrankenPHP image has no &lt;code&gt;pcntl&lt;/code&gt;.&lt;/strong&gt; Without it, &lt;code&gt;octane:frankenphp&lt;/code&gt; dies immediately:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Error  Undefined constant &amp;quot;Laravel\Octane\Commands\Concerns\SIGINT&amp;quot;
at vendor/laravel/octane/src/Commands/Concerns/InteractsWithServers.php:174
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Dockerfile in the Octane docs runs &lt;code&gt;install-php-extensions pcntl&lt;/code&gt; for exactly this reason. Copy that line.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It has no &lt;code&gt;zip&lt;/code&gt; or &lt;code&gt;unzip&lt;/code&gt; either&lt;/strong&gt;, so &lt;code&gt;composer require&lt;/code&gt; fails inside it. Install dependencies in a Composer stage and copy &lt;code&gt;vendor/&lt;/code&gt; over.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;On Alpine, &lt;code&gt;octane:install&lt;/code&gt; downloads the musl binary.&lt;/strong&gt; When there’s no &lt;code&gt;frankenphp&lt;/code&gt; on the PATH, Octane downloads one, and it picks the glibc build only if &lt;code&gt;getconf GNU_LIBC_VERSION&lt;/code&gt; succeeds. On Alpine it doesn’t, so you get the 173 MB musl build. FrankenPHP’s own performance guide says to “avoid musl in production” because PHP is slower on it, especially in ZTS mode. On my trivial benchmark route I couldn’t measure a difference (5,900 req/s against 5,800-6,100 on glibc), but the &lt;a href=&quot;https://frankenphp.dev/docs/known-issues/&quot;&gt;known-issues page&lt;/a&gt; lists concrete gaps, such as &lt;code&gt;GLOB_BRACE&lt;/code&gt; not being available. Prefer the Debian images.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;1-singletons-the-leak-everyone-describes-isnt-the-one-that-bites&quot;&gt;1. Singletons: the leak everyone describes isn’t the one that bites&lt;/h2&gt;
&lt;p&gt;The binding every article uses as its example:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// AppServiceProvider::register()&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token property&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;singleton&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;RequestEcho&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;fn&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name type-declaration&quot;&gt;Application&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$app&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;RequestEcho&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$app&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;request&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Resolved inside a route, with one worker so every request hits the same one:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;?user=alice → {&amp;quot;query_user&amp;quot;:&amp;quot;alice&amp;quot;,&amp;quot;singleton_sees&amp;quot;:&amp;quot;alice&amp;quot;}
?user=bob   → {&amp;quot;query_user&amp;quot;:&amp;quot;bob&amp;quot;,&amp;quot;singleton_sees&amp;quot;:&amp;quot;bob&amp;quot;}
?user=carol → {&amp;quot;query_user&amp;quot;:&amp;quot;carol&amp;quot;,&amp;quot;singleton_sees&amp;quot;:&amp;quot;carol&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No leak. The reason is in Octane’s &lt;code&gt;Worker::handle()&lt;/code&gt;: every request runs on &lt;code&gt;$sandbox = clone $this-&amp;gt;app&lt;/code&gt;, and a singleton resolved on that clone dies with it at the end of the request. The famous warning is true only in two situations, and they’re what you should be looking for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The singleton is resolved during boot.&lt;/strong&gt; The &lt;a href=&quot;https://laravel.com/docs/13.x/octane&quot;&gt;Octane docs&lt;/a&gt; phrase it precisely: the problem is when the instance “is resolved during the application boot process”. I resolved the same class in &lt;code&gt;boot()&lt;/code&gt;, as an eager package would:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;?user=alice → {&amp;quot;query_user&amp;quot;:&amp;quot;alice&amp;quot;,&amp;quot;singleton_sees&amp;quot;:null,&amp;quot;singleton_url&amp;quot;:&amp;quot;http://localhost:8000&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It doesn’t capture the first request. It captures the &lt;strong&gt;fake console request&lt;/strong&gt; that Laravel binds while booting (URL = &lt;code&gt;APP_URL&lt;/code&gt;, no input), and keeps it for every request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Something resolves it through the base application.&lt;/strong&gt; This is the one that really captures the first request. A listener registered in &lt;code&gt;boot()&lt;/code&gt; that resolves lazily through &lt;code&gt;$this-&amp;gt;app&lt;/code&gt;, which is the original application Octane clones, not the clone:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// AppServiceProvider::boot(). Nothing is resolved here yet.&lt;/span&gt;
&lt;span class=&quot;token class-name static-context&quot;&gt;Event&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;listen&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;lab.who&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;fn&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token property&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;make&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;RequestEchoViaProvider&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;?user=alice → &amp;quot;alice&amp;quot;
?user=bob   → &amp;quot;alice&amp;quot;
?user=carol → &amp;quot;alice&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first request resolved the singleton on the base app, and it stayed there for the life of the worker. Adding the class to &lt;code&gt;flush&lt;/code&gt; in &lt;code&gt;config/octane.php&lt;/code&gt; fixed it (&lt;code&gt;alice&lt;/code&gt;, &lt;code&gt;bob&lt;/code&gt;, &lt;code&gt;carol&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The same mechanism turns stateful singletons into a data leak between users. A &lt;code&gt;CurrentTenant&lt;/code&gt; singleton that lives on the base app (because something resolved it at boot), with a middleware that sets it only when the request carries &lt;code&gt;X-Tenant&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;-H &amp;#39;X-Tenant: acme&amp;#39; → {&amp;quot;singleton_tenant&amp;quot;:&amp;quot;acme&amp;quot;,&amp;quot;scoped_tenant&amp;quot;:&amp;quot;acme&amp;quot;}
(no header)         → {&amp;quot;x_tenant_header&amp;quot;:null,&amp;quot;singleton_tenant&amp;quot;:&amp;quot;acme&amp;quot;,&amp;quot;scoped_tenant&amp;quot;:null}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The anonymous request inherited &lt;code&gt;acme&lt;/code&gt;. With four workers it’s worse to debug: only the worker that served &lt;code&gt;acme&lt;/code&gt; leaked, so one request in four came back wrong. The same class registered with &lt;code&gt;scoped()&lt;/code&gt; instead of &lt;code&gt;singleton()&lt;/code&gt; returned &lt;code&gt;null&lt;/code&gt; on every request without the header.&lt;/p&gt;
&lt;p&gt;What to do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;scoped()&lt;/code&gt; for anything that holds per-request state (tenant, current user, locale).&lt;/li&gt;
&lt;li&gt;Don’t resolve request-dependent services in &lt;code&gt;boot()&lt;/code&gt;, and look for listeners and macros registered in &lt;code&gt;boot()&lt;/code&gt; that call &lt;code&gt;$this-&amp;gt;app-&amp;gt;make()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For anything you can’t change (a package), add it to &lt;code&gt;flush&lt;/code&gt; in &lt;code&gt;config/octane.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For services that need the request or config, inject a resolver, &lt;code&gt;fn () =&amp;gt; Container::getInstance()&lt;/code&gt;, instead of the object.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;2-static-state-grows-until-the-worker-restarts&quot;&gt;2. Static state grows until the worker restarts&lt;/h2&gt;
&lt;p&gt;A static array that gets 10 KB per request, which is what a naive in-memory cache or a registry of “already processed” items does:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;req#1    worker dcb87b  items 1    mem_kb 5667
req#100                 items 100  mem_kb 7001
req#300                 items 300  mem_kb 9410
req#500                 items 500  mem_kb 11810
req#501  worker c5125d  items 1    mem_kb 1253   ← restart at --max-requests=500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;About 12 KB per request, reset only when Octane restarts the worker after 500 requests, which is the default. The restart doesn’t appear in the logs, and it costs little: 1.85 ms before, 4.40 ms on the first request of the new worker, 2.19 ms after.&lt;/p&gt;
&lt;p&gt;A trap when you test this yourself: my first version used &lt;code&gt;str_repeat(&amp;#39;x&amp;#39;, 10240)&lt;/code&gt; and memory stayed &lt;strong&gt;flat&lt;/strong&gt; over 500 requests. OPcache folds that constant expression into a single interned string, so every array item pointed at the same memory. With &lt;code&gt;Str::random()&lt;/code&gt; the leak showed. If your leak test comes back clean, check that the data is actually different on each request.&lt;/p&gt;
&lt;h2 id=&quot;3---max-requests0-doesnt-mean-unlimited-on-frankenphp&quot;&gt;3. &lt;code&gt;--max-requests=0&lt;/code&gt; doesn’t mean “unlimited” on FrankenPHP&lt;/h2&gt;
&lt;p&gt;On &lt;a href=&quot;https://openswoole.com/docs/modules/swoole-server/configuration&quot;&gt;OpenSwoole&lt;/a&gt;, &lt;code&gt;max_request&lt;/code&gt; 0 means no limit, and on &lt;a href=&quot;https://docs.roadrunner.dev/docs/php-worker/pool&quot;&gt;RoadRunner&lt;/a&gt; “zero (or nothing) means no limit” for &lt;code&gt;max_jobs&lt;/code&gt;. On FrankenPHP:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php artisan octane:frankenphp --max-requests&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Error: loading initial config: … frankenphp app module: start: failed to initialize workers:
too many consecutive failures: worker public/frankenphp-worker.php has not reached frankenphp_handle_request().
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The container exits with code 1. Octane’s worker script loops &lt;code&gt;while ($requestCount &amp;lt; $maxRequests …)&lt;/code&gt;, so with 0 it never serves a request; FrankenPHP sees six consecutive workers fail to reach &lt;code&gt;frankenphp_handle_request()&lt;/code&gt; and gives up. Through &lt;code&gt;octane:start --server=frankenphp --max-requests=0&lt;/code&gt; the server does start, but the worker gets &lt;code&gt;MAX_REQUESTS=500&lt;/code&gt;: a &lt;code&gt;?:&lt;/code&gt; in the command replaces the 0 with the config default without telling you. The two pull requests that would have made 0 mean unlimited were closed without merging.&lt;/p&gt;
&lt;p&gt;If you want effectively unlimited, pass a large number (&lt;code&gt;--max-requests=1000000&lt;/code&gt; measured the same throughput as 500). But the restarts are what protect you from section 2, so I’d keep the default.&lt;/p&gt;
&lt;h2 id=&quot;4-config-injected-into-a-singleton-goes-stale-and-can-poison-the-worker&quot;&gt;4. Config injected into a singleton goes stale, and can poison the worker&lt;/h2&gt;
&lt;p&gt;A singleton that receives the config repository in its constructor, and a request that changes a value at runtime (per-tenant config, say):&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;?set=changed-in-request → {&amp;quot;config_helper&amp;quot;:&amp;quot;changed-in-request&amp;quot;,&amp;quot;injected_repo&amp;quot;:&amp;quot;original&amp;quot;,
                           &amp;quot;resolver_closure&amp;quot;:&amp;quot;changed-in-request&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The injected repository is the base app’s, so within the request it doesn’t see the change. The worse case is the reverse: a request that &lt;strong&gt;writes&lt;/strong&gt; through that injected repository (&lt;code&gt;$service-&amp;gt;config-&amp;gt;set(...)&lt;/code&gt;) changes the base app’s config, and from then on every later request on that worker reads &lt;code&gt;mutated-via-service&lt;/code&gt;. In classic mode none of this survives the request. The fix is the one the docs give: inject a resolver closure, or call &lt;code&gt;config()&lt;/code&gt; when you need the value.&lt;/p&gt;
&lt;h2 id=&quot;5-what-frankenphp-adds-threads-_env-and-exit&quot;&gt;5. What FrankenPHP adds: threads, &lt;code&gt;$_ENV&lt;/code&gt; and &lt;code&gt;exit()&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Threads, not processes.&lt;/strong&gt; Four workers all reported the same PID, and &lt;code&gt;/proc&lt;/code&gt; showed a single &lt;code&gt;frankenphp&lt;/code&gt; process with 67 threads. FrankenPHP’s docs say so (“threads instead of processes”), and it matters for two reasons: extensions that aren’t thread-safe can’t be used (the known-issues page lists &lt;code&gt;imap&lt;/code&gt;, &lt;code&gt;newrelic&lt;/code&gt; and &lt;code&gt;pcov&lt;/code&gt;, and warns that &lt;code&gt;imagick&lt;/code&gt; in the Docker images can crash because of its OpenMP threads), and a crash in any thread takes down the whole process, every worker included.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;$_ENV&lt;/code&gt; and &lt;code&gt;putenv()&lt;/code&gt; survive between requests.&lt;/strong&gt; FrankenPHP resets &lt;code&gt;$_GET&lt;/code&gt;, &lt;code&gt;$_POST&lt;/code&gt;, &lt;code&gt;$_SERVER&lt;/code&gt; and the rest, but its &lt;a href=&quot;https://frankenphp.dev/docs/worker/&quot;&gt;worker docs&lt;/a&gt; say “&lt;code&gt;$_ENV&lt;/code&gt; is currently not reset between requests”. I tested it with &lt;code&gt;putenv()&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;?v=secret-from-alice → {&amp;quot;prev_getenv&amp;quot;:null}
(next request)       → {&amp;quot;prev_$_ENV&amp;quot;:&amp;quot;secret-from-alice&amp;quot;,&amp;quot;prev_$_SERVER&amp;quot;:null,
                        &amp;quot;prev_getenv&amp;quot;:&amp;quot;secret-from-alice&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With four workers, only the thread that set it saw the value. If any code writes request data into the environment (some SDKs set credentials that way), it leaks to whoever that thread serves next.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;exit()&lt;/code&gt; returns 200.&lt;/strong&gt; &lt;code&gt;exit(0)&lt;/code&gt; and &lt;code&gt;exit(1)&lt;/code&gt; both sent the output so far with &lt;strong&gt;HTTP 200&lt;/strong&gt; and rebooted the worker. Twelve &lt;code&gt;exit(1)&lt;/code&gt; in a row: twelve 200s, and the server survived. &lt;code&gt;dd()&lt;/code&gt; returned 500 and also rebooted. A health check that only looks at the status code won’t see a script that dies halfway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The 30-second limit is per request.&lt;/strong&gt; Octane sets &lt;code&gt;max_execution_time&lt;/code&gt; to 30 seconds. Two 20-second requests in a row on the same worker both returned 200; a 35-second one returned 500 after 31.2 s with “Maximum execution time of 30 seconds exceeded” in the log, and the worker rebooted.&lt;/p&gt;
&lt;p&gt;Open issues worth knowing before you deploy (from &lt;code&gt;php/frankenphp&lt;/code&gt;, still open in September 2026):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/php/frankenphp/issues/2588&quot;&gt;#2588&lt;/a&gt;: Octane worker segfault (exit 139). The reporter fixed it by switching &lt;code&gt;opcache.jit&lt;/code&gt; from &lt;code&gt;tracing&lt;/code&gt; to &lt;code&gt;function&lt;/code&gt;, pointing at a tracing-JIT regression in PHP 8.5.5 and later.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/php/frankenphp/issues/1074&quot;&gt;#1074&lt;/a&gt;: concurrent file uploads hang over HTTPS; the maintainer reproduced it in July with 30 multipart uploads over one HTTP/2 connection.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/php/frankenphp/issues/2388&quot;&gt;#2388&lt;/a&gt;: the Alpine image segfaults with hundreds of environment variables (Kubernetes service links are the usual source).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And update Octane: before 2.18.0, a &lt;code&gt;multipart/form-data&lt;/code&gt; POST without a boundary killed the request. With the old worker script I got a silent 500 with nothing in &lt;code&gt;laravel.log&lt;/code&gt;; on 2.19.1 it’s handled normally.&lt;/p&gt;
&lt;h2 id=&quot;6-the-swoole-only-features-fail-silently&quot;&gt;6. The Swoole-only features fail silently&lt;/h2&gt;
&lt;p&gt;The docs mark concurrent tasks, ticks, the Octane cache and tables as Swoole features. What they do on FrankenPHP:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Feature&lt;/th&gt;&lt;th&gt;On FrankenPHP&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Octane::concurrently()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Runs the tasks &lt;strong&gt;one after another&lt;/strong&gt;: two 300 ms tasks took 600 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Cache::store(&amp;#39;octane&amp;#39;)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Works, but it’s a &lt;strong&gt;separate cache per worker&lt;/strong&gt;: a value written on one of four workers was missing on the other three&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Octane::tick()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Registers without error and &lt;strong&gt;never fires&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Octane::table()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Throws “Tables may only be accessed when using the Swoole server.”&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Only the last one tells you. If you’re moving from Swoole to FrankenPHP, grep for the first three: the code will keep running, just not doing what it did.&lt;/p&gt;
&lt;h2 id=&quot;7-packages-mostly-fine-in-2026&quot;&gt;7. Packages: mostly fine in 2026&lt;/h2&gt;
&lt;p&gt;Octane ships its own reset listeners for Livewire, Inertia, Scout and Socialite. Current versions of the usual suspects handle it themselves: Filament resets its component managers on each request, Telescope listens to Octane’s request events, Debugbar 4 “works out of the box with Octane” (if you’re coming from 3.x, remove its entry from &lt;code&gt;flush&lt;/code&gt; in &lt;code&gt;config/octane.php&lt;/code&gt;), and Inertia 3 fixed its dev tools under Octane. The one to configure is &lt;code&gt;spatie/laravel-permission&lt;/code&gt;: its Octane reset listener is off by default, and its docs say to turn it on if cached permissions look stale or cross between requests.&lt;/p&gt;
&lt;h2 id=&quot;is-it-worth-it-the-numbers&quot;&gt;Is it worth it? The numbers&lt;/h2&gt;
&lt;p&gt;A laptop micro-benchmark, not a production claim: a route that returns a small JSON body, &lt;code&gt;APP_ENV=production&lt;/code&gt;, the server pinned to 4 CPUs (8 workers) and &lt;code&gt;wrk&lt;/code&gt; on separate CPUs, 20 seconds per run.&lt;/p&gt;















































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;Connections&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;Requests/s&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;Median latency&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Octane (FrankenPHP worker)&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;16&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;~6,000&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;2.5 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;FrankenPHP classic, with &lt;code&gt;optimize&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;16&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;~2,900&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;5 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;FrankenPHP classic, &lt;strong&gt;without&lt;/strong&gt; config/route cache&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;16&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;1,345&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;11 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Octane&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;64&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;~5,150&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;12 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;FrankenPHP classic, with &lt;code&gt;optimize&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;64&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;~2,490&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;24 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;php artisan serve&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;64&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;~315&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;204 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Octane doubled throughput against a properly cached classic setup, and gave 4.5 times more against one without &lt;code&gt;php artisan optimize&lt;/code&gt;. The honest comparison is the first: if your production doesn’t cache config and routes, fix that before reaching for Octane. And on a real app the gain depends on how heavy your boot is compared to your I/O; a route that waits 200 ms on the database gains little from saving 3 ms of boot.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Docker Compose Validator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Validate your compose.yaml against the official Compose schema and catch key typos, wrong types and YAML errors, with the exact line. All in your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/docker-compose-validator/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;validador-docker-compose&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;the-checklist-before-you-switch&quot;&gt;The checklist before you switch&lt;/h2&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Services that hold state or receive request/config in the constructor&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;singleton\(|-&amp;gt;instance\(&amp;quot;&lt;/span&gt; app/Providers/
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;__construct\([^)]*(Request|Repository|Container)&amp;quot;&lt;/span&gt; app/

&lt;span class=&quot;token comment&quot;&gt;# Static properties that accumulate&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;static (array|&lt;span class=&quot;token entity&quot; title=&quot;\\&quot;&gt;\\&lt;/span&gt;\$)&amp;quot;&lt;/span&gt; app/ &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;function&amp;quot;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Resolution through the base app from boot()&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token entity&quot; title=&quot;\\&quot;&gt;\\&lt;/span&gt;\&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;-&amp;gt;app-&amp;gt;(make|get)\(&amp;quot;&lt;/span&gt; app/Providers/

&lt;span class=&quot;token comment&quot;&gt;# Things FrankenPHP treats differently&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;putenv\(|&lt;span class=&quot;token entity&quot; title=&quot;\\&quot;&gt;\\&lt;/span&gt;\&lt;span class=&quot;token variable&quot;&gt;$_ENV&lt;/span&gt;\[|&lt;span class=&quot;token entity&quot; title=&quot;\b&quot;&gt;\b&lt;/span&gt;exit\(|&lt;span class=&quot;token entity&quot; title=&quot;\b&quot;&gt;\b&lt;/span&gt;die\(&amp;quot;&lt;/span&gt; app/ routes/

&lt;span class=&quot;token comment&quot;&gt;# Swoole-only APIs that degrade silently&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;Octane::(concurrently|tick|table)|store\(&amp;#39;octane&amp;#39;\)&amp;quot;&lt;/span&gt; app/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, in order: install &lt;code&gt;pcntl&lt;/code&gt; in the image, use a Debian-based image, run your test suite, load-test the Octane server with at least four workers (single-worker tests hide the leaks that only happen on one worker in four), and keep &lt;code&gt;--max-requests&lt;/code&gt; at its default. If you’re also moving to Laravel 13, &lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-laravel-13/&quot;&gt;what breaks upgrading to Laravel 13&lt;/a&gt; covers the framework side.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does a singleton that receives the request leak between requests in Laravel Octane? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not if it is resolved during a request: Octane handles each request on a clone of the application, and singletons resolved on the clone are discarded at the end. Tested on Laravel 13.32 with Octane 2.19.1 and FrankenPHP 1.12.7. It leaks when it is resolved during boot (it captures the console request Laravel binds while booting) or through the base application, for example from a listener registered in boot() that calls $this-&amp;gt;app-&amp;gt;make(); in that case it keeps the first request for the life of the worker. Use scoped(), add the class to flush in config/octane.php, or inject a resolver closure. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What does --max-requests=0 do with Octane and FrankenPHP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It stops the server from starting. With php artisan octane:frankenphp --max-requests=0, the worker script never reaches frankenphp_handle_request() and FrankenPHP exits with &amp;quot;too many consecutive failures&amp;quot;. With octane:start the server starts but silently uses the default of 500. On Swoole and RoadRunner, 0 means unlimited. Use a large number if you want to avoid restarts, but the default 500 is what clears memory leaks. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Which Octane features do not work with FrankenPHP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Concurrent tasks, ticks, the Octane cache and tables are Swoole features. On FrankenPHP, Octane::concurrently() runs tasks sequentially, the octane cache store is a separate array per worker, Octane::tick() registers but never fires, and only Octane::table() throws an exception. The first three fail silently. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does Laravel Octane fail with Undefined constant SIGINT on FrankenPHP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because the official dunglas/frankenphp Docker image does not include the pcntl extension, and Octane uses its signal constants. Install it with install-php-extensions pcntl, as the Dockerfile in the Octane documentation does. The image also lacks zip and unzip, so install Composer dependencies in a separate stage. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is $_ENV reset between requests in FrankenPHP worker mode? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. FrankenPHP resets $_GET, $_POST, $_COOKIE, $_FILES, $_SERVER and $_REQUEST between requests, but its documentation says $_ENV is currently not reset. In testing, a value set with putenv() in one request was visible to the next request on the same thread. Do not store request-specific or sensitive data in the environment. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much faster is Laravel Octane with FrankenPHP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; In a laptop micro-benchmark with a minimal JSON route, Octane served about 6,000 requests per second against about 2,900 for FrankenPHP classic mode with config and route caches, roughly double, and 4.5 times more than classic mode without caches. The real gain depends on how much of each request is framework boot versus waiting on the database or other I/O. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Laravel</category><category>Octane</category><category>FrankenPHP</category><category>PHP</category><category>Performance</category><category>Migration</category><author>Marco Orta</author></item><item><title>OpenAI Shuts Down GPT-4, o1 and o3-mini on October 23: What Breaks in Your Code (It&apos;s Not Just the Model Name)</title><link>https://ortamarco.me/en/blog/what-breaks-openai-gpt-4-shutdown-october-23/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-openai-gpt-4-shutdown-october-23/</guid><description>Swapping gpt-4 for gpt-5.6-sol is the easy part. Library defaults, tools on Chat Completions, max_tokens and fine-tunes break after it. With greps and prices.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;On October 23, 2026 OpenAI shuts down &lt;code&gt;gpt-3.5-turbo&lt;/code&gt;, &lt;code&gt;gpt-4&lt;/code&gt;, &lt;code&gt;gpt-4-turbo&lt;/code&gt;, &lt;code&gt;o1&lt;/code&gt;, &lt;code&gt;o1-pro&lt;/code&gt;, &lt;code&gt;o3-mini&lt;/code&gt;, &lt;code&gt;o4-mini&lt;/code&gt;, &lt;code&gt;gpt-4.1-nano&lt;/code&gt;, one &lt;code&gt;gpt-4o&lt;/code&gt; snapshot and &lt;code&gt;gpt-image-1&lt;/code&gt;. The deprecations page makes the fix look like a string swap: put &lt;code&gt;gpt-5.6-sol&lt;/code&gt;, &lt;code&gt;-terra&lt;/code&gt; or &lt;code&gt;-luna&lt;/code&gt; where the old name was. That swap is the easy part, and for a lot of code it isn’t even where the problem is.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Four things break &lt;em&gt;after&lt;/em&gt; you change the name, and one breaks in code where the name never appears: LangChain and LlamaIndex still default to &lt;code&gt;gpt-3.5-turbo&lt;/code&gt;, so a project that never wrote a model name is using one that dies in 32 days. Below is everything, checked against OpenAI’s docs as they were served on September 21 and against the libraries’ source code. Where a claim comes from user reports rather than from OpenAI, I say so.&lt;/p&gt;
&lt;p&gt;If what you need is the full calendar across providers (Anthropic, Gemini, Azure) and how to tell whether a business chatbot is affected, that’s in &lt;a href=&quot;https://ortamarco.me/en/blog/ai-model-retirements-2026/&quot;&gt;the AI model retirements guide&lt;/a&gt;. This post is the code side.&lt;/p&gt;
&lt;h2 id=&quot;what-shuts-down-and-what-openai-says-to-use-instead&quot;&gt;What shuts down, and what OpenAI says to use instead&lt;/h2&gt;
&lt;p&gt;From the &lt;a href=&quot;https://developers.openai.com/api/docs/deprecations&quot;&gt;deprecations page&lt;/a&gt;, section “2026-04-22: Legacy GPT model snapshots”. OpenAI announced it by email on April 22; the substitute column was edited later, since it names models released in July. Prices are standard tier, per million tokens, input / output, from the &lt;a href=&quot;https://developers.openai.com/api/docs/pricing&quot;&gt;pricing page&lt;/a&gt;:&lt;/p&gt;


















































































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Shuts down Oct 23&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;th&gt;Substitute&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-3.5-turbo&lt;/code&gt; (&lt;code&gt;-0125&lt;/code&gt;, &lt;code&gt;-completions&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;$0.50 / $1.50&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-terra&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$2 / $12&lt;/td&gt;&lt;td&gt;&lt;strong&gt;4× / 8×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-4&lt;/code&gt; (&lt;code&gt;-0613&lt;/code&gt;, &lt;code&gt;-completions&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;$30 / $60&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$4 / $20&lt;/td&gt;&lt;td&gt;cheaper&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-4-turbo&lt;/code&gt; (&lt;code&gt;-2024-04-09&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;$10 / $30&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$4 / $20&lt;/td&gt;&lt;td&gt;cheaper&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-4o-2024-05-13&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$5 / $15&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$4 / $20&lt;/td&gt;&lt;td&gt;≈ / 1.3×&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-4.1-nano&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$0.10 / $0.40&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-luna&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$0.20 / $1.20&lt;/td&gt;&lt;td&gt;2× / 3×&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;o1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$15 / $60&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$4 / $20&lt;/td&gt;&lt;td&gt;cheaper&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;o1-pro&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$150 / $600&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;, pro mode&lt;/td&gt;&lt;td&gt;$4 / $20 + more tokens&lt;/td&gt;&lt;td&gt;cheaper&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;o3-mini&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$1.10 / $4.40&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-sol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$4 / $20&lt;/td&gt;&lt;td&gt;3.6× / 4.5×&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;o4-mini&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$1.10 / $4.40&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.6-terra&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$2 / $12&lt;/td&gt;&lt;td&gt;1.8× / 2.7×&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-image-1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$10 / $40 (image tokens)&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-image-2&lt;/code&gt;&lt;/td&gt;&lt;td&gt;$8 / $30&lt;/td&gt;&lt;td&gt;cheaper&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three details the table hides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;gpt-4o&lt;/code&gt; alias is not on the list.&lt;/strong&gt; It points to &lt;code&gt;gpt-4o-2024-08-06&lt;/code&gt; according to &lt;a href=&quot;https://developers.openai.com/api/docs/models/gpt-4o&quot;&gt;its model page&lt;/a&gt;, and only the May 2024 snapshot dies. &lt;code&gt;gpt-4o-mini&lt;/code&gt; and &lt;code&gt;gpt-4.1&lt;/code&gt; (without &lt;code&gt;-nano&lt;/code&gt;) aren’t on it either.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sol’s $4 / $20 is a promotion.&lt;/strong&gt; OpenAI says it is “available at least through November 21, 2026” and describes it as a 20% cut on input and 33% on output, which puts the regular price at about $5 / $30. Budget with that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fine-tunes go the same day&lt;/strong&gt;: &lt;code&gt;ft-gpt-3.5-turbo&lt;/code&gt;, &lt;code&gt;ft-gpt-4&lt;/code&gt;, &lt;code&gt;ft-gpt-4.1-nano-2025-04-14&lt;/code&gt;, &lt;code&gt;ft-babbage-002&lt;/code&gt; and &lt;code&gt;ft-davinci-002&lt;/code&gt;. More on that below, because they have no real successor.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-the-failure-looks-like&quot;&gt;What the failure looks like&lt;/h2&gt;
&lt;p&gt;OpenAI’s only official statement is that “the model or endpoint will no longer be accessible.” It doesn’t document the error payload. What users reported after the July and August shutdowns this year (&lt;a href=&quot;https://github.com/meridianlabs-ai/actions/issues/71&quot;&gt;here&lt;/a&gt; and &lt;a href=&quot;https://github.com/ShenSeanChen/waku-agent/issues/132&quot;&gt;here&lt;/a&gt;) is consistent:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;404 invalid_request_error / model_not_found
The model `gpt-5.1-codex` has been deprecated, learn more here:
https://platform.openai.com/docs/deprecations
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Python SDK raises it as &lt;code&gt;openai.NotFoundError&lt;/code&gt;. Two practical consequences:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It isn’t retryable.&lt;/strong&gt; If your wrapper retries on any exception with backoff, on October 23 it will spend its retry budget on a request that can never succeed, and your users will wait for it. Retry on 429 and 5xx, not on 404.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A health check that lists models will lie to you.&lt;/strong&gt; According to those same reports, retired IDs keep showing up in &lt;code&gt;GET /v1/models&lt;/code&gt; after they stop working. A startup check that confirms “my model is in the list” passes on a dead model. The only reliable check is a real one-token request.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Responses API words it differently (“Model not found …”), so if you match on the message text, match on &lt;code&gt;code: model_not_found&lt;/code&gt; instead.&lt;/p&gt;
&lt;h2 id=&quot;1-the-model-you-never-wrote-library-defaults&quot;&gt;1. The model you never wrote: library defaults&lt;/h2&gt;
&lt;p&gt;This is the one that catches teams who think they are safe because &lt;code&gt;grep gpt-3.5&lt;/code&gt; finds nothing. I checked the source of the libraries that most tutorials from 2023 and 2024 use:&lt;/p&gt;






























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Library&lt;/th&gt;&lt;th&gt;Default when you don’t pass a model&lt;/th&gt;&lt;th&gt;Source&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;LangChain Python, &lt;code&gt;ChatOpenAI&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;quot;gpt-3.5-turbo&amp;quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://github.com/langchain-ai/langchain/blob/41d357287c0470f70e45e232c711013258c0ac1a/libs/partners/openai/langchain_openai/chat_models/base.py#L733&quot;&gt;&lt;code&gt;base.py#L733&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;LangChain.js, &lt;code&gt;ChatOpenAI&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;quot;gpt-3.5-turbo&amp;quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://github.com/langchain-ai/langchainjs/blob/7d6e1b098723690bd1b98bc36ed18c75fa5a85ed/libs/providers/langchain-openai/src/chat_models/base.ts#L286&quot;&gt;&lt;code&gt;base.ts#L286&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;LlamaIndex, &lt;code&gt;OpenAI&lt;/code&gt; LLM&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;quot;gpt-3.5-turbo&amp;quot;&lt;/code&gt;, and it’s what core falls back to when no LLM is configured&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://github.com/run-llama/llama_index/blob/main/llama-index-integrations/llms/llama-index-llms-openai/llama_index/llms/openai/base.py&quot;&gt;&lt;code&gt;DEFAULT_OPENAI_MODEL&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;llama-index-llms-openai&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Vercel AI SDK&lt;/td&gt;&lt;td&gt;no default model, but &lt;code&gt;openai.completion()&lt;/code&gt; “currently only” supports &lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://ai-sdk.dev/providers/ai-sdk-providers/openai&quot;&gt;OpenAI provider docs&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;So this, which appears in countless READMEs, is a &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; call:&lt;/p&gt;
&lt;pre class=&quot;language-python&quot; data-language=&quot;python&quot;&gt;&lt;code class=&quot;language-python&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; langchain_openai &lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; ChatOpenAI

llm &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; ChatOpenAI&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;temperature&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# no model= → &amp;quot;gpt-3.5-turbo&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And with LlamaIndex it can be less visible still: if you never set &lt;code&gt;Settings.llm&lt;/code&gt;, a query engine builds an &lt;code&gt;OpenAI()&lt;/code&gt; on its own, with the same default. LlamaIndex’s &lt;code&gt;OpenAIResponses&lt;/code&gt; class defaults to &lt;code&gt;gpt-4o-mini&lt;/code&gt;, which survives.&lt;/p&gt;
&lt;p&gt;The Vercel case has an earlier date: &lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt; shuts down &lt;strong&gt;September 28&lt;/strong&gt;, together with &lt;code&gt;babbage-002&lt;/code&gt; and &lt;code&gt;davinci-002&lt;/code&gt;, so anything using &lt;code&gt;openai.completion()&lt;/code&gt; or the legacy &lt;code&gt;/v1/completions&lt;/code&gt; endpoint has one week, not a month. Those three are the only models the endpoint’s reference still names.&lt;/p&gt;
&lt;p&gt;The fix is always the same: pass the model explicitly, and read it from configuration so the next shutdown is an environment variable, not a deploy.&lt;/p&gt;
&lt;p&gt;For contrast, &lt;code&gt;laravel/ai&lt;/code&gt; 1.x already defaults to &lt;code&gt;gpt-5.6-terra&lt;/code&gt;, &lt;code&gt;-luna&lt;/code&gt;, &lt;code&gt;-sol&lt;/code&gt; and &lt;code&gt;gpt-image-2&lt;/code&gt;. Newer libraries got this right; the problem is code built on the ones from 2023.&lt;/p&gt;
&lt;h2 id=&quot;2-tools-on-chat-completions-now-need-reasoning-turned-off&quot;&gt;2. Tools on Chat Completions now need reasoning turned off&lt;/h2&gt;
&lt;p&gt;Every substitute in the table is a reasoning model, and that changes the contract of Chat Completions. From the &lt;a href=&quot;https://developers.openai.com/api/docs/guides/upgrading-to-gpt-5p6-sol&quot;&gt;GPT-5.6 upgrade guide&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For GPT-5.6, function tools in Chat Completions are compatible only with effective reasoning &lt;code&gt;none&lt;/code&gt;. Reasoning with tools should use the Responses API.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The default reasoning effort is &lt;code&gt;medium&lt;/code&gt;. So a function-calling chatbot that worked on &lt;code&gt;gpt-4-turbo&lt;/code&gt;, with the model string changed and nothing else, fails on its first request. This is the error one team hit on &lt;code&gt;gpt-5.6-luna&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;400 - Function tools with reasoning_effort are not supported for gpt-5.6-luna
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You have two ways out. The minimal one keeps Chat Completions and turns reasoning off, which is the closest thing to how &lt;code&gt;gpt-4-turbo&lt;/code&gt; behaved:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; res &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; openai&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;chat&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;completions&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;gpt-5.6-sol&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;reasoning_effort&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;none&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;// required if you pass tools&lt;/span&gt;
  messages&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  tools&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The one OpenAI recommends is moving that call to the Responses API, where reasoning and tools do work together. It’s more than a rename: the system prompt goes to &lt;code&gt;instructions&lt;/code&gt;, structured outputs move from &lt;code&gt;response_format&lt;/code&gt; to &lt;code&gt;text.format&lt;/code&gt;, and function definitions lose a nesting level:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Chat Completions&lt;/span&gt;
&lt;span class=&quot;token literal-property property&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;function&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;get_order&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; parameters &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// Responses&lt;/span&gt;
&lt;span class=&quot;token literal-property property&quot;&gt;tools&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;function&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;get_order&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; parameters &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The strictness default also flips. According to the &lt;a href=&quot;https://developers.openai.com/api/docs/guides/migrate-to-responses&quot;&gt;migration guide&lt;/a&gt;, functions are non-strict by default in Chat Completions, while in Responses omitting &lt;code&gt;strict&lt;/code&gt; makes it attempt strict mode. If your JSON schemas were written loosely, set &lt;code&gt;strict&lt;/code&gt; explicitly instead of inheriting the new default.&lt;/p&gt;
&lt;p&gt;Chat Completions itself is not deprecated. OpenAI’s wording is that it “remains supported” and Responses “is recommended for all new projects”. You don’t have to migrate the endpoint on October 23, only decide about reasoning.&lt;/p&gt;
&lt;h2 id=&quot;3-max_tokens-renamed-and-now-it-has-to-pay-for-thinking&quot;&gt;3. &lt;code&gt;max_tokens&lt;/code&gt;: renamed, and now it has to pay for thinking&lt;/h2&gt;
&lt;p&gt;The Chat Completions reference marks &lt;code&gt;max_tokens&lt;/code&gt; as “deprecated in favor of &lt;code&gt;max_completion_tokens&lt;/code&gt;” and “not compatible with o-series models”. The docs don’t state whether GPT-5.6 rejects &lt;code&gt;max_tokens&lt;/code&gt; outright; the team in the issue above reports that all their calls needed the new name. Rename it either way.&lt;/p&gt;
&lt;p&gt;The part that actually changes behavior is what the new parameter counts. &lt;code&gt;max_completion_tokens&lt;/code&gt; covers “visible output tokens and reasoning tokens”, and reasoning tokens “are billed as output tokens” (&lt;a href=&quot;https://developers.openai.com/api/docs/guides/reasoning&quot;&gt;reasoning guide&lt;/a&gt;). The classic &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; classifier with &lt;code&gt;max_tokens: 50&lt;/code&gt; to force a short label, moved to &lt;code&gt;gpt-5.6-terra&lt;/code&gt; at its default &lt;code&gt;medium&lt;/code&gt; effort, can spend the whole budget thinking and come back truncated or empty, and you pay for the reasoning anyway. OpenAI recommends reserving at least 25,000 tokens for reasoning and output.&lt;/p&gt;
&lt;p&gt;For short, deterministic tasks the fix is not a bigger budget but no reasoning:&lt;/p&gt;
&lt;pre class=&quot;language-python&quot; data-language=&quot;python&quot;&gt;&lt;code class=&quot;language-python&quot;&gt;client&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;chat&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;completions&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;create&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
    model&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;gpt-5.6-terra&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    reasoning_effort&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;none&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;        &lt;span class=&quot;token comment&quot;&gt;# a label doesn&amp;#39;t need to think&lt;/span&gt;
    max_completion_tokens&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;       &lt;span class=&quot;token comment&quot;&gt;# was max_tokens&lt;/span&gt;
    messages&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;messages&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;temperature&lt;/code&gt; and &lt;code&gt;top_p&lt;/code&gt; are a similar trap. For GPT-5.2 and 5.4 the docs say they’re only supported with reasoning effort &lt;code&gt;none&lt;/code&gt;; for 5.6 I couldn’t find the rule written down. If you pass them, test with and without reasoning before the 23rd instead of finding out in production.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #a855f7&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#a855f71a;color:#a855f7&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; LLM Token Counter &amp;amp; AI Cost Calculator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Count the tokens in your text or prompt and estimate the cost across GPT-5, Claude, Gemini, Kimi K3, Grok and DeepSeek. Exact OpenAI counting, per-call and monthly costs, with prompt caching. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/llm-token-counter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;contador-tokens&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;4-fine-tuned-models-have-no-successor&quot;&gt;4. Fine-tuned models have no successor&lt;/h2&gt;
&lt;p&gt;The substitute column sends &lt;code&gt;ft-gpt-3.5-turbo&lt;/code&gt; to &lt;code&gt;gpt-5.6-terra&lt;/code&gt; and &lt;code&gt;ft-gpt-4&lt;/code&gt; to &lt;code&gt;gpt-5.6-sol&lt;/code&gt;. Those are base models. The GPT-5.6 model pages list fine-tuning as not supported, and from January 6, 2027 existing customers can no longer create fine-tuning jobs at all.&lt;/p&gt;
&lt;p&gt;In practice, whatever your fine-tune learned (tone, output format, domain labels) has to move into the prompt: few-shot examples, a strict schema through structured outputs, or retrieval. Plan that work now. It’s the only item on this list that is a project rather than a patch, and the training data you’ll need to write examples from is what you used to build the fine-tune.&lt;/p&gt;
&lt;h2 id=&quot;5-o1-pro-and-gpt-image-1-different-parameters-not-new-names&quot;&gt;5. &lt;code&gt;o1-pro&lt;/code&gt; and &lt;code&gt;gpt-image-1&lt;/code&gt;: different parameters, not new names&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;o1-pro&lt;/code&gt; → &lt;code&gt;gpt-5.6-sol&lt;/code&gt; in pro mode.&lt;/strong&gt; The guide is explicit: “do not search for or invent a separate &lt;code&gt;gpt-5.6-pro&lt;/code&gt; slug”. Pro mode is a reasoning setting on Sol and only works through Responses, not Chat Completions. &lt;code&gt;o1-pro&lt;/code&gt; was already Responses-only, so the endpoint doesn’t change; the model name and the reasoning setting do. OpenAI warns that pro mode “performs more model work”, so the per-token price is lower than &lt;code&gt;o1-pro&lt;/code&gt; but the tokens per request go up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;gpt-image-1&lt;/code&gt; → &lt;code&gt;gpt-image-2&lt;/code&gt;.&lt;/strong&gt; The image guide says to omit &lt;code&gt;input_fidelity&lt;/code&gt; for &lt;code&gt;gpt-image-2&lt;/code&gt;, so if your edit calls pass it, remove it. And if you depend on transparent backgrounds, &lt;code&gt;gpt-image-2&lt;/code&gt; has only offered them in preview since August 20.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;6-the-bill&quot;&gt;6. The bill&lt;/h2&gt;
&lt;p&gt;For the GPT-4 family and &lt;code&gt;o1&lt;/code&gt;, the switch saves money, even more so if you were still paying $30 / $60 for &lt;code&gt;gpt-4-0613&lt;/code&gt;. For everything that was chosen &lt;em&gt;because it was cheap&lt;/em&gt;, it costs more:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A classifier or router on &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; processing 1M input and 200K output tokens a day costs $0.80. On &lt;code&gt;gpt-5.6-terra&lt;/code&gt; with reasoning off, $4.40. &lt;strong&gt;That’s 5.5×&lt;/strong&gt; before counting a single reasoning token.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;o3-mini&lt;/code&gt; → &lt;code&gt;gpt-5.6-sol&lt;/code&gt; is 3.6× on input and 4.5× on output, and more once the promotion ends.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gpt-4.1-nano&lt;/code&gt; → &lt;code&gt;gpt-5.6-luna&lt;/code&gt; doubles input and triples output.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If one of those lines matters to you, test a cheaper tier than the table suggests before accepting the substitute: the column says what OpenAI considers equivalent, not what your task needs. I break down per-task cost across providers in &lt;a href=&quot;https://ortamarco.me/en/blog/how-much-openai-claude-gemini-api-costs-2026/&quot;&gt;how much the OpenAI, Claude and Gemini APIs cost&lt;/a&gt;. And no, the table doesn’t point to &lt;code&gt;gpt-6-astra&lt;/code&gt;, even though the reasoning guide now says “start with” it: at $10 / $50 and without &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt; or &lt;code&gt;top_logprobs&lt;/code&gt; it’s a different decision, which I cover in &lt;a href=&quot;https://ortamarco.me/en/blog/gpt-6-astra-api-developers/&quot;&gt;GPT-6 Astra for developers&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-audit-three-greps-and-a-real-request&quot;&gt;The audit: three greps and a real request&lt;/h2&gt;
&lt;p&gt;From the repository root. The first looks for the dead IDs as exact quoted strings, so it skips &lt;code&gt;gpt-4o&lt;/code&gt;, &lt;code&gt;gpt-4o-mini&lt;/code&gt; and &lt;code&gt;gpt-4.1&lt;/code&gt;, which survive:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# 1. Model IDs that die on Oct 23 (and Sep 28), as quoted strings&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;[&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;\&lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;gpt-3&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;.5-turbo&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4-0613&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4-turbo&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4-1106-preview&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4o-2024-05-13&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-4&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;.1-nano&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;o1&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;o1-pro&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;o1-2024-12-17&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;o3-mini&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;o4-mini&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a-z0-9-&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;gpt-image-1&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;babbage-002&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;davinci-002&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&amp;#39;&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;&amp;quot;&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;`&lt;/span&gt;&lt;/span&gt;]&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  --exclude-dir&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;node_modules,vendor,.git&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;.&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# 2. The same in environment files, where names go unquoted&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;=(gpt-3\.5|gpt-4(-|$)|o1|o3-mini|o4-mini|gpt-image-1$)&amp;quot;&lt;/span&gt; .env* &lt;span class=&quot;token operator&quot;&gt;&lt;span class=&quot;token file-descriptor important&quot;&gt;2&lt;/span&gt;&amp;gt;&lt;/span&gt;/dev/null

&lt;span class=&quot;token comment&quot;&gt;# 3. Constructors that inherit a library default&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;ChatOpenAI\(|llama_index\.llms\.openai|openai\.completion\(&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;*.py &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;*.ts &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;*.js --exclude-dir&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;node_modules &lt;span class=&quot;token builtin class-name&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;model&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The third one gives false positives by design (a multi-line constructor with &lt;code&gt;model=&lt;/code&gt; on another line will appear), but the list is short and it’s worth reading it by eye.&lt;/p&gt;
&lt;p&gt;Then the test that counts: for every model you end up using, one real request with your production parameters (tools, &lt;code&gt;max_completion_tokens&lt;/code&gt;, &lt;code&gt;temperature&lt;/code&gt; if you pass it) against the new name. Not &lt;code&gt;GET /v1/models&lt;/code&gt;, for the reason above.&lt;/p&gt;
&lt;p&gt;Model names in configuration also live outside the repo: Make, Zapier or n8n workflows, a vendor’s chatbot panel, a Google Sheet with a script. The &lt;a href=&quot;https://ortamarco.me/en/blog/ai-model-retirements-2026/&quot;&gt;retirements guide&lt;/a&gt; has the checklist for those.&lt;/p&gt;
&lt;h2 id=&quot;the-rest-of-openais-calendar-this-year&quot;&gt;The rest of OpenAI’s calendar this year&lt;/h2&gt;
&lt;p&gt;All from the same deprecations page:&lt;/p&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Date&lt;/th&gt;&lt;th&gt;What shuts down&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Sep 24&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Videos API, &lt;code&gt;sora-2&lt;/code&gt; and &lt;code&gt;sora-2-pro&lt;/code&gt; (no substitute)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Sep 28&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt;, &lt;code&gt;babbage-002&lt;/code&gt;, &lt;code&gt;davinci-002&lt;/code&gt;, &lt;code&gt;gpt-3.5-turbo-1106&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Oct 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5.4-cyber&lt;/code&gt; → &lt;code&gt;gpt-5.6-cyber&lt;/code&gt; (20 days’ notice)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Oct 23&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Everything in this post&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Oct 31&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Evals go read-only&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Nov 30&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Evals dashboard and API, the &lt;code&gt;v1/prompts&lt;/code&gt; API and reusable prompts, Agent Builder&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dec 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-image-1-mini&lt;/code&gt;, &lt;code&gt;gpt-image-1.5&lt;/code&gt;, &lt;code&gt;chatgpt-image-latest&lt;/code&gt; → &lt;code&gt;gpt-image-2&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dec 11&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-5&lt;/code&gt;, &lt;code&gt;gpt-5-mini&lt;/code&gt;, &lt;code&gt;gpt-5-nano&lt;/code&gt;, &lt;code&gt;gpt-5-pro&lt;/code&gt;, &lt;code&gt;o3&lt;/code&gt; and &lt;code&gt;o3-pro&lt;/code&gt; snapshots&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The last row deserves a note: the &lt;code&gt;gpt-5&lt;/code&gt; and &lt;code&gt;o3&lt;/code&gt; model pages each list only the snapshot being retired, so the aliases very likely die with them. The &lt;code&gt;openai-php/laravel&lt;/code&gt; README still uses &lt;code&gt;&amp;#39;model&amp;#39; =&amp;gt; &amp;#39;gpt-5&amp;#39;&lt;/code&gt; as its example. The Assistants API was already shut down on August 26.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Which OpenAI models shut down on October 23, 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; gpt-3.5-turbo (including gpt-3.5-turbo-0125 and the -completions variant), gpt-4 and gpt-4-0613, gpt-4-turbo and gpt-4-turbo-2024-04-09, gpt-4-1106-preview, gpt-4o-2024-05-13, gpt-4.1-nano, o1, o1-pro, o3-mini, o4-mini and gpt-image-1, plus the fine-tuned models ft-gpt-3.5-turbo, ft-gpt-4, ft-gpt-4.1-nano, ft-babbage-002 and ft-davinci-002. OpenAI announced it on April 22, 2026. The gpt-4o alias, gpt-4o-mini and gpt-4.1 are not on the list. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What should I replace gpt-4 and gpt-3.5-turbo with? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; OpenAI lists gpt-5.6-sol as the substitute for gpt-4, gpt-4-turbo, gpt-4o-2024-05-13, o1, o1-pro (in pro mode) and o3-mini; gpt-5.6-terra for gpt-3.5-turbo and o4-mini; gpt-5.6-luna for gpt-4.1-nano; and gpt-image-2 for gpt-image-1. They are reasoning models, so changing the name is not enough: tools in Chat Completions need reasoning_effort none, max_tokens becomes max_completion_tokens and counts reasoning tokens. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What error does the OpenAI API return for a retired model? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; OpenAI does not document the payload. Users who hit the July and August 2026 shutdowns report HTTP 404 with type invalid_request_error and code model_not_found, raised by the Python SDK as openai.NotFoundError. It is not retryable. Retired IDs may keep appearing in GET /v1/models, so the only reliable check is a real request. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does LangChain still use gpt-3.5-turbo by default? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. As of September 2026, ChatOpenAI in both LangChain Python and LangChain.js defaults to gpt-3.5-turbo when no model is passed, and LlamaIndex&amp;#39;s OpenAI LLM does too (it is also what LlamaIndex falls back to when Settings.llm is not set). Code that never names a model will fail on October 23. Pass the model explicitly. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does gpt-5.6 fail with &amp;quot;Function tools with reasoning_effort are not supported&amp;quot;? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because in Chat Completions, GPT-5.6 only accepts function tools when the effective reasoning effort is none, and the default is medium. Either add reasoning_effort: none to the request, which behaves closest to gpt-4-turbo, or move the call to the Responses API, where tools and reasoning work together. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is the gpt-5.6 migration more expensive? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It depends on what you had. From gpt-4, gpt-4-turbo or o1 it is cheaper: gpt-5.6-sol lists at $4 input and $20 output per million tokens, a promotional price available at least through November 21, 2026. From gpt-3.5-turbo to gpt-5.6-terra it is 4 times the input and 8 times the output price, and from o3-mini to sol about 3.6 and 4.5 times, before counting reasoning tokens, which are billed as output. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What happens to my fine-tuned gpt-3.5-turbo model? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It shuts down on October 23, 2026 with the base models. The listed substitute is the base gpt-5.6-terra, and GPT-5.6 does not support fine-tuning; from January 6, 2027 existing customers cannot create fine-tuning jobs. What the fine-tune learned has to move into the prompt: few-shot examples, structured outputs or retrieval. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>OpenAI</category><category>AI</category><category>API</category><category>Python</category><category>JavaScript</category><category>Migration</category><author>Marco Orta</author></item><item><title>Chrome Removes XSLT: What Breaks in Sitemaps, Feeds and CFDI Invoice Tools</title><link>https://ortamarco.me/en/blog/chrome-removes-xslt-what-breaks/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/chrome-removes-xslt-what-breaks/</guid><description>Chrome 158 stops running XSLT on Nov 17. Styled sitemaps survive; in-browser code like Mexican CFDI string builders throws. How to detect it and migrate.</description><pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Chrome 158 reaches Stable on 17 November 2026 and stops running XSLT: &lt;code&gt;XSLTProcessor&lt;/code&gt; goes away, and so does processing of &lt;code&gt;&amp;lt;?xml-stylesheet type=&amp;quot;text/xsl&amp;quot;?&amp;gt;&lt;/code&gt;. For most sites that only changes the decoration. The sitemap or feed a human used to see as a table goes back to raw XML, with no error and no change to the file. Where real code breaks is a very Mexican corner of the web: invoice viewers and validators that build a CFDI’s original string in the browser with the tax authority’s XSLT stylesheet. There, the function throws &lt;code&gt;ReferenceError: XSLTProcessor is not defined&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I tested both in Chrome 152 with the switch that turns XSLT off early. For the invoice part I went further: I generated the original string of a test invoice with five different engines and checked every result against the invoice’s own digital seal. A JavaScript XSLT library produces a string the seal does not verify against, the polyfill Chrome recommends fails if the stylesheet keeps its includes, and the official stylesheet as published does not even run in the browser.&lt;/p&gt;
&lt;h2 id=&quot;the-dates&quot;&gt;The dates&lt;/h2&gt;
&lt;p&gt;Everything comes from the &lt;a href=&quot;https://developer.chrome.com/docs/web-platform/deprecating-xslt&quot;&gt;Chrome for Developers announcement&lt;/a&gt; and the &lt;a href=&quot;https://chromestatus.com/feature/4709671889534976&quot;&gt;Chrome Platform Status entry&lt;/a&gt;, with Stable dates checked against &lt;a href=&quot;https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=158&quot;&gt;Chromium Dash&lt;/a&gt;:&lt;/p&gt;








































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Milestone&lt;/th&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;Date&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Console warnings&lt;/td&gt;&lt;td&gt;Chrome 142&lt;/td&gt;&lt;td&gt;28 Oct 2025&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Official deprecation (console and Lighthouse)&lt;/td&gt;&lt;td&gt;Chrome 143&lt;/td&gt;&lt;td&gt;2 Dec 2025&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Origin Trial to keep using it starts&lt;/td&gt;&lt;td&gt;Chrome 152&lt;/td&gt;&lt;td&gt;25 Aug 2026&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Beta of the removing version&lt;/td&gt;&lt;td&gt;Chrome 158&lt;/td&gt;&lt;td&gt;28 Oct 2026&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;XSLT stops working on Stable&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Chrome 158&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;17 Nov 2026&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Origin Trial and the &lt;code&gt;XSLTEnabled&lt;/code&gt; enterprise policy end&lt;/td&gt;&lt;td&gt;Chrome 176&lt;/td&gt;&lt;td&gt;17 Aug 2027&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;One detail shortens the runway more than it looks: &lt;a href=&quot;https://developer.chrome.com/blog/chrome-two-week-start&quot;&gt;since Chrome 153 (8 September) a new Stable version ships every two weeks&lt;/a&gt;, not every four. Between today and 158 there are nine weeks and five releases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What about Firefox and Safari?&lt;/strong&gt; Same direction, no date. Mozilla took a &lt;a href=&quot;https://github.com/mozilla/standards-positions/issues/1287&quot;&gt;positive position&lt;/a&gt;, and its &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=1990759&quot;&gt;tracking bug&lt;/a&gt; has already closed the console warning (Firefox 147) and an enterprise policy (Firefox 151), but disabling it by default is still open. WebKit said it is &lt;a href=&quot;https://github.com/whatwg/html/issues/11523#issuecomment-3149280766&quot;&gt;“cautiously supportive”&lt;/a&gt; and would probably wait for one engine to remove it fully. The HTML standard &lt;a href=&quot;https://github.com/whatwg/html/pull/12805&quot;&gt;marked XSLT deprecated&lt;/a&gt; on 25 August 2026. In practice, what Chrome 158 does is what most of your users will see in November.&lt;/p&gt;
&lt;h2 id=&quot;what-breaks-what-you-see-how-to-fix-it&quot;&gt;What breaks, what you see, how to fix it&lt;/h2&gt;








































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;What you use&lt;/th&gt;&lt;th&gt;What happens in Chrome 158&lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;XML sitemap with an XSL stylesheet (WordPress core, Yoast, Rank Math, &lt;code&gt;xslURL&lt;/code&gt; in &lt;code&gt;@astrojs/sitemap&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;A human sees Chrome’s XML viewer instead of the table. No error, and the served file does not change&lt;/td&gt;&lt;td&gt;Nothing, if nobody looks at it. If it matters, drop the instruction or serve a separate HTML version&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;RSS/Atom feed with an XSL stylesheet (a &lt;code&gt;.xsl&lt;/code&gt; &lt;code&gt;stylesheet&lt;/code&gt; in &lt;code&gt;@astrojs/rss&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Opened in the browser, it shows raw XML. Feed readers do not notice&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;link rel=&amp;quot;alternate&amp;quot; type=&amp;quot;application/rss+xml&amp;quot;&amp;gt;&lt;/code&gt; in your HTML, or the one-line polyfill&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JavaScript that calls &lt;code&gt;new XSLTProcessor()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ReferenceError: XSLTProcessor is not defined&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Move the transform to the server, or use a JS/WASM XSLT engine&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;CFDI viewer or validator that builds the original string in the browser&lt;/td&gt;&lt;td&gt;The string is never produced, so the seal cannot be verified&lt;/td&gt;&lt;td&gt;Server-side ext-xsl, xslt-polyfill with a flattened stylesheet, or SaxonJS (details below)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;&amp;lt;?xml-stylesheet type=&amp;quot;text/css&amp;quot;?&amp;gt;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Nothing: still supported&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;XSLT on your server (PHP ext-xsl, Saxon, xsltproc)&lt;/td&gt;&lt;td&gt;Nothing: this only affects the browser&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;sitemaps-and-feeds-the-decoration-breaks-not-the-file&quot;&gt;Sitemaps and feeds: the decoration breaks, not the file&lt;/h2&gt;
&lt;p&gt;I opened the &lt;code&gt;yoast.com&lt;/code&gt; sitemap index in Chrome 152 twice. With XSLT on, Chrome applies the stylesheet and renders a page titled “XML Sitemap” with its table. With &lt;code&gt;--disable-features=XSLT&lt;/code&gt;, the same URL lands in Chrome’s XML viewer: the tree of &lt;code&gt;&amp;lt;sitemap&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; with the &lt;code&gt;xml-stylesheet&lt;/code&gt; instruction visible, no styling and no error. According to the announcement, Chrome also shows a banner linking to extensions when XSLT is off; in headless mode I could not see it.&lt;/p&gt;
&lt;p&gt;This will hit a lot of people, because the XSL stylesheet ships by default across most of the ecosystem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;WordPress core&lt;/strong&gt; adds &lt;code&gt;&amp;lt;?xml-stylesheet type=&amp;quot;text/xsl&amp;quot; href=&amp;quot;.../wp-sitemap.xsl&amp;quot; ?&amp;gt;&lt;/code&gt; to &lt;code&gt;wp-sitemap.xml&lt;/code&gt; since 5.5. &lt;a href=&quot;https://core.trac.wordpress.org/ticket/65593&quot;&gt;Ticket #65593&lt;/a&gt; to remove it sits in the 7.2 milestone, with &lt;a href=&quot;https://github.com/WordPress/wordpress-develop/pull/12448&quot;&gt;PR #12448&lt;/a&gt; open and an alternative (&lt;a href=&quot;https://github.com/WordPress/wordpress-develop/pull/12584&quot;&gt;#12584&lt;/a&gt;) that renders an HTML version on the server. As of 13 September neither is merged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Yoast SEO&lt;/strong&gt; still emits it in &lt;code&gt;trunk&lt;/code&gt;, which the changelog labels 28.5 for 15 September, and the &lt;code&gt;yoast.com&lt;/code&gt; sitemap carries it today.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rank Math&lt;/strong&gt; still emits it in &lt;code&gt;trunk&lt;/code&gt;, and the &lt;code&gt;rankmath.com&lt;/code&gt; sitemap carries it today too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Astro&lt;/strong&gt;: &lt;code&gt;@astrojs/sitemap&lt;/code&gt; has an &lt;code&gt;xslURL&lt;/code&gt; option, and &lt;code&gt;@astrojs/rss&lt;/code&gt; sets &lt;code&gt;type=&amp;quot;text/xsl&amp;quot;&lt;/code&gt; when you pass a &lt;code&gt;stylesheet&lt;/code&gt; ending in &lt;code&gt;.xsl&lt;/code&gt;. Neither is on by default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-about-google&quot;&gt;What about Google?&lt;/h3&gt;
&lt;p&gt;I am not going to write “Googlebot is unaffected” as if Google had said so: I found no Google Search statement about XSLT and sitemaps. What is verifiable is that the change lives in the browser. Your server sends exactly the same bytes before and after 17 November, and Chrome’s announcement says it is not removing XML, only XSLT. A crawler that already read your sitemap without running the stylesheet keeps receiving the same file.&lt;/p&gt;
&lt;h3 id=&quot;what-to-do-with-the-sitemap&quot;&gt;What to do with the sitemap&lt;/h3&gt;
&lt;p&gt;If no human ever opens your sitemap, do nothing. If people use it as a page, you have three paths:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Drop the instruction.&lt;/strong&gt; It saves the request for the &lt;code&gt;.xsl&lt;/code&gt; and the console warnings. WordPress core has official filters: per its own source, returning a falsy value means “the raw XML of the sitemap will be displayed”:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// In an mu-plugin or functions.php&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;add_filter&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;wp_sitemaps_stylesheet_url&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;__return_false&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;add_filter&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;wp_sitemaps_stylesheet_index_url&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;__return_false&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// Yoast: the filter says &amp;quot;url&amp;quot;, but it receives the full declaration&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;add_filter&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;wpseo_stylesheet_url&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;__return_empty_string&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Rank Math the filter is per sitemap type (&lt;code&gt;rank_math/sitemap/{type}_stylesheet_url&lt;/code&gt;). In Astro, just remove &lt;code&gt;xslURL&lt;/code&gt; or &lt;code&gt;stylesheet&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Serve HTML separately.&lt;/strong&gt; That is what WordPress PR #12584 proposes: the same transform, done on the server, published at a &lt;code&gt;.html&lt;/code&gt; URL. The &lt;code&gt;.xml&lt;/code&gt; stays for machines.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;CSS instead of XSL.&lt;/strong&gt; &lt;code&gt;&amp;lt;?xml-stylesheet type=&amp;quot;text/css&amp;quot;?&amp;gt;&lt;/code&gt; is still supported, but the WordPress ticket pointed out the limit: CSS can style the XML, but the &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; entries do not become clickable links.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For feeds, Chrome recommends advertising the feed with &lt;code&gt;&amp;lt;link rel=&amp;quot;alternate&amp;quot;&amp;gt;&lt;/code&gt; in your HTML instead of a visible link to the &lt;code&gt;.xml&lt;/code&gt;, or adding a single polyfill line to the feed.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #f43f5e&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#f43f5e1a;color:#f43f5e&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; XML Formatter and Validator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Indent, minify and validate XML in your browser. It preserves comments, CDATA and the declaration, and points at errors with line and column. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/xml-formatter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;formateador-xml&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;cfdi-invoices-where-code-actually-breaks&quot;&gt;CFDI invoices: where code actually breaks&lt;/h2&gt;
&lt;p&gt;Context for anyone who does not invoice in Mexico: a &lt;strong&gt;CFDI&lt;/strong&gt; is the electronic invoice format mandated by Mexico’s tax authority, the SAT, and every one is a signed XML file. The signature (the &lt;code&gt;Sello&lt;/code&gt; attribute) is not computed over the whole XML but over the &lt;strong&gt;original string&lt;/strong&gt; (&lt;em&gt;cadena original&lt;/em&gt;): the invoice’s values in a fixed order, separated by &lt;code&gt;|&lt;/code&gt;. To verify a seal you need that string. To print the tax stamp’s own string on a PDF representation, you need it too.&lt;/p&gt;
&lt;p&gt;The SAT publishes that sequence as an XSLT stylesheet: on its &lt;a href=&quot;http://omawww.sat.gob.mx/tramitesyservicios/Paginas/anexo_20.htm&quot;&gt;Annex 20 page&lt;/a&gt;, the “Secuencia de cadena original” document links to &lt;a href=&quot;http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0/cadenaoriginal_4_0.xslt&quot;&gt;&lt;code&gt;cadenaoriginal_4_0.xslt&lt;/code&gt;&lt;/a&gt;, last modified on 17 April 2026. The tax stamp has its own, &lt;code&gt;cadenaoriginal_TFD_1_1.xslt&lt;/code&gt;. Three details of that file matter here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It declares &lt;code&gt;version=&amp;quot;2.0&amp;quot;&lt;/code&gt;, but browsers only implement XSLT 1.0. It still works with 1.0 processors: the &lt;a href=&quot;https://github.com/eclipxe13/CfdiUtils&quot;&gt;CfdiUtils&lt;/a&gt; docs say so for PHP, which uses the same engine as Chrome (libxslt), and I confirmed it in the browser below.&lt;/li&gt;
&lt;li&gt;It pulls in &lt;strong&gt;33 &lt;code&gt;xsl:include&lt;/code&gt;&lt;/strong&gt; files (utilities and complements), all by absolute URL to &lt;code&gt;http://www.sat.gob.mx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The output is plain text (&lt;code&gt;method=&amp;quot;text&amp;quot;&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is why building the string in the browser was tempting: &lt;code&gt;XSLTProcessor&lt;/code&gt; came built in, and the XML never left the user’s machine. I looked for real code doing it and found two patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An npm library of Mexican validators and catalogues, with a release published at the end of August 2026, whose original-string function uses &lt;code&gt;new XSLTProcessor()&lt;/code&gt; when it detects &lt;code&gt;window&lt;/code&gt; and falls back to a JavaScript engine in Node. In Chrome 158 the detection is still true (&lt;code&gt;DOMParser&lt;/code&gt; is not going anywhere), so it takes the browser branch and throws the &lt;code&gt;ReferenceError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A public JavaScript e-signature repository that loads &lt;code&gt;cadenaoriginal_3_3.xslt&lt;/code&gt; from a file input, stores it in &lt;code&gt;localStorage&lt;/code&gt; and builds the string with &lt;code&gt;XSLTProcessor.transformToDocument&lt;/code&gt; to verify the seal of a CFDI 3.3 invoice.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-sat-stylesheet-does-not-run-as-is-in-the-browser&quot;&gt;The SAT stylesheet does not run as-is in the browser&lt;/h3&gt;
&lt;p&gt;I found this while testing and have not seen it documented anywhere. Hand &lt;code&gt;XSLTProcessor&lt;/code&gt; the official stylesheet and Chrome refuses to load the 33 includes (the URL below is my test page):&lt;/p&gt;
&lt;pre class=&quot;language-text&quot; data-language=&quot;text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;Unsafe attempt to load URL http://www.sat.gob.mx/sitio_internet/cfd/2/cadenaoriginal_2_0/utilerias.xslt
from frame with URL http://localhost:8765/native.html. Domains, protocols and ports must match.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result is &lt;code&gt;null&lt;/code&gt;. So anyone building the string in the browser with &lt;code&gt;XSLTProcessor&lt;/code&gt; today has to serve the stylesheet and its includes from their own origin, or flattened into a single file. That helps detection: the copy lives in your repository, so searching for &lt;code&gt;cadenaoriginal&lt;/code&gt; finds it.&lt;/p&gt;
&lt;h3 id=&quot;why-this-sites-cfdi-viewer-does-not-break&quot;&gt;Why this site’s CFDI viewer does not break&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.siemprecontable.net/herramientas/visor-cfdi&quot;&gt;CFDI 4.0 XML viewer&lt;/a&gt; I wrote for this site, which now lives on Siempre Contable (in Spanish), does not use XSLT, and not by luck. It reads the XML with &lt;code&gt;DOMParser&lt;/code&gt;, walks &lt;code&gt;Comprobante&lt;/code&gt;, &lt;code&gt;Emisor&lt;/code&gt;, &lt;code&gt;Receptor&lt;/code&gt;, &lt;code&gt;Conceptos&lt;/code&gt;, &lt;code&gt;Impuestos&lt;/code&gt; and the &lt;code&gt;TimbreFiscalDigital&lt;/code&gt;, translates the catalogue codes and checks the arithmetic. It does not build the original string or validate the seal, and the tool says so on screen. &lt;code&gt;DOMParser&lt;/code&gt; is one of the APIs Chrome cites as the modern way to read XML, so it is not at risk. I grepped the whole repository: not a single &lt;code&gt;XSLTProcessor&lt;/code&gt; or &lt;code&gt;xml-stylesheet&lt;/code&gt;. The site’s sitemaps and RSS feed carry no stylesheet either. The open-source &lt;a href=&quot;https://ortamarco.me/en/portfolio/mx-fiscal-mcp/&quot;&gt;mx-fiscal-mcp-server&lt;/a&gt;, which reads CFDI invoices for AI agents, runs in Node on &lt;code&gt;@xmldom/xmldom&lt;/code&gt; and does not use XSLT either.&lt;/p&gt;
&lt;h2 id=&quot;the-alternatives-tested-against-a-real-seal&quot;&gt;The alternatives, tested against a real seal&lt;/h2&gt;
&lt;p&gt;Claiming “SaxonJS solves it” is easy. To check it, I took &lt;code&gt;cfdi40-valid.xml&lt;/code&gt; from the CfdiUtils test suite, a CFDI 4.0 invoice signed with a SAT test certificate. I generated the string with each engine and accepted it only if the XML’s &lt;code&gt;Sello&lt;/code&gt; verifies against it with the public key of its own &lt;code&gt;Certificado&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# cert.pem = the base64 Certificado attribute, wrapped as PEM&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# sello.bin = the Sello attribute, base64-decoded&lt;/span&gt;
openssl x509 &lt;span class=&quot;token parameter variable&quot;&gt;-in&lt;/span&gt; cert.pem &lt;span class=&quot;token parameter variable&quot;&gt;-pubkey&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-noout&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt; pub.pem
openssl dgst &lt;span class=&quot;token parameter variable&quot;&gt;-sha256&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-verify&lt;/span&gt; pub.pem &lt;span class=&quot;token parameter variable&quot;&gt;-signature&lt;/span&gt; sello.bin cadena.txt
&lt;span class=&quot;token comment&quot;&gt;# Verified OK  → the string is correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reference was Saxon-HE 12.5 (Java, MPL 2.0) with the official stylesheet: &lt;code&gt;Verified OK&lt;/code&gt;. The rest:&lt;/p&gt;















































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Engine&lt;/th&gt;&lt;th&gt;Runs in&lt;/th&gt;&lt;th&gt;License&lt;/th&gt;&lt;th&gt;Result with the SAT stylesheet&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Native &lt;code&gt;XSLTProcessor&lt;/code&gt;, official stylesheet&lt;/td&gt;&lt;td&gt;Browser&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;Fails: blocks cross-origin includes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Native &lt;code&gt;XSLTProcessor&lt;/code&gt;, local copy&lt;/td&gt;&lt;td&gt;Browser&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;code&gt;Verified OK&lt;/code&gt;. Gone in Chrome 158&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;xslt-polyfill 1.0.28, stylesheet flattened into one file&lt;/td&gt;&lt;td&gt;Browser (WASM)&lt;/td&gt;&lt;td&gt;BSD-3-Clause&lt;/td&gt;&lt;td&gt;&lt;code&gt;Verified OK&lt;/code&gt; with native XSLT turned off&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;xslt-polyfill 1.0.28, copy with includes&lt;/td&gt;&lt;td&gt;Browser (WASM)&lt;/td&gt;&lt;td&gt;BSD-3-Clause&lt;/td&gt;&lt;td&gt;Fails: &lt;code&gt;Error: Unknown mime type undefined&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SaxonJS 2.7 (&lt;code&gt;saxon-js&lt;/code&gt; + &lt;code&gt;xslt3&lt;/code&gt;), stylesheet compiled to SEF&lt;/td&gt;&lt;td&gt;Node; per its docs the SEF also runs in the browser&lt;/td&gt;&lt;td&gt;Free of charge, not open source&lt;/td&gt;&lt;td&gt;&lt;code&gt;Verified OK&lt;/code&gt; in Node&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;xslt-processor 5.1.2&lt;/td&gt;&lt;td&gt;Pure JS&lt;/td&gt;&lt;td&gt;LGPL-3.0&lt;/td&gt;&lt;td&gt;Different string, seal does not verify&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The xslt-processor string starts with &lt;code&gt;|||||&lt;/code&gt; (five pipes instead of two). With the flattened stylesheet, the output shrinks to three pipes. It may be fine for other jobs, but it does not produce the SAT’s original string as-is.&lt;/p&gt;
&lt;p&gt;The polyfill’s failure with includes is not a random bug; it is in its README. It resolves &lt;code&gt;xsl:include&lt;/code&gt; with &lt;code&gt;fetch()&lt;/code&gt;, which is asynchronous, while &lt;code&gt;XSLTProcessor&lt;/code&gt;’s methods are synchronous, so they fail when the stylesheet has includes. Flattening fixes it: replace each &lt;code&gt;&amp;lt;xsl:include&amp;gt;&lt;/code&gt; with the contents of the included stylesheet, in the same order. With the current 4.0 stylesheet you do not need to add namespaces, because the main root already declares every one the includes use.&lt;/p&gt;
&lt;h3 id=&quot;option-1-on-the-server-the-boring-one-which-is-why-it-wins&quot;&gt;Option 1: on the server (the boring one, which is why it wins)&lt;/h3&gt;
&lt;p&gt;PHP’s ext-xsl is still in the &lt;a href=&quot;https://www.php.net/manual/en/book.xsl.php&quot;&gt;PHP manual&lt;/a&gt;, built on libxslt, and Chrome’s removal does not touch it. With CfdiUtils (&lt;code&gt;eclipxe/cfdiutils&lt;/code&gt;, MIT, 3.0.4 from 11 September 2026):&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;CfdiUtils&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;XmlResolver&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;XmlResolver&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;CfdiUtils&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;CadenaOrigen&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;DOMBuilder&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// XmlResolver downloads the SAT stylesheets and rewrites their dependencies to local copies&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$resolver&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;XmlResolver&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$location&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$resolver&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;resolveCadenaOrigenLocation&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;4.0&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token variable&quot;&gt;$cadena&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;DOMBuilder&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$xmlContent&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$location&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A correction to something that gets repeated: &lt;strong&gt;CfdiUtils does not build the string without XSLT.&lt;/strong&gt; &lt;code&gt;DOMBuilder&lt;/code&gt; is an ext-xsl &lt;code&gt;XSLTProcessor&lt;/code&gt;; it also ships &lt;code&gt;GenkgoXslBuilder&lt;/code&gt; (XSLT 2.0 in PHP) and &lt;code&gt;SaxonbCliBuilder&lt;/code&gt;. Its author explains that building the string without XSLT is possible, but it means a lot of code to write and test, and that code has to change along with every complement’s specification.&lt;/p&gt;
&lt;p&gt;From the browser, it becomes one request:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; res &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;/api/cfdi/original-string&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;POST&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token string-property property&quot;&gt;&amp;#39;Content-Type&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;application/xml&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; xmlText&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cadena &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The cost is privacy: if your viewer promised “the XML never leaves your browser”, that stops being true and the copy has to change.&lt;/p&gt;
&lt;h3 id=&quot;option-2-stay-in-the-browser-with-the-polyfill&quot;&gt;Option 2: stay in the browser with the polyfill&lt;/h3&gt;
&lt;p&gt;If local processing is the product, &lt;a href=&quot;https://github.com/mfreed7/xslt_polyfill&quot;&gt;xslt-polyfill&lt;/a&gt; replaces &lt;code&gt;XSLTProcessor&lt;/code&gt; with libxslt compiled to WebAssembly. It weighs about 1.4 MB minified. With a flattened stylesheet, your existing code does not change:&lt;/p&gt;
&lt;pre class=&quot;language-html&quot; data-language=&quot;html&quot;&gt;&lt;code class=&quot;language-html&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;script&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;/vendor/xslt-polyfill.min.js&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token script&quot;&gt;&lt;/span&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token script&quot;&gt;&lt;span class=&quot;token language-javascript&quot;&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; xsl &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;DOMParser&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;parseFromString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;flattenedStylesheet&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;application/xml&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cfdi &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;DOMParser&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;parseFromString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;xmlText&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;application/xml&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; p &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;XSLTProcessor&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// the polyfill&amp;#39;s, once the browser has no XSLT&lt;/span&gt;
  p&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;importStylesheet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;xsl&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cadena &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; p&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;transformToFragment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;cfdi&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; document&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;textContent&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While the browser still has native XSLT, the polyfill does not install itself. To try it in today’s Chrome, set &lt;code&gt;window.xsltUsePolyfillAlways = true&lt;/code&gt; before loading the script.&lt;/p&gt;
&lt;h3 id=&quot;option-3-saxonjs&quot;&gt;Option 3: SaxonJS&lt;/h3&gt;
&lt;p&gt;SaxonJS implements the mandatory parts of XSLT 3.0, which is more than enough for a stylesheet declaring 2.0. The documented flow is to compile the stylesheet to a SEF file and run that:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;npx xslt3 &lt;span class=&quot;token parameter variable&quot;&gt;-xsl:cadenaoriginal_4_0_flat.xslt&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-export:cadena.sef.json&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-nogo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; SaxonJS &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;saxon-js&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;principalResult&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; cadena &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; SaxonJS&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;stylesheetFileName&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;cadena.sef.json&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;sourceText&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; xmlText&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;destination&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;serialized&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my test, compiling took 2.4 s and the SEF weighed 3.65 MB, which matters if you ship it to the browser. Check the license before redistributing: Saxonica offers it free of charge, but it is not open source.&lt;/p&gt;
&lt;h2 id=&quot;how-to-find-out-if-you-depend-on-xslt&quot;&gt;How to find out if you depend on XSLT&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. Search the code.&lt;/strong&gt; Server-side hits in PHP, Java or .NET do not affect you; JavaScript that runs in the browser does:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;XSLTProcessor|transformToFragment|transformToDocument|importStylesheet|xml-stylesheet|cadenaoriginal&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;*.&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;js,mjs,ts,tsx,jsx,vue,svelte,astro,html,xml,php&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  --exclude-dir&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;node_modules,vendor,.git&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;.&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;find&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-name&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;*.xsl&amp;quot;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-name&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;*.xslt&amp;quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt; node_modules
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Search what you built.&lt;/strong&gt; The npm library above hides &lt;code&gt;XSLTProcessor&lt;/code&gt; inside &lt;code&gt;node_modules&lt;/code&gt;, so the grep above misses it. Run the same pattern over your build output (&lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;.next/&lt;/code&gt;, &lt;code&gt;public/build/&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Check what you serve.&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-s&lt;/span&gt; https://yoursite.com/wp-sitemap.xml &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;300&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;xml-stylesheet[^?]*&amp;#39;&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-s&lt;/span&gt; https://yoursite.com/feed/ &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;300&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;xml-stylesheet[^?]*&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;4. Test with XSLT off.&lt;/strong&gt; No need to wait for November or install Canary. In Stable Chrome, go to &lt;code&gt;chrome://flags/#xslt&lt;/code&gt; and set it to &lt;em&gt;Disabled&lt;/em&gt;, or start Chrome with &lt;code&gt;--disable-features=XSLT&lt;/code&gt; (I verified it on Chrome 152). For a headless check:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;chrome &lt;span class=&quot;token parameter variable&quot;&gt;--headless&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;new --disable-features&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;XSLT --dump-dom &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token string&quot;&gt;&amp;#39;data:text/html,&amp;lt;script&amp;gt;document.write(typeof XSLTProcessor)&amp;lt;/script&amp;gt;&amp;#39;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# undefined  → this is how your app will look in Chrome 158&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;5. Let production tell you.&lt;/strong&gt; Chrome reports the deprecation through the Reporting API with the id &lt;code&gt;XSLT&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;ReportingObserver&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;reports&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; r &lt;span class=&quot;token keyword&quot;&gt;of&lt;/span&gt; reports&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;body&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;XSLT&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; navigator&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;sendBeacon&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;/telemetry/xslt&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;body&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;sourceFile &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;deprecation&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;buffered&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;observe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And open the console: every use prints “XSLTProcessor and XSLT Processing Instructions have been deprecated by all browsers”.&lt;/p&gt;
&lt;h2 id=&quot;who-actually-needs-to-act&quot;&gt;Who actually needs to act&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You run a CFDI viewer, validator or PDF generator that builds the original string in the browser:&lt;/strong&gt; you are the audience for this post. You have until 17 November. Pick server-side (and update your privacy copy) or the polyfill with a flattened stylesheet, and validate against a real seal as above before shipping.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You use &lt;code&gt;XSLTProcessor&lt;/code&gt; for anything else on the front end:&lt;/strong&gt; same deadline, same options. If you cannot migrate in time, the &lt;a href=&quot;https://developer.chrome.com/docs/web-platform/origin-trials&quot;&gt;Origin Trial&lt;/a&gt; buys you until August 2027. If what uses XSLT is an XML document, there is no &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; for the meta tag, so the token goes in the &lt;code&gt;Origin-Trial&lt;/code&gt; HTTP header.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You have a WordPress, Yoast, Rank Math or Astro site with a styled sitemap:&lt;/strong&gt; nothing that matters for indexing breaks. Drop the instruction if the warning bothers you, or serve HTML separately if people read that sitemap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your company runs an internal app or device that serves XML with XSL and you cannot change it:&lt;/strong&gt; the &lt;code&gt;XSLTEnabled&lt;/code&gt; enterprise policy and the extension Chrome recommends are for you. The policy expires in August 2027. The extension applies the polyfill, so it does not depend on native XSLT.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You only build the string in PHP, Java or .NET on the server:&lt;/strong&gt; not your problem. This is a browser change.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/validate-mexican-rfc-cfdi-claude-mcp/&quot;&gt;Validate Mexican RFC, CURP and CFDI invoices from Claude&lt;/a&gt;: the open-source MCP server that reads CFDI 4.0 and validates RFC, CURP and CLABE, and the six details a regex gets wrong.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/best-mcp-servers-for-seo-2026/&quot;&gt;Best MCP servers for SEO in 2026&lt;/a&gt;: if you audit sitemaps with agents, which tool does what.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/&quot;&gt;What breaks upgrading to Node 26&lt;/a&gt;: the same format for the runtime.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When does Chrome remove XSLT support? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; In Chrome 158, which reaches Stable on 17 November 2026. From that version XSLTProcessor and processing of xml-stylesheet instructions with type text/xsl stop working, except for sites enrolled in the Origin Trial and machines with the XSLTEnabled enterprise policy. Both exceptions end in Chrome 176, on 17 August 2027. The Chrome 158 beta ships on 28 October 2026. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does my XML sitemap with an XSL stylesheet break in Chrome 158? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The file does not. What changes is how a person sees it in Chrome: instead of the table generated by the XSL stylesheet, the browser shows its raw XML viewer, with no error. I tested it in Chrome 152 with XSLT disabled on the yoast.com sitemap index. The server keeps sending exactly the same bytes. Google has not published anything specific about XSLT and sitemaps, but the change is in the browser, not in the XML document. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I remove the XSL stylesheet from a WordPress sitemap? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; In WordPress core, return false from the wp_sitemaps_stylesheet_url and wp_sitemaps_stylesheet_index_url filters, for example with __return_false; the core source says that displays the raw XML. In Yoast, the wpseo_stylesheet_url filter receives the full declaration and you can return an empty string. In Rank Math the filter is per type: rank_math/sitemap/{type}_stylesheet_url. WordPress core has ticket 65593 open to remove the stylesheet in version 7.2. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why do CFDI invoice viewers that use XSLT in the browser break? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because the original string of a CFDI 4.0 invoice is defined by the SAT through the cadenaoriginal_4_0.xslt stylesheet, and the convenient way to run it in the browser was XSLTProcessor. In Chrome 158 that class no longer exists and the code throws ReferenceError: XSLTProcessor is not defined. Without the original string you cannot verify the invoice&amp;#39;s digital seal. Reading the XML with DOMParser, as this site&amp;#39;s CFDI viewer does, keeps working. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Which alternative correctly builds the SAT original string without browser XSLT? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; I tested several against the seal of a CFDI 4.0 test invoice. Saxon-HE in Java, SaxonJS 2.7 with the stylesheet compiled to SEF, and xslt-polyfill 1.0.28 in the browser with the stylesheet flattened into one file all produced the correct string. xslt-polyfill fails if the stylesheet keeps its 33 xsl:include files, and xslt-processor 5.1.2 produced a different string that the seal does not verify against. On the server, PHP with ext-xsl, which is what CfdiUtils uses, is unaffected. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does CfdiUtils build the CFDI original string without XSLT? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. Its DOMBuilder uses XSLTProcessor from PHP&amp;#39;s ext-xsl extension with the SAT stylesheet, and it also offers GenkgoXslBuilder and SaxonbCliBuilder. Its documentation notes that the stylesheet declares XSLT 2.0 but the PHP transform produces the expected result. Because it runs on the server, Chrome&amp;#39;s XSLT removal does not affect it. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How can I test my site today as if it were Chrome 158? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; In Stable Chrome, go to chrome://flags/#xslt and set it to Disabled, or start the browser with --disable-features=XSLT. With that, typeof XSLTProcessor returns undefined and XML files with an XSL stylesheet render raw. To catch uses in production, register a ReportingObserver for deprecation reports and filter the ones with id XSLT. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Chrome</category><category>XSLT</category><category>XML</category><category>CFDI</category><category>SEO</category><category>Migration</category><author>Marco Orta</author></item><item><title>Portainer 3.0 Is Kubernetes-First: What Changes If You Run Docker (and Which Alternative to Pick)</title><link>https://ortamarco.me/en/blog/portainer-3-docker-alternatives/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/portainer-3-docker-alternatives/</guid><description>Portainer 3 is Kubernetes-first with no CE; the free tier is an expiring 3-node license. What breaks, and when to pick Coolify, Komodo, Dockge or Dockhand.</description><pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Portainer 3.0 will not break your Docker host at the end of the month. What ends is something else: there will be no Community Edition of the 3.x line.&lt;/strong&gt; CEO Neil Cresswell said so himself in the &lt;a href=&quot;https://www.portainer.io/blog/portainer-3-0-is-coming&quot;&gt;official announcement on 11 September 2026&lt;/a&gt;: CE “will continue on the 2.x codebase” and “will not receive the 3.x changes.” 3.x stays free for the community only through the &lt;strong&gt;3 Nodes Free&lt;/strong&gt; program, which is a Business Edition license. And 3.x is, in his words, “a Kubernetes-first codebase.”&lt;/p&gt;
&lt;p&gt;If you run Docker — not Kubernetes — with Portainer CE, the decision is not urgent, but it is real: stay on 2.x for as long as it lasts, take the free three-node license and accept a product built around Kubernetes, or leave. Below are the facts checked against primary sources, what breaks on each path, and which tool fits which setup. I run Coolify in production on two VPSes, so at the end I also share what has bitten me.&lt;/p&gt;
&lt;h2 id=&quot;what-the-announcement-actually-says&quot;&gt;What the announcement actually says&lt;/h2&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Topic&lt;/th&gt;&lt;th&gt;What Portainer published&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Last 2.x release&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2.45 LTS&lt;/strong&gt; (GitHub release &lt;code&gt;2.45.0&lt;/code&gt;, 27 August 2026) is the last release in the 2.x line&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.0&lt;/td&gt;&lt;td&gt;Ships as &lt;strong&gt;3.0.0 STS “from the end of this month”&lt;/strong&gt;; the next LTS is &lt;strong&gt;3.3.0, in December&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Direction&lt;/td&gt;&lt;td&gt;3.x is Kubernetes-first: keeping Docker/Podman, Swarm and Kubernetes at parity in one codebase “is no longer viable”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Native Docker, Swarm and Podman environments&lt;/td&gt;&lt;td&gt;You can still add them and “they still work,” but they are ordered second in the UI and &lt;strong&gt;will not receive new capabilities&lt;/strong&gt; from the policy engine, GitOps engine or observability layer&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;New products&lt;/td&gt;&lt;td&gt;Portainer-Run, IDP, Command and AiGrid are &lt;strong&gt;Kubernetes-only&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;If you stay on 2.x&lt;/td&gt;&lt;td&gt;“Nothing changes”: security updates, bug fixes and selective back-ports of 3.x features continue&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Community Edition&lt;/td&gt;&lt;td&gt;Continues on 2.x. &lt;strong&gt;No separate CE build of 3.x&lt;/strong&gt;; 3.x is free through &lt;strong&gt;3 Nodes Free&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Official recommendation&lt;/td&gt;&lt;td&gt;Move from Docker to &lt;strong&gt;D2K&lt;/strong&gt; (a synthetic Docker on top of Kubernetes) or native Kubernetes; KubeSolo for single nodes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h3 id=&quot;the-reddit-take-ce-is-gone-and-the-only-free-option-is-business-up-to-3-nodes&quot;&gt;The Reddit take: “CE is gone, and the only free option is Business up to 3 nodes”&lt;/h3&gt;
&lt;p&gt;That reading spread on r/selfhosted. &lt;strong&gt;It is correct for the 3.x line and incomplete about everything else.&lt;/strong&gt; Here is what the primary sources confirm:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;True:&lt;/strong&gt; there will be no CE edition of 3.x, and the free way to run 3.x is 3 Nodes Free. The &lt;a href=&quot;https://www.portainer.io/take-3&quot;&gt;program page&lt;/a&gt; says it is a &lt;strong&gt;Business Edition license key&lt;/strong&gt;, sent after you fill in a form, and “limited to one license per organization.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incomplete:&lt;/strong&gt; CE does not vanish tomorrow. It stays on 2.x, the &lt;a href=&quot;https://github.com/portainer/portainer&quot;&gt;&lt;code&gt;portainer/portainer&lt;/code&gt;&lt;/a&gt; repository keeps its zlib license, and the &lt;a href=&quot;https://docs.portainer.io/start/lifecycle&quot;&gt;lifecycle policy&lt;/a&gt; gives 2.45 LTS maintenance &lt;strong&gt;until May 2027&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The detail almost nobody mentions:&lt;/strong&gt; the three-node license &lt;strong&gt;expires and has to be renewed&lt;/strong&gt;. The &lt;a href=&quot;https://docs.portainer.io/faqs/licensing/how-do-i-renew-my-3-nodes-free-license&quot;&gt;docs&lt;/a&gt; say the new key is not sent until 14 days before the current one expires. CE needed no form and no renewal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What counts as a node:&lt;/strong&gt; per the &lt;a href=&quot;https://docs.portainer.io/faqs/licensing/what-is-a-node-for-licensing-purposes&quot;&gt;licensing FAQ&lt;/a&gt;, on Docker any server running the Portainer Server or the Agent counts. Three Docker hosts (one running the server, two running agents) are three nodes, so you have used up the free tier.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A warning about that May 2027 date: the same lifecycle page still lists a 2.46 STS for September and a 2.51 LTS for February 2027, which the announcement contradicts, since 2.45 is the last 2.x. The table has not been updated since the announcement, so treat May 2027 as the published date, not a guarantee. The announcement itself gives no end date for 2.x.&lt;/p&gt;
&lt;h2 id=&quot;what-breaks-and-what-doesnt-on-each-path&quot;&gt;What breaks (and what doesn’t) on each path&lt;/h2&gt;
&lt;h3 id=&quot;path-1-you-stay-on-portainer-ce-245&quot;&gt;Path 1: you stay on Portainer CE 2.45&lt;/h3&gt;
&lt;p&gt;Nothing breaks today. There are still two things worth doing this week.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pin the exact image version.&lt;/strong&gt; On Docker Hub, the &lt;code&gt;lts&lt;/code&gt;, &lt;code&gt;sts&lt;/code&gt; and &lt;code&gt;latest&lt;/code&gt; tags of &lt;code&gt;portainer/portainer-ce&lt;/code&gt; were last updated on 27 August, with 2.45.0. I found nothing documenting where those tags will point once 3.0 STS ships, so if you run Watchtower or anything similar, do not let a floating tag make the call:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; data-language=&quot;yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;portainer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; portainer/portainer&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;ce&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;2.45.0   &lt;span class=&quot;token comment&quot;&gt;# not :latest or :sts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Make sure you are on 2.45.0 (or 2.39.7 if you are still on the previous LTS).&lt;/strong&gt; The &lt;a href=&quot;https://github.com/portainer/portainer/releases/tag/2.45.0&quot;&gt;2.45.0 release notes&lt;/a&gt; fix a critical authorization bypass in the Docker proxy: unrecognized API version prefixes such as &lt;code&gt;/v1.47.0/&lt;/code&gt; skipped access control entirely, letting non-admin users reach the Docker API directly. 2.39.7 LTS got the same fix on the same day.&lt;/p&gt;
&lt;p&gt;To check which image you are running:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--filter&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;portainer &lt;span class=&quot;token parameter variable&quot;&gt;--format&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;{{.Image}}&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Staying costs you new features and buys you time to decide.&lt;/p&gt;
&lt;h3 id=&quot;path-2-you-move-to-3x-on-the-3-node-license&quot;&gt;Path 2: you move to 3.x on the 3-node license&lt;/h3&gt;
&lt;p&gt;Your native Docker environments keep working; the announcement is explicit about that. What changes are the terms: a Business license that expires, a three-node cap, a product whose roadmap no longer includes Docker, and a 3.0 that ships as STS (the LTS arrives in December with 3.3.0). If you manage more than three hosts, this path is a paid one.&lt;/p&gt;
&lt;p&gt;There is an upside if several people share your Portainer: role-based access control is a Business Edition feature per the &lt;a href=&quot;https://docs.portainer.io/admin/user/roles&quot;&gt;roles documentation&lt;/a&gt;, so 3 Nodes Free gives you RBAC.&lt;/p&gt;
&lt;h3 id=&quot;path-3-you-follow-portainers-advice-and-move-to-d2k&quot;&gt;Path 3: you follow Portainer’s advice and move to D2K&lt;/h3&gt;
&lt;p&gt;This is where things do break, by design rather than by bug. &lt;a href=&quot;https://github.com/portainer/d2k&quot;&gt;D2K&lt;/a&gt; (MIT licensed) exposes the Docker Engine API and translates every call into Kubernetes objects inside one namespace. Its own README lists what it does not support and says it “will not be added”:&lt;/p&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;What you use on Docker today&lt;/th&gt;&lt;th&gt;What happens on D2K&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;build:&lt;/code&gt; in your compose file&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Ignored&lt;/strong&gt;: images must be pre-built and pushed to a registry&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;docker build&lt;/code&gt;, &lt;code&gt;docker load&lt;/code&gt;, &lt;code&gt;docker save&lt;/code&gt;, &lt;code&gt;buildx&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Not supported&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;macvlan&lt;/code&gt; / &lt;code&gt;ipvlan&lt;/code&gt; networks&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Not supported&lt;/strong&gt;: those workloads “must remain on a native Docker host”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--network host&lt;/code&gt;, &lt;code&gt;--ip&lt;/code&gt;, &lt;code&gt;--mac-address&lt;/code&gt;, &lt;code&gt;--link&lt;/code&gt;&lt;/td&gt;&lt;td&gt;No equivalent&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Separate networks per stack&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker network create&lt;/code&gt; is synthetic: &lt;strong&gt;network isolation is not enforced&lt;/strong&gt;, and all pods share the namespace network&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Bind mounts like &lt;code&gt;./data:/app/data&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Become &lt;code&gt;hostPath&lt;/code&gt; volumes, “unreliable in multi-node clusters”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--device&lt;/code&gt; (for example &lt;code&gt;/dev/video0&lt;/code&gt;), &lt;code&gt;--ulimit&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Not translated&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;docker stats&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Needs metrics-server to return real data&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;If your compose file builds its image on the same server, if you isolate your database on an internal network, or if anything runs with &lt;code&gt;network_mode: host&lt;/code&gt;, D2K is not a migration. It is a redesign, and there is still a Kubernetes cluster underneath to operate.&lt;/p&gt;
&lt;h2 id=&quot;the-alternatives-verified&quot;&gt;The alternatives, verified&lt;/h2&gt;
&lt;p&gt;I checked the repository, license and latest release of each one on 13 September 2026.&lt;/p&gt;
&lt;h3 id=&quot;coolify-if-what-you-do-in-portainer-is-deploy-your-own-apps&quot;&gt;Coolify: if what you do in Portainer is deploy your own apps&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;License:&lt;/strong&gt; Apache-2.0. About 61,700 GitHub stars.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maturity:&lt;/strong&gt; stable v4.0.0 shipped on 27 April 2026, and the latest release is &lt;strong&gt;v4.3.19, from 10 September&lt;/strong&gt;. It shipped four releases between 3 and 10 September.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it is:&lt;/strong&gt; a self-hosted PaaS, not a container UI. It deploys from git (Nixpacks, Railpack, Dockerfile, Docker Compose or static site), runs the reverse proxy (Traefik or Caddy) with certificates, schedules database backups, ships 280+ one-click services and manages multiple servers over SSH.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Users:&lt;/strong&gt; teams, 2FA, and OAuth with Azure, Bitbucket, Clerk, Discord, GitHub, GitLab, Google, Authentik, Infomaniak and Zitadel (the list comes from the source code).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; 2 cores, 2 GB of RAM and 10 GB of disk per its &lt;a href=&quot;https://coolify.io/docs/get-started/installation&quot;&gt;documentation&lt;/a&gt;, which also recommends a fresh server to avoid conflicts with whatever is already running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it is not:&lt;/strong&gt; a dashboard for containers you started by hand. Its model is that Coolify does the deploying.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swarm:&lt;/strong&gt; &lt;strong&gt;deprecated.&lt;/strong&gt; The notice in its own code says Swarm will be removed in Coolify v5 and that new Swarm deployments are not recommended.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;komodo-the-most-direct-portainer-replacement-across-many-servers&quot;&gt;Komodo: the most direct Portainer replacement across many servers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;License:&lt;/strong&gt; GPL-3.0. About 12,200 stars. Latest release: &lt;strong&gt;v2.3.3, from 1 September 2026&lt;/strong&gt;; v2.0.0 shipped on 24 March.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it does:&lt;/strong&gt; connects servers (with an agent called Periphery on each), deploys containers and &lt;strong&gt;Compose stacks defined in the UI, on the host or in a git repo, with auto-redeploy on push through webhooks&lt;/strong&gt;. It also builds images, runs scheduled automations and records who changed what and when.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swarm:&lt;/strong&gt; supported since v2.0.0 (clusters, nodes, services, stacks, configs and secrets).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Users:&lt;/strong&gt; username and password with 2FA, OAuth with GitHub, Google and generic OIDC, and granular role-based permissions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server limit:&lt;/strong&gt; none, “and there never will be,” per its README.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; Docker, plus &lt;strong&gt;MongoDB or FerretDB&lt;/strong&gt; (on Postgres) for Core. It asks for the most infrastructure on this list, and its docs assume you bring your own reverse proxy.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;dockge-if-all-you-want-is-to-edit-composeyaml-files-in-a-browser&quot;&gt;Dockge: if all you want is to edit &lt;code&gt;compose.yaml&lt;/code&gt; files in a browser&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;License:&lt;/strong&gt; MIT. About 24,300 stars. Same author as Uptime Kuma.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maturity:&lt;/strong&gt; latest release &lt;strong&gt;1.5.0, from 30 March 2025&lt;/strong&gt;; the latest commit on &lt;code&gt;master&lt;/code&gt; is from April 2026. Maintenance is slow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it does:&lt;/strong&gt; creates, edits, starts and updates Compose stacks with a web terminal, and converts &lt;code&gt;docker run&lt;/code&gt; commands to compose. Your files stay on disk (&lt;code&gt;/opt/stacks&lt;/code&gt; by default) and keep working with plain &lt;code&gt;docker compose&lt;/code&gt;. Since 1.4.0 it manages multiple hosts through agents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it doesn’t:&lt;/strong&gt; &lt;strong&gt;it has a single account.&lt;/strong&gt; Setup refuses to create a second user, and the pull request that added users and groups (#891) was closed unmerged in July 2026. No SSO, no roles, and the README does not mention deploying from git.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;dockhand-the-most-complete-ui-with-fine-print-in-the-license&quot;&gt;Dockhand: the most complete UI, with fine print in the license&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It exists and moves fast:&lt;/strong&gt; the &lt;a href=&quot;https://github.com/Finsys/dockhand&quot;&gt;&lt;code&gt;Finsys/dockhand&lt;/code&gt;&lt;/a&gt; repository was created in December 2025, has about 6,200 stars, and its latest release is &lt;strong&gt;v1.0.47, from 12 September 2026&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it does:&lt;/strong&gt; containers, Compose stacks with a visual editor, &lt;strong&gt;git deployments with webhooks&lt;/strong&gt;, multiple hosts (over the socket, TCP with TLS, or Hawser, its MIT-licensed agent that connects outbound with no open ports), vulnerability scanning with Grype and Trivy, backups (in beta) and free OIDC/SSO. SQLite by default or PostgreSQL, and they say it runs on a Raspberry Pi 4.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it doesn’t:&lt;/strong&gt; Swarm; there is an &lt;a href=&quot;https://github.com/Finsys/dockhand/issues/58&quot;&gt;open issue&lt;/a&gt; asking for it. RBAC, LDAP/Active Directory and compliance audit logging are Enterprise features ($1,499 per host per year).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The license contradicts itself.&lt;/strong&gt; The repository’s &lt;code&gt;LICENSE.txt&lt;/code&gt; is the &lt;strong&gt;Business Source License 1.1&lt;/strong&gt; with an additional use grant that explicitly permits “internal business use” in production, as long as you do not offer it as a Docker management SaaS; it converts to Apache-2.0 on 1 January 2029. The &lt;a href=&quot;https://dockhand.pro/license.html&quot;&gt;license terms on its website&lt;/a&gt;, however, say commercial users need a paid license for production environments or shared instances, and the pricing table places the “commercial usage license” in the SMB plan ($499 per host per year). For a homelab there is no ambiguity. If you are a company, get clarification in writing before you put it in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;lazydocker-and-yacht-for-completeness&quot;&gt;Lazydocker and Yacht, for completeness&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/jesseduffield/lazydocker&quot;&gt;Lazydocker&lt;/a&gt;&lt;/strong&gt; (MIT, about 52,800 stars, v0.25.2 from April 2026) is a terminal UI, not a web server: no users, no agents, nothing to expose. If you only used Portainer to read logs and restart containers, it is enough.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/Yacht-sh/Yacht&quot;&gt;Yacht&lt;/a&gt;&lt;/strong&gt; published again in September 2026 (release &lt;code&gt;1.1&lt;/code&gt;) after years without releases; the previous one, &lt;code&gt;v0.0.7-alpha&lt;/code&gt;, dates from 2021. Too early to trust with production.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-decision-table&quot;&gt;The decision table&lt;/h2&gt;























































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Your situation&lt;/th&gt;&lt;th&gt;Pick&lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Homelab with 1 to 3 hosts; you use Portainer CE to look and restart&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Stay on CE 2.45.0&lt;/strong&gt; and decide before May 2027&lt;/td&gt;&lt;td&gt;Nothing breaks today and you have runway&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You only edit &lt;code&gt;compose.yaml&lt;/code&gt; files and you are the only user&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Dockge&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;MIT, minimal, and your files stay on disk&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You want a modern Portainer-style UI for personal use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Dockhand&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Most complete: git, vulnerability scanning and free SSO&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;A company with several servers, several people and stacks in git&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Komodo&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;GPL, no server limit, roles and OIDC with no license fee&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You run Docker Swarm&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Komodo&lt;/strong&gt;, or stay on Portainer 2.45&lt;/td&gt;&lt;td&gt;Coolify deprecated it; Dockge and Dockhand don’t support it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You deploy your own apps from git to one or more VPSes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Coolify&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Builds, proxy, certificates and backups included&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You already pay for Portainer Business, or 3 nodes with RBAC is enough and you are heading to Kubernetes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Portainer 3.x&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;That is exactly who it is built for&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;You only open Portainer to read logs&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Lazydocker&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Nothing to expose, nothing to license&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Your stacks build images on the server or use &lt;code&gt;network_mode: host&lt;/code&gt; or &lt;code&gt;macvlan&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Anything but D2K&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;D2K ignores &lt;code&gt;build:&lt;/code&gt; and does not support those networks&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;what-i-have-learned-running-coolify-on-two-vpses&quot;&gt;What I have learned running Coolify on two VPSes&lt;/h2&gt;
&lt;p&gt;I run Coolify in production on two servers: one hosting this site and client apps, the other running internal tools. Three things no comparison page tells you, and they apply to any tool that talks to the Docker socket, Portainer, Dockge and Dockhand included.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. After upgrading &lt;code&gt;docker-ce&lt;/code&gt;, restart the containers that mount the socket.&lt;/strong&gt; In May 2026 I upgraded Docker from 29.5.0 to 29.5.2 with &lt;code&gt;live-restore&lt;/code&gt; enabled. The apps stayed up, but Coolify’s proxy (Traefik with the Docker provider) got stuck in a reconnect loop against &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. The result: &lt;strong&gt;HTTP 502 on every app&lt;/strong&gt; routed through Docker, while the dashboard, which is routed through a config file, kept responding, which makes it easy to misdiagnose. &lt;code&gt;live-restore&lt;/code&gt; protects the containers, not the connections they held open to the socket. Since then, after every upgrade I run:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; restart coolify-proxy coolify-sentinel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or, more generally, I restart every container that mounts the socket:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token for-or-select variable&quot;&gt;c&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--format&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;{{.Names}}&amp;#39;&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;do&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; inspect &lt;span class=&quot;token parameter variable&quot;&gt;-f&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;{{range .Mounts}}{{if eq .Source &amp;quot;/var/run/docker.sock&amp;quot;}}{{$.Name}}{{end}}{{end}}&amp;#39;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$c&lt;/span&gt;&amp;quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;^$&amp;#39;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;done&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;xargs&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-r&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-n1&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; restart
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that since v4.3.19 Sentinel is mandatory on regular Coolify servers, so restarting &lt;code&gt;coolify-sentinel&lt;/code&gt; now applies to every install.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Docker bypasses UFW.&lt;/strong&gt; When I installed Coolify on the second VPS, the dashboard and realtime ports ended up open to the internet even though UFW did not allow them: ports published by a container go through iptables rules that Docker manages before UFW ever sees the traffic. I fixed it with &lt;code&gt;DROP&lt;/code&gt; rules in the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain, matching on the connection’s original destination port (&lt;code&gt;conntrack --ctorigdstport&lt;/code&gt;, because by the time a packet reaches that chain, DNAT has already rewritten the port), and left only 80 and 443 public. If you install any of these UIs with a published port, &lt;strong&gt;check from outside the server&lt;/strong&gt;; do not trust &lt;code&gt;ufw status&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Firewall rules pinned to a container IP break on reboot.&lt;/strong&gt; On the production server, after a reboot, every domain timed out even though Traefik was healthy. The &lt;code&gt;ufw-docker&lt;/code&gt; rules pointed at the proxy’s old internal IP, and the proxy came back with a different one. The fix that holds was allowing 80 and 443 to Docker’s network range instead of to a specific IP. Restarting Docker does not fix it, because the rules belong to UFW.&lt;/p&gt;
&lt;p&gt;And one that comes straight from Coolify’s own documentation, which I can confirm: create the admin account as soon as the install finishes. Whoever reaches the registration page first gets control of the server.&lt;/p&gt;
&lt;p&gt;If you are copying your stacks out of Portainer into another tool, validate each &lt;code&gt;compose.yaml&lt;/code&gt; against the official schema before the first deploy:&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Docker Compose Validator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Validate your compose.yaml against the official Compose schema and catch key typos, wrong types and YAML errors, with the exact line. All in your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/docker-compose-validator/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;validador-docker-compose&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;verdict&quot;&gt;Verdict&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;If you run Portainer CE on Docker, do not migrate this month.&lt;/strong&gt; 2.45 LTS is still maintained, 3.0 ships as STS, and the first 3.x LTS does not arrive until December. Pin the image to &lt;code&gt;2.45.0&lt;/code&gt; and use the next few months to trial your alternative on a server that is not production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But do not wait for a CE 3.x, because it is not coming.&lt;/strong&gt; The Portainer you knew — free, no sign-up, no node cap, Docker as a first-class citizen — is frozen on 2.x.&lt;/p&gt;
&lt;p&gt;To choose, ask what you really do in Portainer. If you &lt;strong&gt;deploy your own apps&lt;/strong&gt;, the natural move is Coolify, and it is what I use. If you &lt;strong&gt;manage stacks across several servers with a team&lt;/strong&gt;, Komodo is the closest thing to what Portainer used to be, with no node cap and no commercial license. If &lt;strong&gt;it is just you and a homelab&lt;/strong&gt;, Dockge if the minimum is enough, or Dockhand if you want everything. And if you were already heading to Kubernetes, Portainer 3.x is built for you; D2K only makes sense if your stacks neither build images nor rely on special networking.&lt;/p&gt;
&lt;p&gt;Keep reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/complete-docker-guide-2026/&quot;&gt;The complete Docker guide for 2026&lt;/a&gt;: Compose v5, Engine 29 and how Compose, Swarm and Kubernetes compare for production.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-kubernetes-1-37/&quot;&gt;Kubernetes 1.37: what breaks on upgrade day&lt;/a&gt;: if you do end up on Kubernetes, with or without D2K, this is what blocks an upgrade right now.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is Portainer Community Edition going away with 3.0? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not immediately, but there will be no CE 3.x. According to the official announcement on 11 September 2026, CE continues on the 2.x codebase and will not receive the 3.x changes. Portainer is not cutting a separate CE build of 3.x: the free way to run 3.x is the 3 Nodes Free program, which is a Business Edition license. Portainer&amp;#39;s lifecycle policy gives 2.45 LTS maintenance until May 2027. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is Portainer 3 Nodes Free and what does it limit? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It is a free Portainer Business Edition license for up to three nodes, limited to one license per organization and requested through a form. The license expires and must be renewed: the new key is sent no earlier than 14 days before expiry. On Docker, any server running the Portainer Server or the Portainer Agent counts as a node, so three Docker hosts already use up the free tier. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Will my Docker environments stop working if I upgrade to Portainer 3.x? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. The announcement says native Docker, Swarm and Podman environments can still be added and still work from the UI. What changes is that they are ordered second in the product and will not receive new capabilities from the policy engine, GitOps engine or observability layer. The new products, Portainer-Run, IDP, Command and AiGrid, are Kubernetes-only. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How long will Portainer 2.45 LTS get patches? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The lifecycle policy in Portainer&amp;#39;s documentation lists May 2027 as the end of support and maintenance for 2.45 LTS. The 3.0 announcement says the 2.x line will keep receiving security updates, bug fixes and selective back-ports, without giving an end date. That lifecycle table still lists 2.46 to 2.51 releases that the announcement contradicts, so treat May 2027 as the published date rather than a guarantee. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is Portainer D2K and what does it not support? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; D2K is an MIT-licensed Docker-to-Kubernetes translator: it exposes the Docker Engine API and turns each call into Kubernetes objects inside one namespace. Per its README, it ignores the Compose build directive, does not support docker build, load, save or buildx, does not support macvlan or ipvlan networks, has no equivalent for host networking or static IP and MAC addresses, does not enforce network isolation between stacks, and turns bind mounts into hostPath volumes that are unreliable in multi-node clusters. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is the best free Portainer alternative for Docker? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It depends on what you use it for. For several servers and several people, Komodo (GPL-3.0) has no server limit and includes roles and OIDC, although it needs MongoDB or FerretDB. For one person editing compose files, Dockge (MIT) is the simplest, but it supports a single account. For deploying your own apps from git with a proxy and certificates, Coolify (Apache-2.0). Dockhand is the most complete UI, but its commercial licensing is ambiguous. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can Coolify replace Portainer? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Only if what you do in Portainer is deploy your own applications. Coolify is a self-hosted PaaS: it deploys from git with Nixpacks, Railpack, Dockerfile or Docker Compose, sets up Traefik or Caddy with certificates and schedules database backups. It is not designed to manage containers created by hand, its documentation recommends a fresh server, and its Docker Swarm support is deprecated and will be removed in Coolify v5. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is Dockhand free for commercial use? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It is unclear, because its two license texts contradict each other. The repository&amp;#39;s LICENSE.txt is the Business Source License 1.1 with a grant that permits internal business use in production, as long as it is not offered as a SaaS. The license terms on dockhand.pro instead require a paid license for commercial users in production or on shared instances, and the pricing table puts the commercial usage license in the SMB plan at $499 per host per year. Personal use is free with no ambiguity. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Docker</category><category>DevOps</category><category>Containers</category><category>Infrastructure</category><category>Migration</category><author>Marco Orta</author></item><item><title>GitHub Actions in Fall 2026: What Breaks (and Why node20 Isn&apos;t the Problem)</title><link>https://ortamarco.me/en/blog/what-breaks-github-actions-fall-2026/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-github-actions-fall-2026/</guid><description>GitHub drops Node 20 on Sept 23, but node20 actions won&apos;t fail: they already run on Node 24. What breaks is the escape hatch. Every date, plus audit greps.</description><pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;On 23 September 2026 GitHub removes Node 20 from the Actions runners, and almost everyone is reading that as “my actions with &lt;code&gt;using: node20&lt;/code&gt; are going to fail.” They are not. Since 16 June the runner has been rewriting &lt;code&gt;node20&lt;/code&gt; to &lt;code&gt;node24&lt;/code&gt; before it executes anything, and it does the same with &lt;code&gt;node12&lt;/code&gt; and &lt;code&gt;node16&lt;/code&gt;. What goes away on the 23rd is the escape hatch: the &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&lt;/code&gt; variable stops working.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That inverts who is at risk. If your CI has been green since June without that variable, the 23rd changes nothing for you on GitHub-hosted runners. The ones that break are precisely the teams that &lt;em&gt;already hit&lt;/em&gt; an action that can’t handle Node 24, set the variable to keep moving, and filed the real fix under later. And around that date there are four more with real consequences: the self-hosted runner minimum version (25 September), retention for checks and runs (1 October), the &lt;code&gt;macos-14&lt;/code&gt; brownouts (October) and its retirement (2 November).&lt;/p&gt;
&lt;p&gt;I checked this against the runner’s source code and against this site’s own CI, which uses two &lt;code&gt;node20&lt;/code&gt; actions. Everything below comes with its source.&lt;/p&gt;
&lt;h2 id=&quot;the-calendar-by-date&quot;&gt;The calendar, by date&lt;/h2&gt;

































































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Date&lt;/th&gt;&lt;th&gt;What changes&lt;/th&gt;&lt;th&gt;What breaks&lt;/th&gt;&lt;th&gt;How to detect it&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;14, 16 and 18 Sep&lt;/strong&gt; (11:00-15:00 ET)&lt;/td&gt;&lt;td&gt;Brownouts ahead of the self-hosted runner minimum version&lt;/td&gt;&lt;td&gt;Outdated runners &lt;strong&gt;cannot register or run jobs&lt;/strong&gt; during the window&lt;/td&gt;&lt;td&gt;Runner deprecations API (below)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;17 Sep&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ubuntu-22.04&lt;/code&gt; and &lt;code&gt;ubuntu-22.04-arm&lt;/code&gt; enter deprecation&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Nothing fails yet&lt;/strong&gt;; queues may be longer&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep&lt;/code&gt; for the label&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;21-30 Sep&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;windows-11-arm&lt;/code&gt; moves to the Visual Studio 2026 image&lt;/td&gt;&lt;td&gt;The toolchain underneath changes&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep windows-11-arm&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;23 Sep&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Node 20 leaves the runners&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&lt;/code&gt; is ignored; Linux ARM32 runners lose support&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep&lt;/code&gt; for the variable&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;25 Sep&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Self-hosted runner minimum version is enforced&lt;/td&gt;&lt;td&gt;Registering requires ≥ 2.329.0; a runner that doesn’t install each release within 30 days &lt;strong&gt;stops receiving jobs&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Deprecations API&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;1 Oct&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Actions retention now covers checks, workflow runs and statuses&lt;/td&gt;&lt;td&gt;Anything past your retention period (90 days by default) &lt;strong&gt;is deleted&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Repo/org retention setting&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;5-31 Oct&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Eight &lt;code&gt;macos-14&lt;/code&gt; brownouts&lt;/td&gt;&lt;td&gt;Jobs scheduled inside the window &lt;strong&gt;fail&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep macos-14&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;2 Nov&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;macos-14&lt;/code&gt; retired&lt;/td&gt;&lt;td&gt;Jobs with that label &lt;strong&gt;terminate with an error&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep macos-14&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;17 Apr 2027&lt;/td&gt;&lt;td&gt;&lt;code&gt;ubuntu-22.04&lt;/code&gt; retired (brownouts before, in March and April)&lt;/td&gt;&lt;td&gt;Jobs terminate with an error&lt;/td&gt;&lt;td&gt;&lt;code&gt;grep&lt;/code&gt; for the label&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;1-node-20-what-actually-happens-on-23-september&quot;&gt;1. Node 20: what actually happens on 23 September&lt;/h2&gt;
&lt;h3 id=&quot;what-the-notice-says&quot;&gt;What the notice says&lt;/h3&gt;
&lt;p&gt;GitHub’s &lt;a href=&quot;https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/&quot;&gt;changelog post from 19 September 2025&lt;/a&gt;, with an editor’s note dated 25 August 2026, sets two dates: from &lt;strong&gt;16 June 2026&lt;/strong&gt; runners use Node 24 by default, and the &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true&lt;/code&gt; opt-out back to Node 20 “will only work until we upgrade the runner and remove Node20 on &lt;strong&gt;September 23rd, 2026&lt;/strong&gt;.” It adds two platform limits: Node 24 is incompatible with macOS 13.4 and lower, and it has no official ARM32 support.&lt;/p&gt;
&lt;p&gt;What the notice does not explain is what happens to an action whose &lt;code&gt;action.yml&lt;/code&gt; still says &lt;code&gt;using: node20&lt;/code&gt;. That lives in the code.&lt;/p&gt;
&lt;h3 id=&quot;what-the-runner-code-says&quot;&gt;What the runner code says&lt;/h3&gt;
&lt;p&gt;In &lt;a href=&quot;https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/HandlerFactory.cs&quot;&gt;&lt;code&gt;HandlerFactory.cs&lt;/code&gt;&lt;/a&gt; (identical at tag v2.337.0, the current release), the runner does two things before launching a JavaScript action:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If the action declares &lt;code&gt;node12&lt;/code&gt; or &lt;code&gt;node16&lt;/code&gt;, it bumps it to &lt;code&gt;node20&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it ends up on &lt;code&gt;node20&lt;/code&gt;, it asks &lt;a href=&quot;https://github.com/actions/runner/blob/main/src/Runner.Common/Util/NodeUtil.cs&quot;&gt;&lt;code&gt;NodeUtil.DetermineActionsNodeVersion&lt;/code&gt;&lt;/a&gt; which version to use.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That function has three phases, driven by feature flags on GitHub’s side:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot; data-language=&quot;csharp&quot;&gt;&lt;code class=&quot;language-csharp&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Phase 3: Always use Node 24 regardless of environment variables&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;requireNode24&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;Constants&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;NodeMigration&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Node24&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// Phase 2: Node 24 is the default&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;useNode24ByDefault&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;allowUnsecureNode&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;Constants&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;NodeMigration&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Node20&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;Constants&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;NodeMigration&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Node24&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/actions/runner/pull/3948&quot;&gt;PR #3948&lt;/a&gt;, which introduced this, is blunt about it: in phase 3, “All actions use Node 24” and “No opt-out options available.” No branch returns an error for declaring &lt;code&gt;node20&lt;/code&gt;. The action runs on a different Node, which is very different from not running.&lt;/p&gt;
&lt;p&gt;Note the order too: &lt;code&gt;node12&lt;/code&gt; → &lt;code&gt;node20&lt;/code&gt; → &lt;code&gt;node24&lt;/code&gt;. An old action with &lt;code&gt;using: node16&lt;/code&gt; (for example &lt;code&gt;actions/checkout@v3&lt;/code&gt;) ends up on Node 24 as well.&lt;/p&gt;
&lt;h3 id=&quot;the-proof-this-sites-ci&quot;&gt;The proof: this site’s CI&lt;/h3&gt;
&lt;p&gt;This blog’s CI workflow uses &lt;code&gt;actions/checkout@v4&lt;/code&gt; and &lt;code&gt;actions/setup-node@v4&lt;/code&gt;. The official &lt;code&gt;action.yml&lt;/code&gt; files at those tags declare &lt;code&gt;using: node20&lt;/code&gt;; from &lt;code&gt;v5&lt;/code&gt; onward they declare &lt;code&gt;node24&lt;/code&gt; (I checked tags &lt;code&gt;v4&lt;/code&gt; through &lt;code&gt;v7&lt;/code&gt; of both). The run on 11 September, on runner 2.337.0 with the &lt;code&gt;ubuntu-24.04&lt;/code&gt; image, finished green with this annotation:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Node.js 20 is deprecated. The following actions target Node.js 20 but are being
forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And in the log of those two actions’ steps, this line:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Node 20 is being deprecated. This workflow is running with Node 24 by default.
If you need to temporarily use Node 20, you can set the
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same annotation appears on the very first run of that workflow, on 23 August. In other words, my two &lt;code&gt;node20&lt;/code&gt; actions have never run on Node 20 in this CI, and I didn’t touch anything to make that happen. 23 September doesn’t change their runtime, because that change already happened in June. Bumping them to &lt;code&gt;v5&lt;/code&gt; or later clears the annotation, and it’s worth doing, but there’s no clock on it.&lt;/p&gt;
&lt;h3 id=&quot;what-it-looks-like-when-it-does-break&quot;&gt;What it looks like when it does break&lt;/h3&gt;
&lt;p&gt;There are three real ways to fail, and none of them is a GitHub message saying “Node 20 no longer exists.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You had the variable set.&lt;/strong&gt; Some action of yours tripped on Node 24 after 16 June, you set &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true&lt;/code&gt;, and it stayed on Node 20. In phase 3 the runner &lt;strong&gt;ignores the variable without telling you&lt;/strong&gt;: the &lt;code&gt;requireNode24&lt;/code&gt; branch returns &lt;code&gt;null&lt;/code&gt; as its message. The action goes back to Node 24, and the error you see is its own code’s error, the same one you saw the first time. One hint that phase 3 is live: with the current runner code, the “If you need to temporarily use Node 20…” line disappears from the log, because it is only printed while Node 24 is the default but not yet mandatory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The action uses something Node 24 removed.&lt;/strong&gt; Per the &lt;a href=&quot;https://nodejs.org/docs/latest-v24.x/api/deprecations.html&quot;&gt;Node 24 deprecations table&lt;/a&gt; and the &lt;a href=&quot;https://nodejs.org/en/blog/release/v24.0.0&quot;&gt;24.0.0 release notes&lt;/a&gt;, these reached end-of-life in that release:&lt;/p&gt;






























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;API&lt;/th&gt;&lt;th&gt;Status in Node 24&lt;/th&gt;&lt;th&gt;How it fails&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tls.createSecurePair()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Removed (DEP0064)&lt;/td&gt;&lt;td&gt;&lt;code&gt;TypeError: ... is not a function&lt;/code&gt; when called&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;fs.truncate()&lt;/code&gt; with a file descriptor&lt;/td&gt;&lt;td&gt;Removed (DEP0081)&lt;/td&gt;&lt;td&gt;Throws &lt;code&gt;ERR_INVALID_ARG_TYPE&lt;/code&gt;: it expects a path. Use &lt;code&gt;fs.ftruncate()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;dirent.path&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Removed (DEP0178)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Doesn’t throw on its own&lt;/strong&gt;: the property is gone and you read &lt;code&gt;undefined&lt;/code&gt;. Use &lt;code&gt;dirent.parentPath&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;OutgoingMessage.prototype._headers&lt;/code&gt; / &lt;code&gt;_headerNames&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Removed (DEP0066)&lt;/td&gt;&lt;td&gt;You read &lt;code&gt;undefined&lt;/code&gt;. Use &lt;code&gt;getHeaders()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;dirent.path&lt;/code&gt; is the sneakiest one on the list, because reading it throws nothing: the failure shows up later, wherever that path gets used, or never, if it’s only concatenated into a string and comes out as &lt;code&gt;undefined/file&lt;/code&gt;. If you maintain an action that walks directories, search for that property before you trust the green check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your self-hosted runner can’t run Node 24.&lt;/strong&gt; On Linux ARM32, the same &lt;code&gt;NodeUtil.cs&lt;/code&gt; has a kill switch that fails the step with:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Linux ARM32 runners are no longer supported. Please migrate to a supported platform.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before it’s flipped, the runner keeps those actions on Node 20 with a warning whose default date is the Node 20 removal date: “Linux ARM32 runners are deprecated and will no longer be supported after September 23rd, 2026.” GitHub hasn’t published the exact day the switch flips; the changelog only says ARM32 loses support after the Node 20 deprecation. On macOS, &lt;a href=&quot;https://github.com/nodejs/node/blob/v24.x/BUILDING.md&quot;&gt;Node 24’s &lt;code&gt;BUILDING.md&lt;/code&gt;&lt;/a&gt; requires macOS 13.5 or later.&lt;/p&gt;
&lt;h2 id=&quot;2-the-audit-what-to-look-for-and-where&quot;&gt;2. The audit: what to look for and where&lt;/h2&gt;
&lt;h3 id=&quot;in-one-repository&quot;&gt;In one repository&lt;/h3&gt;
&lt;p&gt;Run this from the root:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# 1. The switch that stops working on 23 September&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rn&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&amp;#39;&lt;/span&gt; .github/

&lt;span class=&quot;token comment&quot;&gt;# 2. Image labels with a date. No &amp;quot;runs-on:&amp;quot; on purpose,&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;#    so matrices show up too (os: [ubuntu-22.04, ...])&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;ubuntu-22\.04|macos-14|windows-11-arm&amp;#39;&lt;/span&gt; .github/

&lt;span class=&quot;token comment&quot;&gt;# 3. Your own or local actions that declare an old Node&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rnE&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;action.yml &lt;span class=&quot;token parameter variable&quot;&gt;--include&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;action.yaml &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token string&quot;&gt;&amp;quot;using:[[:space:]]*[&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;]?node(12|16|20)&amp;quot;&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Search 2 has a blind spot: &lt;code&gt;runs-on: ${{ vars.RUNNER }}&lt;/code&gt; or a label passed as an input to a reusable workflow won’t show up in any &lt;code&gt;grep&lt;/code&gt;. For those, the log is the source of truth. The “Set up job” step prints the real image:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;gh run view &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;run-id&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--log&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-m1&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;Image:&amp;#39;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# Image: ubuntu-24.04&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the matrix is long or nested, converting it to JSON and filtering it with &lt;code&gt;jq&lt;/code&gt; is usually faster than reading the YAML by eye:&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #f43f5e&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#f43f5e1a;color:#f43f5e&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; JSON and YAML Converter &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/json-yaml-converter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;conversor-json-yaml&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;p&gt;To find out which runtime each third-party action you use declares, including SHA-pinned ones, you have to read its &lt;code&gt;action.yml&lt;/code&gt; at that exact ref. This script does it with &lt;code&gt;gh&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rhoE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;uses:[[:space:]]*[&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;]?[^[:space:]&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;#]+@[^[:space:]&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;#]+&amp;quot;&lt;/span&gt; .github/ &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;sed&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-E&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;s/uses:[[:space:]]*[&amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;]?//&amp;quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;sort&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-u&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;read&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-r&lt;/span&gt; ref&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;do&lt;/span&gt;
      &lt;span class=&quot;token assign-left variable&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;${ref&lt;span class=&quot;token operator&quot;&gt;%&lt;/span&gt;@*}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;ver&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;${ref&lt;span class=&quot;token operator&quot;&gt;#&lt;/span&gt;*@}&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$spec&lt;/span&gt;&amp;quot;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; ./*&lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt;docker://*&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;continue&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;esac&lt;/span&gt;
      &lt;span class=&quot;token assign-left variable&quot;&gt;repo&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;cut&lt;/span&gt; -d/ -f1-2 &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$spec&lt;/span&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;sub&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;cut&lt;/span&gt; -d/ -f3- &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$spec&lt;/span&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token for-or-select variable&quot;&gt;f&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; action.yml action.yaml&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;do&lt;/span&gt;
        &lt;span class=&quot;token assign-left variable&quot;&gt;rt&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;gh api &lt;span class=&quot;token string&quot;&gt;&amp;quot;repos/&lt;span class=&quot;token variable&quot;&gt;$repo&lt;/span&gt;/contents/&lt;span class=&quot;token variable&quot;&gt;${sub&lt;span class=&quot;token operator&quot;&gt;:+&lt;/span&gt;$sub&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;}&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$f&lt;/span&gt;?ref=&lt;span class=&quot;token variable&quot;&gt;$ver&lt;/span&gt;&amp;quot;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--jq&lt;/span&gt; .content &lt;span class=&quot;token operator&quot;&gt;&lt;span class=&quot;token file-descriptor important&quot;&gt;2&lt;/span&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
             &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; base64 &lt;span class=&quot;token parameter variable&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-E&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;^[[:space:]]*using:&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;tr&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot; &amp;#39;&lt;span class=&quot;token entity&quot; title=&quot;\&amp;quot;&quot;&gt;\&amp;quot;&lt;/span&gt;&amp;quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;cut&lt;/span&gt; -d: &lt;span class=&quot;token parameter variable&quot;&gt;-f2&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$rt&lt;/span&gt;&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&lt;span class=&quot;token variable&quot;&gt;$rt&lt;/span&gt;  &lt;span class=&quot;token variable&quot;&gt;$ref&lt;/span&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token builtin class-name&quot;&gt;break&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;done&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On this repo it prints:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;node20  actions/checkout@v4
node20  actions/setup-node@v4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It handles subpaths (&lt;code&gt;github/codeql-action/init@v3&lt;/code&gt;) and SHAs. A SHA pointing at a &lt;code&gt;node20&lt;/code&gt; release &lt;strong&gt;doesn’t fail on the 23rd&lt;/strong&gt;, for the same reason &lt;code&gt;@v4&lt;/code&gt; doesn’t: the runner forces it onto Node 24. What’s different with a SHA is how it gets updated: nothing moves on its own, so Dependabot or a human has to bump it.&lt;/p&gt;
&lt;p&gt;One warning before bumping a major: read the release notes. &lt;code&gt;actions/setup-node&lt;/code&gt; v5 &lt;a href=&quot;https://github.com/actions/setup-node/releases/tag/v5.0.0&quot;&gt;turned on automatic caching&lt;/a&gt; when &lt;code&gt;package.json&lt;/code&gt; has a &lt;code&gt;packageManager&lt;/code&gt; field, and v6 &lt;a href=&quot;https://github.com/actions/setup-node/releases/tag/v6.0.0&quot;&gt;limited it to npm&lt;/a&gt;. If you already pass &lt;code&gt;cache: npm&lt;/code&gt; explicitly, as my CI does, it doesn’t affect you.&lt;/p&gt;
&lt;h3 id=&quot;across-an-organization&quot;&gt;Across an organization&lt;/h3&gt;
&lt;p&gt;GitHub code search reaches every repo at once:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;gh search code &lt;span class=&quot;token parameter variable&quot;&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class=&quot;token string&quot;&gt;&amp;#39;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&amp;#39;&lt;/span&gt;
gh search code &lt;span class=&quot;token parameter variable&quot;&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class=&quot;token string&quot;&gt;&amp;#39;ubuntu-22.04 path:.github/workflows&amp;#39;&lt;/span&gt;
gh search code &lt;span class=&quot;token parameter variable&quot;&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class=&quot;token string&quot;&gt;&amp;#39;macos-14 path:.github/workflows&amp;#39;&lt;/span&gt;
gh search code &lt;span class=&quot;token parameter variable&quot;&gt;--owner&lt;/span&gt; YOUR_ORG node20 &lt;span class=&quot;token parameter variable&quot;&gt;--filename&lt;/span&gt; action.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two limits of the &lt;a href=&quot;https://docs.github.com/en/rest/search/search#search-code&quot;&gt;code search API&lt;/a&gt; that will bite you: &lt;strong&gt;it only searches the default branch&lt;/strong&gt; (a workflow on a release branch won’t show up) and it allows &lt;strong&gt;10 requests per minute&lt;/strong&gt;. While preparing this post I hit &lt;code&gt;HTTP 403: API rate limit exceeded&lt;/code&gt; as soon as I chained a handful of searches; add a &lt;code&gt;sleep&lt;/code&gt; between them if you automate it.&lt;/p&gt;
&lt;h2 id=&quot;3-self-hosted-runners-two-dates-in-three-days&quot;&gt;3. Self-hosted runners: two dates in three days&lt;/h2&gt;
&lt;p&gt;If you only use GitHub-hosted runners, skip this section. If you run your own, 25 September matters more than the 23rd.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners&quot;&gt;12 June changelog post&lt;/a&gt; resumes enforcement of two rules on github.com:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;To register&lt;/strong&gt; (or re-register) a runner you need version &lt;strong&gt;2.329.0&lt;/strong&gt; or later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;To keep running jobs&lt;/strong&gt;, the runner must install each new release &lt;strong&gt;within 30 days&lt;/strong&gt; of its publication. “A runner pinned to 2.329.0 that never updates again will not pick up jobs.”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Full enforcement begins on &lt;strong&gt;25 September 2026&lt;/strong&gt; (the notice’s table labels that date as GitHub Enterprise Cloud, and the same notice states the change applies to github.com), with brownouts first: on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern, out-of-date runners can neither register nor run jobs. What you see, per the notice, is that “workflows targeting unsupported runners may remain queued or fail.”&lt;/p&gt;
&lt;p&gt;The way to find out when your version expires is an endpoint GitHub &lt;a href=&quot;https://github.blog/changelog/2026-09-03-github-actions-early-september-2026-updates&quot;&gt;added on 3 September&lt;/a&gt;. I queried it today:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;gh api repos/OWNER/REPO/actions/runners/deprecations/2.335.1
&lt;span class=&quot;token comment&quot;&gt;# {&amp;quot;runner_version&amp;quot;:&amp;quot;2.335.1&amp;quot;,&amp;quot;runtime_deprecates_at&amp;quot;:&amp;quot;2026-09-24T15:30:55Z&amp;quot;}&lt;/span&gt;

gh api repos/OWNER/REPO/actions/runners/deprecations/2.337.0
&lt;span class=&quot;token comment&quot;&gt;# {&amp;quot;runner_version&amp;quot;:&amp;quot;2.337.0&amp;quot;,&amp;quot;runtime_deprecates_at&amp;quot;:null}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A runner on 2.335.1 falls out of support on 24 September, so by the time full enforcement starts on the 25th it no longer gets jobs. You get the installed version on the machine itself with &lt;code&gt;./config.sh --version&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The self-hosted checklist, in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Update the runner&lt;/strong&gt; to the current release and leave auto-update on. If it’s off, you need a manual cadence shorter than 30 days.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rebuild the images, VM templates and containers&lt;/strong&gt; you create runners from. The changelog asks you to recreate runners built from older cached images or templates: if the image ships a runner older than 2.329.0, it won’t register.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux ARM32:&lt;/strong&gt; move to arm64. Node 24 has no official support there, the runner’s &lt;code&gt;linux-arm&lt;/code&gt; package only bundles the Node 20 binary (&lt;a href=&quot;https://github.com/actions/runner/blob/main/src/Misc/externals.sh&quot;&gt;&lt;code&gt;externals.sh&lt;/code&gt;&lt;/a&gt;), and the code that fails the step is already in the runner.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS 13.4 or earlier:&lt;/strong&gt; upgrade the OS. GitHub lists Node 24 as incompatible with those versions.&lt;/li&gt;
&lt;li&gt;Actions that already declare &lt;code&gt;node24&lt;/code&gt; (such as &lt;code&gt;actions/checkout@v5&lt;/code&gt;) require runner &lt;a href=&quot;https://github.com/actions/checkout/releases/tag/v5.0.0&quot;&gt;v2.327.1&lt;/a&gt; at minimum. If you’ve done step 1, you already have it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;4-retention-1-october-history-gets-deleted&quot;&gt;4. Retention, 1 October: history gets deleted&lt;/h2&gt;
&lt;p&gt;This change breaks no build, but it deletes data. According to the &lt;a href=&quot;https://github.blog/changelog/2026-08-27-actions-retention-will-cover-checks-workflow-runs-and-statuses&quot;&gt;27 August changelog post&lt;/a&gt;, starting &lt;strong&gt;1 October 2026&lt;/strong&gt; checks, workflow runs and statuses follow the same retention setting that already governed artifacts and logs, &lt;strong&gt;90 days by default&lt;/strong&gt;. Until now they were kept for “400+ days” regardless of your configuration.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For public repositories the maximum is &lt;strong&gt;90 days&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The change &lt;strong&gt;is not retroactive&lt;/strong&gt;: raising retention later won’t restore anything.&lt;/li&gt;
&lt;li&gt;Check and workflow run metadata isn’t billed as storage; the associated artifacts and logs are.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What breaks is anything that reads that history beyond your period: deployment metrics computed from &lt;code&gt;gh run list&lt;/code&gt;, audits that link to a specific run, quarterly reports. Export what you need before 1 October.&lt;/p&gt;
&lt;p&gt;What the changelog does &lt;strong&gt;not&lt;/strong&gt; say: what happens to a pull request open for more than 90 days whose required check expires. I couldn’t find it documented. If you have long-lived PRs under branch protection, look at them after 1 October before assuming anything.&lt;/p&gt;
&lt;h2 id=&quot;5-images-macos-14-has-a-date-ubuntu-2204-doesnt-yet&quot;&gt;5. Images: macos-14 has a date, ubuntu-22.04 doesn’t yet&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;macos-14&lt;/code&gt;&lt;/strong&gt; (&lt;a href=&quot;https://github.com/actions/runner-images/issues/13518&quot;&gt;actions/runner-images#13518&lt;/a&gt;): deprecation started on 6 July and support ends on &lt;strong&gt;2 November 2026&lt;/strong&gt;. Before that there are eight brownouts during which jobs fail: 5, 12, 16, 19, 23, 26, 29 and 30 October, each from 14:00 UTC to 00:00 UTC the next day. It covers &lt;code&gt;macos-14&lt;/code&gt;, &lt;code&gt;macos-14-large&lt;/code&gt; and &lt;code&gt;macos-14-xlarge&lt;/code&gt;. The replacement is &lt;code&gt;macos-15&lt;/code&gt;, &lt;code&gt;macos-26&lt;/code&gt; or &lt;code&gt;macos-latest&lt;/code&gt;, which &lt;a href=&quot;https://github.blog/changelog/2026-05-14-github-actions-upcoming-image-migrations&quot;&gt;started pointing to macOS 26&lt;/a&gt; with the migration that began on 15 June.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ubuntu-22.04&lt;/code&gt;&lt;/strong&gt; (&lt;a href=&quot;https://github.com/actions/runner-images/issues/14254&quot;&gt;actions/runner-images#14254&lt;/a&gt;): on 17 September &lt;strong&gt;deprecation only begins&lt;/strong&gt;. The issue warns about longer queue times at peak hours, not failures. Retirement is on &lt;strong&gt;17 April 2027&lt;/strong&gt;, with brownouts that the issue schedules for March and April (no year given, but they fall before the retirement). It also covers &lt;code&gt;ubuntu-22.04-arm&lt;/code&gt;. The replacement is &lt;code&gt;ubuntu-24.04&lt;/code&gt;, &lt;code&gt;ubuntu-26.04&lt;/code&gt; or &lt;code&gt;ubuntu-latest&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If someone tells you &lt;code&gt;ubuntu-22.04&lt;/code&gt; breaks this week, it doesn’t. Migrate calmly, but don’t leave it for March.&lt;/p&gt;
&lt;h2 id=&quot;whats-in-the-changelog-and-breaks-nothing&quot;&gt;What’s in the changelog and breaks nothing&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;cache-mode&lt;/code&gt;&lt;/strong&gt; (&lt;a href=&quot;https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode&quot;&gt;10 September&lt;/a&gt;): a new key to limit cache access per workflow or job. It’s opt-in: workflows that don’t set it “continue to use the existing secure defaults.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The separate Code Quality path&lt;/strong&gt; (&lt;a href=&quot;https://github.blog/changelog/2026-08-20-separate-github-actions-path-for-github-code-quality&quot;&gt;20 August&lt;/a&gt;): only affects you if you have reports filtering on &lt;code&gt;dynamic/github-code-scanning/codeql&lt;/code&gt; or on the &lt;code&gt;github-advanced-security&lt;/code&gt; actor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;actions/checkout&lt;/code&gt; and &lt;code&gt;pull_request_target&lt;/code&gt;&lt;/strong&gt; (&lt;a href=&quot;https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout&quot;&gt;18 June&lt;/a&gt;): this one does break, but it already happened. Since 20 July the floating tags, &lt;code&gt;@v4&lt;/code&gt; included, refuse to check out fork code in &lt;code&gt;pull_request_target&lt;/code&gt;. If your workflow depended on that, it’s already red. It’s the same family of risk I covered in &lt;a href=&quot;https://ortamarco.me/en/blog/npm-supply-chain-trusted-publishing-not-enough/&quot;&gt;the worm that got in through npm trusted publishing&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-im-doing-with-my-ci&quot;&gt;What I’m doing with my CI&lt;/h2&gt;
&lt;p&gt;Nothing urgent. &lt;code&gt;actions/checkout@v4&lt;/code&gt; and &lt;code&gt;actions/setup-node@v4&lt;/code&gt; have been running on Node 24 since June, the workflow doesn’t use the opt-out variable, it runs on &lt;code&gt;ubuntu-latest&lt;/code&gt; (currently &lt;code&gt;ubuntu-24.04&lt;/code&gt;) and on GitHub-hosted runners. 23 September changes nothing for it.&lt;/p&gt;
&lt;p&gt;I am going to bump both actions to their current major, for two reasons unrelated to the date: a permanent yellow annotation trains you to ignore annotations, and an action its authors no longer test on the runtime it actually runs on is debt. If you’re also thinking about your own project’s Node rather than the actions’, I covered that in &lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/&quot;&gt;what breaks when upgrading to Node 26&lt;/a&gt;.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What happens to GitHub Actions on 23 September 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; GitHub removes Node 20 from the runners. Since 16 June 2026 runners had already been running actions on Node 24 by default, and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true let you go back to Node 20. From 23 September that variable stops working and every JavaScript action runs on Node 24. Self-hosted runners on Linux ARM32 lose support, because Node 24 has no official support for that platform. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does an action that declares using: node20 fail after 23 September? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not for declaring it. The runner code (HandlerFactory.cs and NodeUtil.cs in actions/runner) rewrites node12 and node16 to node20, and node20 to node24; in the final phase it ignores the environment variables and always picks Node 24, without returning an error. The action fails if its code uses something Node 24 removed, such as tls.createSecurePair or fs.truncate with a file descriptor, or if it runs on a self-hosted Linux ARM32 runner or on macOS 13.4 or earlier. The logs show the annotation: The following actions target Node.js 20 but are being forced to run on Node.js 24. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Do I have to update actions/checkout@v4 and actions/setup-node@v4? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It&amp;#39;s a good idea, but no date forces it. The v4 tags of both declare node20 and v5 onward declare node24. On GitHub-hosted runners, the v4 tags have already been running on Node 24 since June and keep working. When bumping setup-node, read the release notes: v5 turned on automatic caching when package.json has packageManager, and v6 limited it to npm. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I find affected workflows across my organization? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; With GitHub code search: gh search code --owner YOUR_ORG with ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, with ubuntu-22.04 path:.github/workflows, with macos-14 path:.github/workflows, and with node20 --filename action.yml for your own actions. The API only searches the default branch and allows 10 requests per minute. Labels that come from variables or expressions don&amp;#39;t show up in any search: for those, check the Image: line of the Set up job step in the log. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does ubuntu-22.04 break on 17 September 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. On 17 September deprecation only begins, and the notice talks about longer queue times at peak hours. Jobs start failing during the brownouts that issue 14254 in actions/runner-images schedules for March and April, before the image is retired on 17 April 2027. The one with a date this fall is macos-14: brownouts in October and retirement on 2 November 2026. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What do self-hosted runners need to do before 25 September? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Update the runner. From 25 September 2026 GitHub requires version 2.329.0 to register a runner, and each new release must be installed within 30 days to keep receiving jobs. There are brownouts first on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern. The endpoint GET /repos/OWNER/REPO/actions/runners/deprecations/VERSION returns when your version stops running jobs. Also rebuild the images you create runners from. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What changes with GitHub Actions retention on 1 October 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Checks, workflow runs and statuses start following the artifact and log retention setting, which defaults to 90 days; before, they were kept for more than 400 days. For public repositories the maximum is 90 days, and the change is not retroactive. If you have metrics or audits that read old runs, export the data before that date. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>GitHub Actions</category><category>CI/CD</category><category>DevOps</category><category>Node.js</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>WhatsApp API, October 1: What Changes in Your Code When Meta Starts Billing Service Messages</title><link>https://ortamarco.me/en/blog/whatsapp-api-october-1-billing-webhook/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/whatsapp-api-october-1-billing-webhook/</guid><description>From Oct 1, Meta bills service messages past 1,000 per number and utility inside the window. The webhook pricing object, the free tier and payment method.</description><pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;On October 1, 2026 the WhatsApp status webhook does not change shape. It changes meaning. The same &lt;code&gt;pricing&lt;/code&gt; object that arrives today with &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;free_customer_service&amp;quot;&lt;/code&gt; starts arriving with &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;regular&amp;quot;&lt;/code&gt; in two cases that stay free through September 30: service messages from the 1,001st of the month on each business phone number, and utility templates you send inside the 24-hour customer service window. If your code hardcodes the service rate at zero, decides with &lt;code&gt;billable&lt;/code&gt;, or drops statuses whose category it does not recognize, your cost report will say you spent nothing while Meta bills you.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is plenty of coverage of the new prices. This post is about the other half: which fields to read, which values each one takes according to Meta’s documentation, how to count the free tier without inventing the rule, and what to check before September 30. The figures come from Meta’s Mexico rate card, in both of the currencies it publishes that row in: MXN and USD.&lt;/p&gt;
&lt;h2 id=&quot;what-is-billed-before-and-after-october-1&quot;&gt;What is billed before and after October 1&lt;/h2&gt;
&lt;p&gt;Per &lt;strong&gt;delivered&lt;/strong&gt; message to a +52 (Mexico) phone number. The numbers come from the official rate cards “effective July 1, 2026” and “effective October 1, 2026”, which I downloaded on September 13 from &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing&quot;&gt;Meta’s pricing page&lt;/a&gt; (&lt;code&gt;Mexico&lt;/code&gt; row). Meta publishes a separate card per billing currency; I quote the MXN and USD cards as published and do not convert between them. Use the card that matches your WABA’s currency, and the row that matches your recipients’ country.&lt;/p&gt;





















































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Message&lt;/th&gt;&lt;th&gt;Through Sep 30 (MXN / USD)&lt;/th&gt;&lt;th&gt;From Oct 1 (MXN / USD)&lt;/th&gt;&lt;th&gt;&lt;code&gt;pricing.type&lt;/code&gt; from Oct 1&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Marketing template&lt;/td&gt;&lt;td&gt;0.5614 / 0.0305&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.7298 / 0.0397&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;regular&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility template &lt;strong&gt;outside&lt;/strong&gt; the window&lt;/td&gt;&lt;td&gt;0.1565 / 0.0085&lt;/td&gt;&lt;td&gt;0.1565 / 0.0085&lt;/td&gt;&lt;td&gt;&lt;code&gt;regular&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility template &lt;strong&gt;inside&lt;/strong&gt; the 24h window&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.1565 / 0.0085, from the first one&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;regular&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Authentication template&lt;/td&gt;&lt;td&gt;0.1565 / 0.0085&lt;/td&gt;&lt;td&gt;0.1565 / 0.0085&lt;/td&gt;&lt;td&gt;&lt;code&gt;regular&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Service (non-template), 1st to 1,000th of the month per number&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;&lt;code&gt;free_customer_service&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Service (non-template), 1,001st onward&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.1565 / 0.0085&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;regular&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Anything inside the 72h free entry point window&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;free&lt;/td&gt;&lt;td&gt;&lt;code&gt;free_entry_point&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three details that change how you write the code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The switch happens at 12am in your WABA’s timezone&lt;/strong&gt;, not UTC. The pricing page says so for the whole October package: “Rate updates below apply as of 12am by WhatsApp Business Account (WABA) timezone”.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 1,000 free tier covers service messages only.&lt;/strong&gt; Meta describes it as “a free monthly tier of 1,000 service messages per business phone number”, with no roll-over. It announces no free tier for utility: a utility template inside the window is billed from the first one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mexico’s marketing rate rises about 30%&lt;/strong&gt; (0.5614 → 0.7298 MXN; 0.0305 → 0.0397 USD). It is the only rate change in that row. Volume tiers will not help a small business either: in the October tiers card, Mexico utility is charged at list rate from message 0 to 1,000,000 a month, and service messages have no tiers at all per the &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages&quot;&gt;non-template messages page&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-real-payload-and-the-fields-that-matter&quot;&gt;The real payload, and the fields that matter&lt;/h2&gt;
&lt;p&gt;This is a service message delivered after the monthly free tier is used up. The envelope follows the &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status&quot;&gt;status messages webhook reference&lt;/a&gt;, and the &lt;code&gt;pricing&lt;/code&gt; object is verbatim the one Meta publishes for “Paid service message”. IDs and phone numbers are the reference’s own example values; the &lt;code&gt;timestamp&lt;/code&gt; is October 14, 2026.&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; data-language=&quot;json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;object&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;whatsapp_business_account&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;entry&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;102290129340398&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&amp;quot;changes&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;token property&quot;&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;token property&quot;&gt;&amp;quot;messaging_product&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;whatsapp&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;token property&quot;&gt;&amp;quot;metadata&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
              &lt;span class=&quot;token property&quot;&gt;&amp;quot;display_phone_number&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;15550783881&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
              &lt;span class=&quot;token property&quot;&gt;&amp;quot;phone_number_id&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;106540352242922&amp;quot;&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;token property&quot;&gt;&amp;quot;statuses&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
              &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;token property&quot;&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMAA=&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;token property&quot;&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;delivered&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;token property&quot;&gt;&amp;quot;timestamp&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;1792002600&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;token property&quot;&gt;&amp;quot;recipient_id&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;16505551234&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;token property&quot;&gt;&amp;quot;pricing&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
                  &lt;span class=&quot;token property&quot;&gt;&amp;quot;billable&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                  &lt;span class=&quot;token property&quot;&gt;&amp;quot;pricing_model&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;PMP&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                  &lt;span class=&quot;token property&quot;&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;regular&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                  &lt;span class=&quot;token property&quot;&gt;&amp;quot;category&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;service&amp;quot;&lt;/span&gt;
                &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
              &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
          &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
          &lt;span class=&quot;token property&quot;&gt;&amp;quot;field&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;messages&amp;quot;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same message in September, or inside the free tier, carries &lt;code&gt;&amp;quot;billable&amp;quot;: false&lt;/code&gt; and &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;free_customer_service&amp;quot;&lt;/code&gt;. A utility template inside the window flips the same way: &lt;code&gt;free_customer_service&lt;/code&gt; through September 30, &lt;code&gt;regular&lt;/code&gt; from October 1, with &lt;code&gt;&amp;quot;category&amp;quot;: &amp;quot;utility&amp;quot;&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;pricingtype-the-one-that-decides&quot;&gt;&lt;code&gt;pricing.type&lt;/code&gt;: the one that decides&lt;/h3&gt;
&lt;p&gt;This field tells you &lt;strong&gt;why&lt;/strong&gt; a message was or was not billed. The status reference documents three values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;regular&lt;/code&gt;: billed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;free_customer_service&lt;/code&gt;: free because it went out inside the customer service window. From October, that includes being inside the 1,000 free tier.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;free_entry_point&lt;/code&gt;: free because of the 72-hour window opened by Click-to-WhatsApp ads and Facebook Page call-to-action buttons.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pricing page adds a fourth that the reference (last updated May 21) does not list yet: &lt;code&gt;free_group_customer_service&lt;/code&gt;, for group messages.&lt;/p&gt;
&lt;h3 id=&quot;pricingcategory-which-rate-was-applied&quot;&gt;&lt;code&gt;pricing.category&lt;/code&gt;: which rate was applied&lt;/h3&gt;
&lt;p&gt;Reference values: &lt;code&gt;authentication&lt;/code&gt;, &lt;code&gt;authentication-international&lt;/code&gt;, &lt;code&gt;marketing&lt;/code&gt;, &lt;code&gt;marketing_lite&lt;/code&gt;, &lt;code&gt;referral_conversion&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt; and &lt;code&gt;utility&lt;/code&gt;. Watch &lt;code&gt;authentication-international&lt;/code&gt;: in &lt;code&gt;pricing.category&lt;/code&gt; it uses a &lt;strong&gt;hyphen&lt;/strong&gt;, while the conversation category in &lt;code&gt;conversation.origin.type&lt;/code&gt; uses an underscore. The pricing page adds &lt;code&gt;group_service&lt;/code&gt; and &lt;code&gt;group_utility&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The practical consequence: &lt;strong&gt;do not validate these fields against a closed list.&lt;/strong&gt; If your parser drops a status because the category is not in your enum, that message ends up unpriced in your database while Meta still bills it.&lt;/p&gt;
&lt;h3 id=&quot;pricingbillable-do-not-decide-with-it&quot;&gt;&lt;code&gt;pricing.billable&lt;/code&gt;: do not decide with it&lt;/h3&gt;
&lt;p&gt;The reference is explicit: “The billable property will be deprecated in a future versioned release. Use pricing.type and pricing.category together to determine whether a message is billable and, if so, its billing rate.” Today &lt;code&gt;billable&lt;/code&gt; and &lt;code&gt;type&lt;/code&gt; agree. The day Meta removes it in a new Graph API version, an &lt;code&gt;if (billable === false) return 0&lt;/code&gt; turns into “everything is billed” or “nothing is billed”, depending on how you treat &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;when-the-object-arrives-and-when-it-counts&quot;&gt;When the object arrives, and when it counts&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Per the reference syntax, &lt;code&gt;pricing&lt;/code&gt; is “only included with sent status, and one of either delivered or read status”. The v24.0 example adds that it can show up only on &lt;code&gt;delivered&lt;/code&gt; and not on &lt;code&gt;sent&lt;/code&gt;. So you get it &lt;strong&gt;twice or once&lt;/strong&gt;, and you must deduplicate by &lt;code&gt;wamid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Meta bills what is &lt;strong&gt;delivered&lt;/strong&gt;, not what is sent (“only when the message is delivered (vs. sent)”). And &lt;code&gt;delivered&lt;/code&gt; may never arrive: if the user has the chat open, Meta sends &lt;code&gt;read&lt;/code&gt; directly. Count a message as delivered when either &lt;code&gt;delivered&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; &lt;code&gt;read&lt;/code&gt; shows up.&lt;/li&gt;
&lt;li&gt;Since v24.0 the &lt;code&gt;conversation&lt;/code&gt; object is omitted unless the message is inside a free entry point window. If your code took the category from &lt;code&gt;conversation.origin.type&lt;/code&gt;, it was already behind.&lt;/li&gt;
&lt;/ul&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; JSON to TypeScript &amp;amp; Code Converter &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Convert any JSON into TypeScript interfaces, Zod schemas, PHP classes, Python dataclasses or Go structs. Detects nested and optional types. All in your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/json-to-typescript/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;json-a-typescript&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;the-four-bugs-that-make-your-report-say-zero&quot;&gt;The four bugs that make your report say zero&lt;/h2&gt;
&lt;p&gt;While reviewing the WhatsApp agent I have in development for this post, I found two of these in my own code. All four are easy to ship:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A rate table with no effective date.&lt;/strong&gt; A &lt;code&gt;service: 0&lt;/code&gt; constant is correct through September 30 and wrong from October 1, even though the webhook already says &lt;code&gt;regular&lt;/code&gt;. Meta can only change pricing on the first day of a quarter (January, April, July, October), with one month’s notice for a rate card update: model the card with its effective date.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deciding with &lt;code&gt;billable&lt;/code&gt;.&lt;/strong&gt; Works today; breaks when Meta retires it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A category allowlist.&lt;/strong&gt; &lt;code&gt;group_service&lt;/code&gt;, &lt;code&gt;free_group_customer_service&lt;/code&gt; or the hyphen in &lt;code&gt;authentication-international&lt;/code&gt; end up as “no category” and add zero to your total.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Counting every status.&lt;/strong&gt; If you add to the total on every webhook that carries &lt;code&gt;pricing&lt;/code&gt;, one message counts twice (&lt;code&gt;sent&lt;/code&gt; + &lt;code&gt;delivered&lt;/code&gt;), and a message that failed after being sent counts even though Meta does not bill it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;code-counting-billable-messages-per-number-and-per-month&quot;&gt;Code: counting billable messages per number and per month&lt;/h2&gt;
&lt;p&gt;The approach mirrors the cost ledger in my agent: one row per outbound message, filled in by whatever each status carries, with cost computed at read time. It targets Cloudflare D1 (SQLite), but the SQL is portable.&lt;/p&gt;
&lt;p&gt;The table first. &lt;code&gt;category&lt;/code&gt; and &lt;code&gt;pricing_type&lt;/code&gt; are stored &lt;strong&gt;exactly as received&lt;/strong&gt;, with no allowlist:&lt;/p&gt;
&lt;pre class=&quot;language-sql&quot; data-language=&quot;sql&quot;&gt;&lt;code class=&quot;language-sql&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;TABLE&lt;/span&gt; wa_outbound &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
  wamid           &lt;span class=&quot;token keyword&quot;&gt;TEXT&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  phone_number_id &lt;span class=&quot;token keyword&quot;&gt;TEXT&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;month&lt;/span&gt;           &lt;span class=&quot;token keyword&quot;&gt;TEXT&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;              &lt;span class=&quot;token comment&quot;&gt;-- &amp;#39;YYYY-MM&amp;#39; in the WABA timezone&lt;/span&gt;
  delivered       &lt;span class=&quot;token keyword&quot;&gt;INTEGER&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;NULL&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;DEFAULT&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;-- 1 once `delivered` or `read` arrives&lt;/span&gt;
  category        &lt;span class=&quot;token keyword&quot;&gt;TEXT&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;                       &lt;span class=&quot;token comment&quot;&gt;-- pricing.category, not normalized&lt;/span&gt;
  pricing_type    &lt;span class=&quot;token keyword&quot;&gt;TEXT&lt;/span&gt;                        &lt;span class=&quot;token comment&quot;&gt;-- pricing.type, not normalized&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;INDEX&lt;/span&gt; wa_outbound_month &lt;span class=&quot;token keyword&quot;&gt;ON&lt;/span&gt; wa_outbound &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;phone_number_id&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;month&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then what you do with each &lt;code&gt;statuses[]&lt;/code&gt; entry. &lt;code&gt;phone_number_id&lt;/code&gt; comes from &lt;code&gt;value.metadata.phone_number_id&lt;/code&gt;, which is the unit the free tier is counted on:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot; data-language=&quot;ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Open&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;T&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;T&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// autocompletes, but accepts whatever Meta adds&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;StatusPricing&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token comment&quot;&gt;/** Meta will deprecate it: decide with `type` + `category`. */&lt;/span&gt;
  billable&lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;boolean&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  pricing_model&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Open&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;PMP&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;CBP&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  type&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Open&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;regular&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;free_customer_service&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;free_entry_point&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;free_group_customer_service&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  category&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Open&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;service&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;utility&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;marketing&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;marketing_lite&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;authentication&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;authentication-international&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;referral_conversion&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;group_service&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;group_utility&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;WebhookStatus&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  id&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// wamid&lt;/span&gt;
  status&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Open&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;sent&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;delivered&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;read&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;failed&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;played&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  timestamp&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// epoch seconds, as a string&lt;/span&gt;
  recipient_id&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  pricing&lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; StatusPricing&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;/** &amp;#39;YYYY-MM&amp;#39; in your WABA timezone, e.g. &amp;#39;America/Mexico_City&amp;#39;. */&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;monthKey&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;epochSeconds&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; timeZone&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Intl&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;DateTimeFormat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;en-CA&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; timeZone&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; year&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;numeric&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; month&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;2-digit&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;epochSeconds &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;slice&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;7&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;DELIVERED&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;delivered&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;read&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// `read` without `delivered` still means delivered&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;applyStatus&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;db&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; D1Database&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; phoneNumberId&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; s&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; WebhookStatus&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; timeZone&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; db
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;prepare&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;INSERT INTO wa_outbound (wamid, phone_number_id, month, delivered, category, pricing_type)
       VALUES (?1, ?2, ?3, ?4, ?5, ?6)
       ON CONFLICT(wamid) DO UPDATE SET
         month        = CASE WHEN excluded.delivered = 1 AND delivered = 0 THEN excluded.month ELSE month END,
         delivered    = MAX(delivered, excluded.delivered),
         category     = COALESCE(excluded.category, category),
         pricing_type = COALESCE(excluded.pricing_type, pricing_type)&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;bind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
      s&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      phoneNumberId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token function&quot;&gt;monthKey&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;Number&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;s&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;timestamp&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; timeZone&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token constant&quot;&gt;DELIVERED&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;has&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;s&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;status&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      s&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;pricing&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;category &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      s&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;pricing&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;type &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ON CONFLICT&lt;/code&gt; does the dirty work: repeated statuses do not duplicate, &lt;code&gt;pricing&lt;/code&gt; is kept whether it came on &lt;code&gt;sent&lt;/code&gt; or on &lt;code&gt;delivered&lt;/code&gt;, and the month that counts is the delivery month (a message sent at 11:59pm on September 30 and delivered on October 1 lands in October).&lt;/p&gt;
&lt;p&gt;Finally, the monthly bill. The rate card carries a date, and what decides whether a message is billed is &lt;code&gt;type&lt;/code&gt;, not your counter:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot; data-language=&quot;ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;FREE_SERVICE_PER_MONTH&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;/** Mexico card in MXN per delivered message. One entry per quarter with changes. */&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;MX_RATES_MXN&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; from&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; rates&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Record&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; from&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;2026-07&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; rates&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; marketing&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.5614&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; utility&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.1565&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; authentication&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.1565&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; from&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;2026-10&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; rates&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; marketing&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.7298&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; utility&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.1565&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; authentication&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.1565&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; service&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.1565&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token function-variable function&quot;&gt;ratesFor&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;month&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;MX_RATES_MXN&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;findLast&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;card&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; card&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;from &lt;span class=&quot;token operator&quot;&gt;&amp;lt;=&lt;/span&gt; month&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;rates &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;monthlyBill&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;db&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; D1Database&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; phoneNumberId&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; month&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; results &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; db
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;prepare&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;SELECT category, pricing_type, COUNT(*) AS n FROM wa_outbound
       WHERE phone_number_id = ? AND month = ? AND delivered = 1
       GROUP BY category, pricing_type&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;bind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;phoneNumberId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; month&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token generic-function&quot;&gt;&lt;span class=&quot;token function&quot;&gt;all&lt;/span&gt;&lt;span class=&quot;token generic class-name&quot;&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; category&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; pricing_type&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; n&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; rates &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ratesFor&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;month&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; total &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; serviceDelivered &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; paidService &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; unpriced&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Record&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; category&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; pricing_type&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; n &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;of&lt;/span&gt; results&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt;category &lt;span class=&quot;token operator&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt;pricing_type&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      unpriced&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;(no pricing)&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;unpriced&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;(no pricing)&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token comment&quot;&gt;// Meta does not document whether entry point messages use up the tier: not counted here.&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;category &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;service&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; pricing_type &lt;span class=&quot;token operator&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;free_entry_point&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; serviceDelivered &lt;span class=&quot;token operator&quot;&gt;+=&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;pricing_type &lt;span class=&quot;token operator&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;regular&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// free: window, tier or entry point&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;category &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;service&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; paidService &lt;span class=&quot;token operator&quot;&gt;+=&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; rate &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; rates&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;category&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;rate &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;undefined&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      unpriced&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;category&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;unpriced&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;category&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// billed and you have no rate: do not add it as 0&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    total &lt;span class=&quot;token operator&quot;&gt;+=&lt;/span&gt; n &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; rate&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    total&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;round&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;total &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    serviceDelivered&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    freeServiceLeft&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;FREE_SERVICE_PER_MONTH&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; serviceDelivered&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    paidService&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    unpriced&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your WABA bills in USD, swap the card for the USD row (0.0305 / 0.0085 / 0.0085 from July, 0.0397 / 0.0085 / 0.0085 / 0.0085 from October). With that in place, alerting is one line in a daily cron: fire when &lt;code&gt;freeServiceLeft&lt;/code&gt; drops below 200, when the month’s first &lt;code&gt;paidService&lt;/code&gt; appears, or when &lt;code&gt;unpriced&lt;/code&gt; is not empty. And if &lt;code&gt;paidService&lt;/code&gt; is above zero while &lt;code&gt;serviceDelivered&lt;/code&gt; is under 1,000, &lt;strong&gt;your counter and Meta disagree&lt;/strong&gt;: another app sending from the same number, lost webhooks, or a month boundary in a different timezone. Meta wins.&lt;/p&gt;
&lt;p&gt;To reconcile at month end, the WABA’s &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/analytics&quot;&gt;&lt;code&gt;pricing_analytics&lt;/code&gt;&lt;/a&gt; field accepts the &lt;code&gt;PHONE&lt;/code&gt;, &lt;code&gt;PRICING_CATEGORY&lt;/code&gt; and &lt;code&gt;PRICING_TYPE&lt;/code&gt; dimensions. Two caveats from the same page: the data is approximate (“may differ from what’s shown on invoices”), and &lt;code&gt;COST&lt;/code&gt; is not returned if your WABA shares a Solution Partner’s credit line. Also, that reference (last updated June 11) still describes &lt;code&gt;SERVICE&lt;/code&gt; as “Messages that were not charged”; the non-template messages page already shows the query with &lt;code&gt;REGULAR&lt;/code&gt; + &lt;code&gt;SERVICE&lt;/code&gt;.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Unix Timestamp Converter &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Convert Unix timestamps to a readable date (ISO, UTC, local and relative time) and dates back to a timestamp, with seconds/milliseconds detection and a live clock. No sign-up. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/unix-timestamp-converter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;conversor-timestamp&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;what-it-costs-a-small-business-with-600-conversations-a-month&quot;&gt;What it costs: a small business with 600 conversations a month&lt;/h2&gt;
&lt;p&gt;A clinic or a repair shop with an agent answering WhatsApp, one phone number, customers on +52, and no Click-to-WhatsApp ads. Per month:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;600 conversations with &lt;strong&gt;5 service messages&lt;/strong&gt; from the agent each: 3,000 service messages.&lt;/li&gt;
&lt;li&gt;600 order or appointment confirmations as utility templates, sent &lt;strong&gt;inside&lt;/strong&gt; the window.&lt;/li&gt;
&lt;li&gt;400 reminders as utility templates, sent &lt;strong&gt;outside&lt;/strong&gt; the window.&lt;/li&gt;
&lt;li&gt;One marketing campaign to 500 contacts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I assume everything is delivered; in practice it comes out slightly lower, since Meta only bills delivered messages. List rates, before any taxes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;September (July rate card):&lt;/strong&gt;&lt;/p&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Line&lt;/th&gt;&lt;th&gt;Math&lt;/th&gt;&lt;th&gt;MXN&lt;/th&gt;&lt;th&gt;USD&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Service&lt;/td&gt;&lt;td&gt;3,000 × 0&lt;/td&gt;&lt;td&gt;0.00&lt;/td&gt;&lt;td&gt;0.00&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility inside the window&lt;/td&gt;&lt;td&gt;600 × 0&lt;/td&gt;&lt;td&gt;0.00&lt;/td&gt;&lt;td&gt;0.00&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility outside the window&lt;/td&gt;&lt;td&gt;400 × 0.1565 MXN / 0.0085 USD&lt;/td&gt;&lt;td&gt;62.60&lt;/td&gt;&lt;td&gt;3.40&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Marketing&lt;/td&gt;&lt;td&gt;500 × 0.5614 MXN / 0.0305 USD&lt;/td&gt;&lt;td&gt;280.70&lt;/td&gt;&lt;td&gt;15.25&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;343.30&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;18.65&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;October (October rate card):&lt;/strong&gt;&lt;/p&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Line&lt;/th&gt;&lt;th&gt;Math&lt;/th&gt;&lt;th&gt;MXN&lt;/th&gt;&lt;th&gt;USD&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Service&lt;/td&gt;&lt;td&gt;(3,000 − 1,000) = 2,000 × 0.1565 MXN / 0.0085 USD&lt;/td&gt;&lt;td&gt;313.00&lt;/td&gt;&lt;td&gt;17.00&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility inside the window&lt;/td&gt;&lt;td&gt;600 × 0.1565 MXN / 0.0085 USD&lt;/td&gt;&lt;td&gt;93.90&lt;/td&gt;&lt;td&gt;5.10&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Utility outside the window&lt;/td&gt;&lt;td&gt;400 × 0.1565 MXN / 0.0085 USD&lt;/td&gt;&lt;td&gt;62.60&lt;/td&gt;&lt;td&gt;3.40&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Marketing&lt;/td&gt;&lt;td&gt;500 × 0.7298 MXN / 0.0397 USD&lt;/td&gt;&lt;td&gt;364.90&lt;/td&gt;&lt;td&gt;19.85&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;834.40&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;45.35&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Meta’s bill goes from 343.30 to 834.40 MXN on the peso card (18.65 to 45.35 on the dollar card): &lt;strong&gt;491.10 MXN or 26.70 USD more per month&lt;/strong&gt;. Nobody goes under over that, but it is 2.4 times September, and 406.90 of those 491.10 pesos (over 80%) come from two lines that cost zero today.&lt;/p&gt;
&lt;h3 id=&quot;where-the-real-lever-is-your-code&quot;&gt;Where the real lever is: your code&lt;/h3&gt;
&lt;p&gt;Switching the in-window utility confirmations to free-form text &lt;strong&gt;saves nothing in this example&lt;/strong&gt;: in Mexico the service rate and the utility rate are the same, and the free tier is already used up. It only helps businesses that stay under 1,000 service messages a month.&lt;/p&gt;
&lt;p&gt;What actually moves the number is &lt;strong&gt;how many messages your agent sends per reply&lt;/strong&gt;. Plenty of AI agents split each answer into three or four bubbles because it reads more naturally. From October 1, every bubble past the 1,000th is a charge. If the same agent answers with 3 messages per conversation instead of 5:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;600 × 3 = 1,800 service messages.&lt;/li&gt;
&lt;li&gt;(1,800 − 1,000) = 800 × 0.1565 = &lt;strong&gt;125.20 MXN&lt;/strong&gt; (800 × 0.0085 = &lt;strong&gt;6.80 USD&lt;/strong&gt;), versus 313.00 MXN (17.00 USD).&lt;/li&gt;
&lt;li&gt;Savings: &lt;strong&gt;187.80 MXN or 10.20 USD a month&lt;/strong&gt;, without touching a single price.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If some of your customers arrive through Click-to-WhatsApp ads, replying within the first 24 hours opens the 72-hour free entry point window, and everything in it comes back as &lt;code&gt;free_entry_point&lt;/code&gt;. Meta confirms that window is unchanged for message delivery.&lt;/p&gt;
&lt;h2 id=&quot;what-happens-with-no-payment-method&quot;&gt;What happens with no payment method&lt;/h2&gt;
&lt;p&gt;Meta has two wordings here, from different dates, and they are worth reading together.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages&quot;&gt;non-template messages page&lt;/a&gt;, updated &lt;strong&gt;August 25, 2026&lt;/strong&gt;, says:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For any Solution Provider or directly-integrated businesses that does not have a payment method on file by September 30, 2026, Meta will stop delivering service messages as of when they become charged on October 1, 2026. To avoid disruptions to your service messages, please add a payment method for your WhatsApp Business Account(s) by September 30, 2026.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing&quot;&gt;pricing page&lt;/a&gt;, updated &lt;strong&gt;September 10, 2026&lt;/strong&gt;, which is where the free tier was introduced as new, is more specific:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you do not have a payment method for your WhatsApp Business account: Meta &lt;em&gt;will&lt;/em&gt; deliver your first 1,000 service messages each month but &lt;em&gt;not&lt;/em&gt; deliver as of your 1,001st.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;They do not contradict each other: the first says delivery stops “as of when they become charged”, and the second, written after the free tier was added, says that happens at the 1,001st message. In practice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The date Meta gives for having a payment method in place is September 30.&lt;/strong&gt; Do not wait for the first failure.&lt;/li&gt;
&lt;li&gt;Without a payment method, your agent works normally until the number crosses 1,000 service messages for the month, and &lt;strong&gt;then stops delivering mid-month&lt;/strong&gt;. For a busy business that can be a Tuesday afternoon.&lt;/li&gt;
&lt;li&gt;In the pages I reviewed, Meta does not publish which error code the &lt;code&gt;failed&lt;/code&gt; status carries in that case. Watch for a spike in &lt;code&gt;failed&lt;/code&gt; on service messages from October 1.&lt;/li&gt;
&lt;li&gt;The August notice is addressed to “Solution Provider or directly-integrated businesses”. If your WABA is billed through a solution provider, get written confirmation from them that the payment method is sorted before September 30.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;checklist-before-september-30&quot;&gt;Checklist before September 30&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A payment method in Billing Hub&lt;/strong&gt; for every WABA, or written confirmation from your provider if you are billed through one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A tolerant parser:&lt;/strong&gt; store &lt;code&gt;pricing.type&lt;/code&gt; and &lt;code&gt;pricing.category&lt;/code&gt; as received. Never drop a status because it carries &lt;code&gt;group_service&lt;/code&gt;, &lt;code&gt;free_group_customer_service&lt;/code&gt; or &lt;code&gt;authentication-international&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stop deciding with &lt;code&gt;billable&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;type === &amp;#39;regular&amp;#39;&lt;/code&gt; plus the rate for &lt;code&gt;category&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A rate card with effective dates&lt;/strong&gt;, loaded with the October card (Mexico marketing at 0.7298 MXN / 0.0397 USD). The next date Meta can change prices is January 1, 2027.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Count on &lt;code&gt;delivered&lt;/code&gt; or &lt;code&gt;read&lt;/code&gt;, once per &lt;code&gt;wamid&lt;/code&gt;&lt;/strong&gt;, grouped by &lt;code&gt;phone_number_id&lt;/code&gt; and by month in your WABA timezone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerts:&lt;/strong&gt; fewer than 200 free service messages left, first &lt;code&gt;regular&lt;/code&gt; service message of the month, categories with no rate, and a rise in &lt;code&gt;failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Utility templates inside the window:&lt;/strong&gt; from October 1 they are billed from the first one. If you send fewer than 1,000 service messages a month, free-form text inside the window can be free where the template costs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Marketing versus utility templates:&lt;/strong&gt; Meta bills the category a template has at the time of use, and can recategorize it. Subscribe to the &lt;a href=&quot;https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/template_category_update&quot;&gt;&lt;code&gt;template_category_update&lt;/code&gt;&lt;/a&gt; webhook, which warns you 24 hours before an automated category change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Messages per reply:&lt;/strong&gt; merge your agent’s bubbles. It is the cheapest lever in the example.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monthly reconciliation&lt;/strong&gt; against &lt;code&gt;pricing_analytics&lt;/code&gt;, knowing it is approximate.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/ai-whatsapp-agent-for-smbs-laravel-openai/&quot;&gt;Build an AI agent on WhatsApp for your small business (Laravel + OpenAI)&lt;/a&gt;: the webhook, conversation memory and the 24-hour window rules, from scratch.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What changes in the WhatsApp Business API on October 1, 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Meta starts billing two kinds of message that are free today: service messages (non-template messages sent inside the 24-hour customer service window) from the 1,001st message each month per business phone number, and utility templates sent inside that window, which are billed from the first one. In Mexico both cost 0.1565 MXN (0.0085 USD) per delivered message. Mexico marketing also rises from 0.5614 to 0.7298 MXN (0.0305 to 0.0397 USD). The change applies at 12am in your WhatsApp Business account timezone. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I know from the webhook whether a WhatsApp message was billed? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Read pricing.type in the status messages webhook: regular means billed; free_customer_service, free_entry_point or free_group_customer_service mean it was free. pricing.category tells you which rate was applied. Do not rely on pricing.billable: Meta’s reference says it will be deprecated in a future versioned release and recommends using type and category together. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Are the 1,000 free WhatsApp service messages per account or per phone number? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Per business phone number. Meta says every business phone number receives 1,000 free service messages per month, that it charges from the 1,001st service message delivered that month, and that unused messages do not roll over. The free tier only covers service messages; utility templates inside the window have no free tier. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What happens if my WhatsApp Business account has no payment method on October 1? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; According to Meta’s pricing page, updated September 10, 2026, Meta delivers your first 1,000 service messages each month and stops delivering from the 1,001st. The non-template messages page asks businesses to add a payment method in Billing Hub by September 30, 2026. If you are billed through a solution provider, confirm with them that the payment method is in place. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does the pricing object arrive on every status webhook? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. Per Meta’s reference, pricing is included with the sent status and with one of delivered or read, and in v24.0 it can appear only on delivered. Since Meta bills delivered messages and sometimes sends read without delivered, count each message once per wamid when delivered or read arrives, and keep the pricing from whichever status carried it. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much will a small business in Mexico pay Meta from October? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It depends on volume. With 600 conversations a month at 5 service messages each, 600 utility confirmations inside the window, 400 utility reminders outside it and one marketing campaign to 500 contacts, the Meta bill goes from 343.30 MXN in September to 834.40 MXN in October at list rates, or from 18.65 to 45.35 USD on the USD card. Cutting the agent from 5 to 3 messages per conversation lowers the service line from 313.00 to 125.20 MXN. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Should I replace utility templates with free-form text inside the window? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Only if you send fewer than 1,000 service messages a month. In Mexico, service and utility cost the same from October 1, but service gets 1,000 free messages per month per number and utility does not. Once the tier is used up, it makes no difference. The bigger lever is usually having your agent send fewer messages per reply. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>WhatsApp</category><category>Costs</category><category>Pricing</category><category>Webhooks</category><category>TypeScript</category><category>Backend</category><author>Marco Orta</author></item><item><title>Validate Mexican RFC, CURP and CFDI Invoices from Claude, With No API Keys</title><link>https://ortamarco.me/en/blog/validate-mexican-rfc-cfdi-claude-mcp/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/validate-mexican-rfc-cfdi-claude-mcp/</guid><description>An open-source MCP server that lets Claude validate RFC, CURP, CLABE and NSS with their real check digits, read CFDI 4.0 invoices and ask the SAT whether they are still valid. Setup, what to ask it, and the six details a regex gets wrong.</description><pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Ask an AI agent to validate a Mexican RFC and it will usually write a regex. Then it will reject &lt;code&gt;XAXX010101000&lt;/code&gt; — the RFC printed on every invoice issued to the general public — because that RFC does not satisfy its own check digit.&lt;/strong&gt; The SAT assigned it by decree, and the modulus-11 algorithm asks for a &lt;code&gt;4&lt;/code&gt; where the SAT wrote a &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That is the kind of detail an agent cannot be trusted to remember, and Mexican tax data is full of them. So I built &lt;strong&gt;&lt;a href=&quot;https://ortamarco.me/en/portfolio/mx-fiscal-mcp/&quot;&gt;mx-fiscal-mcp-server&lt;/a&gt;&lt;/strong&gt;: an open-source &lt;a href=&quot;https://modelcontextprotocol.io&quot;&gt;MCP&lt;/a&gt; server that gives Claude (or Cursor, or any MCP client) eight read-only tools to validate RFC, CURP, CLABE and NSS with their real algorithms, read CFDI 4.0 invoices, ask the SAT whether an invoice is still valid, and look up the SAT’s code tables.&lt;/p&gt;
&lt;p&gt;It needs &lt;strong&gt;no API keys, no CSD certificate and no PAC contract&lt;/strong&gt;. This post covers the setup, what you can ask it, and the six details that a hand-rolled implementation gets wrong — including one I got wrong myself before release.&lt;/p&gt;
&lt;h2 id=&quot;setup-in-one-line&quot;&gt;Setup in one line&lt;/h2&gt;
&lt;p&gt;The server is published on &lt;a href=&quot;https://www.npmjs.com/package/mx-fiscal-mcp-server&quot;&gt;npm&lt;/a&gt; and in the official MCP Registry, so there is nothing to clone. For Claude Code:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;claude mcp &lt;span class=&quot;token function&quot;&gt;add&lt;/span&gt; mx-fiscal -- npx &lt;span class=&quot;token parameter variable&quot;&gt;-y&lt;/span&gt; mx-fiscal-mcp-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Claude Desktop or Cursor, add it to &lt;code&gt;claude_desktop_config.json&lt;/code&gt; or &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; data-language=&quot;json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;mcpServers&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;mx-fiscal&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;npx&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&amp;quot;args&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;-y&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;mx-fiscal-mcp-server&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Windows, use &lt;code&gt;&amp;quot;command&amp;quot;: &amp;quot;cmd&amp;quot;&lt;/code&gt; with &lt;code&gt;&amp;quot;args&amp;quot;: [&amp;quot;/c&amp;quot;, &amp;quot;npx&amp;quot;, &amp;quot;-y&amp;quot;, &amp;quot;mx-fiscal-mcp-server&amp;quot;]&lt;/code&gt;. It needs Node.js 20 or newer.&lt;/p&gt;
&lt;p&gt;It is built on the v2 MCP SDK, so it speaks the 2026-07-28 protocol revision and still serves the 2025-era clients that Claude Desktop, Claude Code and Cursor use today, from the same process. If you maintain a server yourself, I wrote up &lt;a href=&quot;https://ortamarco.me/en/blog/migrate-mcp-server-2026-07-28/&quot;&gt;what changes when you migrate to 2026-07-28&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-eight-tools&quot;&gt;The eight tools&lt;/h2&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool&lt;/th&gt;&lt;th&gt;What it does&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validate_rfc&lt;/code&gt;&lt;/td&gt;&lt;td&gt;RFC for individuals (13 characters) and companies (12): check digit, parsed fields, birth or incorporation date, and whether it is one of the SAT generics&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validate_curp&lt;/code&gt;&lt;/td&gt;&lt;td&gt;18-character CURP: check digit, birth date, sex, state of birth and the century marker&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validate_clabe&lt;/code&gt;&lt;/td&gt;&lt;td&gt;18-digit bank account number (CLABE): control digit, bank and plaza code&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validate_nss&lt;/code&gt;&lt;/td&gt;&lt;td&gt;11-digit IMSS social security number with its Luhn digit&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;generate_test_data&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1 to 100 coherent fake people or companies: RFC and CURP derived from the same name and birth date, CLABE from a real bank&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;parse_cfdi&lt;/code&gt;&lt;/td&gt;&lt;td&gt;CFDI 4.0 XML to JSON, with every catalogue code labelled, both RFCs validated and the totals checked&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;cfdi_status&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Asks the SAT’s public status service whether the invoice exists, is cancelled or can be cancelled, and returns the result of its EFOS check (the 69-B list)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;sat_catalog_lookup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Nine SAT code tables (tax regimes, CFDI uses, payment forms and methods, and more) by code or text&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Only &lt;code&gt;cfdi_status&lt;/code&gt; touches the network. The other seven never leave the process.&lt;/p&gt;
&lt;h2 id=&quot;what-you-can-ask-it&quot;&gt;What you can ask it&lt;/h2&gt;
&lt;p&gt;A few prompts that work well, with the shape of what comes back:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;“Is &lt;code&gt;GODE561231GR8&lt;/code&gt; a valid RFC? Decode it.”&lt;/em&gt; — kind (individual or company), the date encoded in it, and a legible reason for every failure: not just &lt;code&gt;invalid&lt;/code&gt;, but &lt;em&gt;“the final check digit does not match the modulus-11 result”&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;“Generate 20 Mexican customers with valid RFC, CURP and CLABE for my seed file.”&lt;/em&gt; — every check digit holds, and the RFC and CURP of each record describe the same person.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;“Read this invoice and tell me whether it’s still valid at the SAT.”&lt;/em&gt; — Claude calls &lt;code&gt;parse_cfdi&lt;/code&gt; to label everything and check the arithmetic, then &lt;code&gt;cfdi_status&lt;/code&gt; with the four values it needs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last one is the flow that saves the most time, and it hides most of the traps below.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Mexican ID Validator — RFC, CURP, CLABE, NSS &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Check whether an RFC, CURP, CLABE or NSS is correct. Detects the type on its own, verifies the check digit and breaks down every part of the ID. All in your browser. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/mexican-id-validator/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;validador-rfc-curp-clabe&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;six-details-a-regex-gets-wrong&quot;&gt;Six details a regex gets wrong&lt;/h2&gt;
&lt;h3 id=&quot;1-the-general-public-rfc-does-not-satisfy-its-own-check-digit&quot;&gt;1. The general-public RFC does not satisfy its own check digit&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;XAXX010101000&lt;/code&gt; is the RFC for sales to the general public, and &lt;code&gt;XEXX010101000&lt;/code&gt; is for foreign residents. Run the modulus-11 algorithm on the first twelve characters of the first one and it asks for a &lt;code&gt;4&lt;/code&gt;. The second one happens to satisfy it.&lt;/p&gt;
&lt;p&gt;A validator that only runs the algorithm rejects every invoice issued to the general public — which is why so many billing forms refuse them. A validator that only checks the shape accepts typos. The server reports the two facts separately: &lt;code&gt;is_generic: true&lt;/code&gt; and &lt;code&gt;check_digit_satisfied: false&lt;/code&gt;, and says in words why that combination is fine.&lt;/p&gt;
&lt;h3 id=&quot;2-clabe-keeps-the-last-digit-of-each-product-not-the-sum-of-its-digits&quot;&gt;2. CLABE keeps the last digit of each product, not the sum of its digits&lt;/h3&gt;
&lt;p&gt;The CLABE control digit weights the first 17 digits with 3, 7, 1, 3, 7, 1… From each product it keeps &lt;strong&gt;the last digit&lt;/strong&gt; — the product modulo 10 — so a &lt;code&gt;63&lt;/code&gt; contributes &lt;code&gt;3&lt;/code&gt;. Then it sums, takes the sum modulo 10 and subtracts from 10 (a result of 10 means the digit is &lt;code&gt;0&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;An implementation that copies Luhn does something else: when a product goes above 9, it adds up its digits, so the same &lt;code&gt;63&lt;/code&gt; contributes &lt;code&gt;6 + 3 = 9&lt;/code&gt;. The two routes do not agree: for the 17 digits &lt;code&gt;09000000000000000&lt;/code&gt;, the CLABE algorithm gives &lt;code&gt;7&lt;/code&gt; and the Luhn-style version gives &lt;code&gt;1&lt;/code&gt;. That is enough to accept mistyped account numbers and reject good ones.&lt;/p&gt;
&lt;h3 id=&quot;3-the-curps-17th-character-carries-the-century&quot;&gt;3. The CURP’s 17th character carries the century&lt;/h3&gt;
&lt;p&gt;A CURP stores the birth year in two digits, so &lt;code&gt;31&lt;/code&gt; could be 1931 or 2031. RENAPO resolves it with the 17th character: &lt;strong&gt;a digit means born before 2000, a letter means born in 2000 or later.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the one I got wrong. The first version of the server read that rule backwards, and because the birth date is itself decoded through that character, it reported a “disagreement between the marker and the date” on every valid CURP it saw. The test suite had captured the wrong message as expected behaviour. An independent review caught it before publication; the canonical test vector &lt;code&gt;BOXW310820HNERXN09&lt;/code&gt; now decodes, correctly, to 20 August 1931.&lt;/p&gt;
&lt;h3 id=&quot;4-curp-state-keys-are-renapos-not-inegis&quot;&gt;4. CURP state keys are RENAPO’s, not INEGI’s&lt;/h3&gt;
&lt;p&gt;Positions 12 and 13 of a CURP encode the state of birth, with RENAPO’s own keys: &lt;code&gt;DF&lt;/code&gt; is Mexico City, &lt;code&gt;MC&lt;/code&gt; is the State of Mexico and &lt;code&gt;NE&lt;/code&gt; means born abroad. They match neither the INEGI state codes nor ISO 3166-2:MX, so a lookup against either table silently mislabels people.&lt;/p&gt;
&lt;h3 id=&quot;5-the-sat-status-query-is-picky-about-the-total&quot;&gt;5. The SAT status query is picky about the total&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cfdi_status&lt;/code&gt; sends the SAT an expression built from four values: issuer RFC, receiver RFC, total and UUID. The total goes in the format the reference implementations use (&lt;a href=&quot;https://github.com/nodecfdi/cfdi-expresiones&quot;&gt;nodecfdi&lt;/a&gt; and phpcfdi): six decimals with trailing zeros trimmed, but keeping at least one. So &lt;code&gt;1160.00&lt;/code&gt; travels as &lt;code&gt;1160.0&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot; data-language=&quot;text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;?re=TES150312DX2&amp;amp;rr=PELJ900521DK2&amp;amp;tt=1160.0&amp;amp;id=5A7B3C1D-9E2F-4A6B-8C0D-1E2F3A4B5C6D
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A total that doesn’t follow that format, such as a hand-typed &lt;code&gt;1,160.00&lt;/code&gt;, can make the SAT answer &lt;em&gt;No Encontrado&lt;/em&gt; — “not found” — for an invoice that exists. That is why the server screens the four values before spending a request, and recommends passing the whole XML so it derives them itself.&lt;/p&gt;
&lt;p&gt;Two more rules live in the same query. An RFC containing &lt;code&gt;&amp;amp;&lt;/code&gt; travels encoded as &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, following the &lt;a href=&quot;https://github.com/nodecfdi/sat-estado-cfdi&quot;&gt;nodecfdi&lt;/a&gt; reference implementation. And an empty &lt;code&gt;ValidacionEFOS&lt;/code&gt; field does &lt;strong&gt;not&lt;/strong&gt; mean the issuer is on the 69-B list of companies that invoice simulated operations. The &lt;a href=&quot;http://omawww.sat.gob.mx/tramitesyservicios/Paginas/documentos/Documentacion_WS_Consulta_CFDI_v1.4.pdf&quot;&gt;SAT’s documentation for the service&lt;/a&gt; defines the codes — 100 to 104 when the issuer or a third-party RFC on the invoice is listed (102 and 103 mean the issuer is not, only a third party is), 200 and 201 when neither is — but not an empty value, so the server reports an empty field as &lt;code&gt;unknown&lt;/code&gt;, never as “listed”. Since version 1.0.2 it keeps the two questions apart: &lt;code&gt;efos_state&lt;/code&gt; answers only for the issuer (100, 101 and 104 are &lt;code&gt;listed&lt;/code&gt;; 102, 103, 200 and 201 are &lt;code&gt;not_listed&lt;/code&gt;) and &lt;code&gt;efos_third_party_state&lt;/code&gt; for third-party RFCs, with the raw code always in &lt;code&gt;validacion_efos&lt;/code&gt;. Version 1.0.1 read 102 and 103 as “the issuer is listed”, which the SAT document contradicts. Either way, check the published 69-B list before acting on it.&lt;/p&gt;
&lt;h3 id=&quot;6-the-total-includes-local-taxes&quot;&gt;6. The total includes local taxes&lt;/h3&gt;
&lt;p&gt;The arithmetic check — subtotal minus discount plus transferred taxes minus withheld taxes — is how you spot a tampered or broken invoice. But the CFDI 4.0 schema defines the total over federal &lt;strong&gt;and local&lt;/strong&gt; taxes, and local ones live in a separate &lt;code&gt;implocal&lt;/code&gt; complement. A hotel invoice with a state lodging tax looks off by exactly that tax if you ignore the complement. The server adds it in.&lt;/p&gt;
&lt;p&gt;If you only want to open an invoice by hand, with no agent involved, the &lt;a href=&quot;https://www.siemprecontable.net/herramientas/visor-cfdi&quot;&gt;CFDI viewer&lt;/a&gt; that started on this site now lives on Siempre Contable (in Spanish).&lt;/p&gt;
&lt;h2 id=&quot;reading-and-verifying-are-different-things&quot;&gt;Reading and verifying are different things&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;parse_cfdi&lt;/code&gt; reads the XML: header, issuer, receiver, every line item with its taxes, the tax totals and the digital stamp. It labels every code (&lt;code&gt;G03&lt;/code&gt; is &lt;em&gt;Gastos en general&lt;/em&gt;, &lt;code&gt;601&lt;/code&gt; is &lt;em&gt;General de Ley Personas Morales&lt;/em&gt;), validates both RFCs and checks the arithmetic.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; verify the digital signature. A perfectly parseable invoice can be cancelled or fabricated wholesale, which is what &lt;code&gt;cfdi_status&lt;/code&gt; is for. And an unreachable SAT is not an invalid invoice: the service has no status page and it does go down (its documentation states a capacity of up to 2 million queries an hour and asks callers not to raise their volume, but promises no availability). When it fails, the tool returns &lt;code&gt;available: false&lt;/code&gt; with the reason — a statement about the SAT, never about the document — after a 10-second timeout and one retry.&lt;/p&gt;
&lt;p&gt;The same honesty applies to identifiers. A check digit that adds up says the string is well formed and nothing more. Only the SAT can say an RFC is registered, and only RENAPO that a CURP belongs to someone; this server asks neither, and its wording never implies it did. Even the fake data is labelled carefully: it is generated from common names, so a generated CURP or phone number can coincide with a real person’s by chance.&lt;/p&gt;
&lt;h2 id=&quot;what-it-deliberately-does-not-do&quot;&gt;What it deliberately does not do&lt;/h2&gt;
&lt;p&gt;It does not build, seal or stamp invoices. That half of Mexican e-invoicing needs your digital-seal certificate (CSD) and, for the stamp, a PAC, and it already has an MCP server: &lt;a href=&quot;https://github.com/cmendezs/mcp-cfdi-mx&quot;&gt;mcp-cfdi-mx&lt;/a&gt;, in Python, which builds, validates and seals CFDI 4.0 with your own CSD; the stamping itself still goes through a PAC. This one is the read half, which needs nothing, and the two complement each other.&lt;/p&gt;
&lt;h2 id=&quot;self-hosting-it-safely&quot;&gt;Self-hosting it safely&lt;/h2&gt;
&lt;p&gt;Besides stdio, the server speaks stateless Streamable HTTP for shared or remote use:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token assign-left variable&quot;&gt;TRANSPORT&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;http npx &lt;span class=&quot;token parameter variable&quot;&gt;-y&lt;/span&gt; mx-fiscal-mcp-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is safe by default: it binds to &lt;code&gt;127.0.0.1&lt;/code&gt; and only accepts &lt;code&gt;localhost&lt;/code&gt; &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;Origin&lt;/code&gt; headers, which blocks DNS-rebinding attacks from a web page. To expose it behind a reverse proxy, set &lt;code&gt;HOST=0.0.0.0&lt;/code&gt;, &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; with your public hostname and &lt;code&gt;MCP_AUTH_TOKEN&lt;/code&gt; for Bearer authentication. Don’t skip the token: an open instance relays requests to the SAT for anyone and can get your IP rate-limited.&lt;/p&gt;
&lt;p&gt;Invoice XML is untrusted input, so a document that declares a &lt;code&gt;DOCTYPE&lt;/code&gt; is refused before parsing (a CFDI never has one), and size and element-count caps bound the memory a single parse can take.&lt;/p&gt;
&lt;p&gt;The check-digit arithmetic lives in &lt;a href=&quot;https://www.npmjs.com/package/mx-identifiers&quot;&gt;mx-identifiers&lt;/a&gt;, a zero-dependency library tested against the public SAT, Banxico and RENAPO vectors, and the server has 51 offline unit tests plus a smoke test that calls every tool over the real protocol in both protocol eras. If you are looking for MCP servers in other areas, I keep a list of &lt;a href=&quot;https://ortamarco.me/en/blog/best-mcp-servers-for-seo-2026/&quot;&gt;the best MCP servers for SEO&lt;/a&gt;, including my own.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does the server need a SAT account, a CSD certificate or an API key? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. Every check-digit algorithm and every catalogue is public, and the SAT&amp;#39;s CFDI status service — the one behind the QR code on printed invoices — is public and unauthenticated. The server only reads; it never stamps or cancels anything, so it needs no credentials of any kind. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does the SAT say &amp;#39;No Encontrado&amp;#39; for an invoice that exists? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Usually because one of the four query values does not match what the SAT stored: a total in another format (it goes with six decimals and trailing zeros trimmed, keeping one, so 1160.00 travels as 1160.0), a thousands separator or a mistyped UUID. Passing the whole XML to cfdi_status lets the server derive the four values itself. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is XAXX010101000 a valid RFC? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. It is the SAT&amp;#39;s generic RFC for sales to the general public, assigned by decree, and it appears on real invoices. It does not satisfy the modulus-11 check digit — the algorithm asks for a 4 — so validators without an explicit allow-list reject it. XEXX010101000, for foreign residents, is also generic and does satisfy the algorithm. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can the generated test data belong to a real person? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; By chance, yes. generate_test_data builds each record from common Mexican names and a random birth date, and a CURP is derived from exactly those fields, so a collision with a real person&amp;#39;s CURP or phone number is possible. The data is meant for fixtures, seeds and demos; never send it to the SAT or a PAC, and never use it as a stand-in for a customer&amp;#39;s real data. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Tutorials</category><category>MCP</category><category>Claude</category><category>CFDI</category><category>Mexico</category><category>TypeScript</category><category>AI Agents</category><author>Marco Orta</author></item><item><title>npm Supply Chain: The Worm That Walked In Through Trusted Publishing</title><link>https://ortamarco.me/en/blog/npm-supply-chain-trusted-publishing-not-enough/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/npm-supply-chain-trusted-publishing-not-enough/</guid><description>The unanimous advice since 2025 was to migrate to trusted publishing with OIDC. In May 2026 Shai-Hulud walked in that way and published TanStack packages with legitimate provenance. What OIDC really protects, what it does not, and the four measures that would have stopped the recent attacks.</description><pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;For a year, npm security advice was a single sentence and everybody repeated it: stop publishing with long-lived tokens and move to &lt;em&gt;trusted publishing&lt;/em&gt; with OIDC. It was good advice. It still is. And on May 11, 2026, the Shai-Hulud worm published dozens of official &lt;code&gt;@tanstack/*&lt;/code&gt; packages by walking in exactly that way&lt;/strong&gt; — not by forging provenance, but by genuinely generating it from the project’s own legitimate workflow.&lt;/p&gt;
&lt;p&gt;That distinction is the whole article. A malicious package with an authentic Sigstore signature, issued by Fulcio, logged in Rekor, backed by the real repository context. Every indicator you were taught to check said “fine.”&lt;/p&gt;
&lt;p&gt;This doesn’t mean trusted publishing is useless, or that anyone should go back to tokens. It means something more uncomfortable: &lt;strong&gt;it’s a layer, not a perimeter&lt;/strong&gt;, and we spent a year selling it as the second thing. Here’s what it actually protects, what it leaves out, and the four measures that would have stopped the last twelve months of attacks.&lt;/p&gt;
&lt;h2 id=&quot;the-timeline-with-dates&quot;&gt;The timeline, with dates&lt;/h2&gt;
&lt;p&gt;Worth having in front of you, because the cadence matters more than any single incident:&lt;/p&gt;

























































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Date&lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Jul 2025&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Trusted publishing with OIDC&lt;/strong&gt; goes generally available on npm&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Aug 26, 2025&lt;/td&gt;&lt;td&gt;&lt;strong&gt;S1ngularity&lt;/strong&gt; attack&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Sep 8, 2025&lt;/td&gt;&lt;td&gt;Phishing against &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;chalk&lt;/code&gt; and 16 more utilities — &lt;strong&gt;live for two hours&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Sep 15, 2025&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Shai-Hulud wave 1&lt;/strong&gt;: 700+ packages compromised&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Nov 23, 2025&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Shai-Hulud 2.0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dec 9, 2025&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;npm &lt;strong&gt;permanently revokes every classic token&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Feb 2026&lt;/td&gt;&lt;td&gt;npm CLI &lt;strong&gt;11.10.0&lt;/strong&gt; ships &lt;code&gt;min-release-age&lt;/code&gt; and bulk OIDC configuration&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mar 30, 2026&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;axios@1.14.1&lt;/code&gt;&lt;/strong&gt; compromised — 100M weekly downloads&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;May 11, 2026&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Shai-Hulud against TanStack&lt;/strong&gt;, entering through trusted publishing itself&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;May 14, 2026&lt;/td&gt;&lt;td&gt;&lt;code&gt;node-ipc&lt;/code&gt;: three malicious versions with an 80 KB credential stealer&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Jun 1, 2026&lt;/td&gt;&lt;td&gt;32 &lt;code&gt;@redhat-cloud-services&lt;/code&gt; packages, payload named “Miasma”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Aug 2026&lt;/td&gt;&lt;td&gt;New wave: &lt;code&gt;keyv&lt;/code&gt; and 400+ packages&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Ten months, eight campaigns. &lt;strong&gt;This is no longer a series of incidents: it’s the registry’s normal operating state.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-it-came-in-through-the-good-door&quot;&gt;How it came in through the good door&lt;/h2&gt;
&lt;p&gt;The TanStack attack is worth following step by step, because each link dismantles a different assumption. &lt;a href=&quot;https://www.upwind.io/feed/shai-hulud-tanstack-supply-chain-worm&quot;&gt;Upwind’s reconstruction&lt;/a&gt; lays it out like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A “dropper” dependency&lt;/strong&gt; lands in &lt;code&gt;package.json&lt;/code&gt; and runs during installation via npm’s lifecycle hooks. Nothing exotic: that’s documented behaviour.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The dropper downloads and runs the Bun runtime.&lt;/strong&gt; That way it doesn’t depend on the installed Node and it sidesteps monitoring that watches for Node executions.&lt;/li&gt;
&lt;li&gt;Under Bun runs &lt;strong&gt;a heavily obfuscated 2.3 MB payload&lt;/strong&gt; (&lt;code&gt;router_init.js&lt;/code&gt;) that daemonises itself and stays alive after the install finishes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credential sweep&lt;/strong&gt;: filesystem, cloud APIs, and — this is the good part — &lt;strong&gt;GitHub Actions runner memory, scraped from &lt;code&gt;/proc/*/mem&lt;/code&gt;&lt;/strong&gt;. Targets: AWS keys, npm tokens, Kubernetes service accounts, AI assistant configs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OIDC abuse&lt;/strong&gt;: rather than needing a stored npm token, it &lt;strong&gt;exchanges the GitHub Actions OIDC token for dynamic publish access&lt;/strong&gt;. The workflow’s &lt;code&gt;id-token: write&lt;/code&gt; permission is the key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Propagation&lt;/strong&gt;: with that access it downloads target packages, injects itself, &lt;strong&gt;bumps the version by exactly +3&lt;/strong&gt; and republishes.&lt;/li&gt;
&lt;li&gt;And along the way it &lt;strong&gt;mints a Sigstore bundle through Fulcio and Rekor&lt;/strong&gt; — so the malicious tarball ships with provenance generated from the trusted workflow context.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Point 7 is the one to internalise. &lt;strong&gt;There was no forgery.&lt;/strong&gt; The chain of trust worked exactly as designed; what was compromised was the link before it, the CI runner. Provenance answers “was this built where it says?”, and the answer was yes. It does not answer “is what was built what the maintainer wrote?”, which was the question that mattered.&lt;/p&gt;
&lt;p&gt;The published indicators, in case you need to sweep: workflows talking to &lt;code&gt;api.masscan[.]cloud&lt;/code&gt; or &lt;code&gt;83.142.209.194&lt;/code&gt;, and persistence artifacts named &lt;code&gt;router_init.js&lt;/code&gt;, &lt;code&gt;pgmonitor.py&lt;/code&gt; or &lt;code&gt;pgsql-monitor.service&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;so-is-trusted-publishing-worth-it&quot;&gt;So is trusted publishing worth it?&lt;/h2&gt;
&lt;p&gt;Yes, very much so. But the split is worth being explicit about, because the generic advice never is:&lt;/p&gt;









































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Threat&lt;/th&gt;&lt;th&gt;Does OIDC stop it?&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;npm token stolen off a laptop&lt;/td&gt;&lt;td&gt;✅ Yes — there’s no token to steal&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Token leaked in a CI log or a &lt;code&gt;.env&lt;/code&gt;&lt;/td&gt;&lt;td&gt;✅ Yes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Maintainer phishing (the &lt;code&gt;debug&lt;/code&gt;/&lt;code&gt;chalk&lt;/code&gt; case)&lt;/td&gt;&lt;td&gt;✅ Largely&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Token still valid months after a maintainer leaves&lt;/td&gt;&lt;td&gt;✅ Yes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CI runner compromised during the build&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Malicious dependency running in &lt;code&gt;postinstall&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CI cache poisoning&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Maintainer’s GitHub account compromised&lt;/td&gt;&lt;td&gt;⛔ No&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The correct reading: &lt;strong&gt;OIDC eliminates the long-lived secret, which was the dominant vector through 2025. It does not defend the process that issues the publish.&lt;/strong&gt; And as the first vector closes, attacks move to the second. That is precisely what the timeline above shows.&lt;/p&gt;
&lt;p&gt;One number to calibrate how many people are still in the first box: according to &lt;a href=&quot;https://www.aikido.dev/blog/shai-hulud-trusted-publishing&quot;&gt;Aikido’s analysis&lt;/a&gt;, of the 51,370 most-downloaded packages &lt;strong&gt;only 11,001 use trusted publishing&lt;/strong&gt; — 21.4%, covering barely 25% of download volume. &lt;strong&gt;Three quarters of what you install every day is still published with tokens.&lt;/strong&gt; And adoption moves incident by incident: from a baseline of ~35 new packages a week it spiked to 372 during Shai-Hulud 2.0, then the urgency deflated again.&lt;/p&gt;
&lt;p&gt;So the message isn’t “trusted publishing failed.” It’s &lt;strong&gt;“migrate anyway, and also do these four things.”&lt;/strong&gt;&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-four-measures-that-would-actually-have-stopped-this&quot;&gt;The four measures that would actually have stopped this&lt;/h2&gt;
&lt;h3 id=&quot;1-dependency-cooldown-dont-install-what-shipped-this-morning&quot;&gt;1. Dependency cooldown: don’t install what shipped this morning&lt;/h3&gt;
&lt;p&gt;This is by far the best impact-to-effort ratio available, and still the least used.&lt;/p&gt;
&lt;p&gt;The idea is offensively simple: &lt;strong&gt;tell your package manager to ignore versions published less than N days ago&lt;/strong&gt;. Almost all of these campaigns are detected and pulled within hours — the malicious &lt;code&gt;debug&lt;/code&gt; and &lt;code&gt;chalk&lt;/code&gt; versions were live for &lt;strong&gt;two hours&lt;/strong&gt; — so a modest delay takes you entirely out of the exposure window. Of ten attacks analysed, &lt;a href=&quot;https://christian-schneider.net/blog/dependency-cooldowns-supply-chain-defense/&quot;&gt;eight had windows shorter than a week&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All four managers support it natively now, with the trap that &lt;strong&gt;the units don’t match&lt;/strong&gt;:&lt;/p&gt;



































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Manager&lt;/th&gt;&lt;th&gt;Key&lt;/th&gt;&lt;th&gt;Unit&lt;/th&gt;&lt;th&gt;Since&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;npm&lt;/td&gt;&lt;td&gt;&lt;code&gt;min-release-age&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;days&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;CLI 11.10.0 (Feb 2026)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;pnpm&lt;/td&gt;&lt;td&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;minutes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;10.16 (Sep 2025)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Yarn&lt;/td&gt;&lt;td&gt;&lt;code&gt;npmMinimalAgeGate&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;minutes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Berry 4.10.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Bun&lt;/td&gt;&lt;td&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;seconds&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;In npm, one line in &lt;code&gt;.npmrc&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-ini&quot; data-language=&quot;ini&quot;&gt;&lt;code class=&quot;language-ini&quot;&gt;&lt;span class=&quot;token key attr-name&quot;&gt;min-release-age&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token value attr-value&quot;&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In pnpm, inside &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, with an exclusion list for whatever you publish yourself:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; data-language=&quot;yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;minimumReleaseAge&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10080&lt;/span&gt;          &lt;span class=&quot;token comment&quot;&gt;# 7 days in minutes&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;minimumReleaseAgeExclude&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;@my-company/*&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two practical warnings. First: &lt;strong&gt;the cooldown is enforced at install time, not at update-suggestion time&lt;/strong&gt;, so your dependency bot can open the PR and the &lt;code&gt;npm ci&lt;/code&gt; fail afterwards; set the same threshold in both places. Second: &lt;strong&gt;Bun counts seconds, not minutes&lt;/strong&gt;: seven days is &lt;code&gt;604800&lt;/code&gt;, and anyone who copies pnpm’s &lt;code&gt;10080&lt;/code&gt; ends up with a cooldown of under three hours without noticing. Yarn does accept durations like &lt;code&gt;7d&lt;/code&gt;, and its exemption list is not a &lt;code&gt;*Exclude&lt;/code&gt; key but &lt;code&gt;npmPreapprovedPackages&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you want to see what it would have held back in your project before switching it on, &lt;a href=&quot;https://ortamarco.me/en/portfolio/dep-cooldown/&quot;&gt;dep-cooldown&lt;/a&gt;, an open-source tool of mine, reads your npm, pnpm, Yarn or Bun lockfile and gives you the age of every resolved version, including on the day you installed (&lt;code&gt;--as-of&lt;/code&gt;). And &lt;code&gt;npx dep-cooldown --config all&lt;/code&gt; prints the key for all four managers with the unit already converted.&lt;/p&gt;
&lt;h3 id=&quot;2-separate-the-runner-that-installs-from-the-runner-that-publishes&quot;&gt;2. Separate the runner that installs from the runner that publishes&lt;/h3&gt;
&lt;p&gt;This is the one that would have cut the TanStack attack dead.&lt;/p&gt;
&lt;p&gt;The structural problem is that in most workflows &lt;strong&gt;the same job runs &lt;code&gt;npm ci&lt;/code&gt; and &lt;code&gt;npm publish&lt;/code&gt;&lt;/strong&gt;. That means third-party code executing during install lives in the same process that holds — or can request — the OIDC token.&lt;/p&gt;
&lt;p&gt;The right shape is to split it, with &lt;code&gt;id-token: write&lt;/code&gt; existing &lt;strong&gt;only in the publishing job&lt;/strong&gt;, which installs nothing from third parties:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; data-language=&quot;yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;jobs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;runs-on&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ubuntu&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;latest
    &lt;span class=&quot;token key atrule&quot;&gt;permissions&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; read          &lt;span class=&quot;token comment&quot;&gt;# no id-token here&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;steps&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; actions/checkout@v5
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; npm ci &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;ignore&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;scripts
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; npm run build
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; npm pack
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; actions/upload&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;artifact@v4
        &lt;span class=&quot;token key atrule&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; tarball&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;*.tgz&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;token key atrule&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;needs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; build
    &lt;span class=&quot;token key atrule&quot;&gt;runs-on&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ubuntu&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;latest
    &lt;span class=&quot;token key atrule&quot;&gt;permissions&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; read
      &lt;span class=&quot;token key atrule&quot;&gt;id-token&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; write         &lt;span class=&quot;token comment&quot;&gt;# the permission lives only here&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;steps&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; actions/download&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;artifact@v4
        &lt;span class=&quot;token key atrule&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; tarball &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; npm publish &lt;span class=&quot;token important&quot;&gt;*.tgz&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;provenance
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;--ignore-scripts&lt;/code&gt; on the install step. Lifecycle hooks are steps 1 and 2 of the attack; if your build doesn’t need them — and many don’t — turning them off in CI is free. If some dependency genuinely does need them, the exception list is shorter than you think and worth writing by hand.&lt;/p&gt;
&lt;h3 id=&quot;3-treat-the-ci-cache-as-untrusted-input&quot;&gt;3. Treat the CI cache as untrusted input&lt;/h3&gt;
&lt;p&gt;The initial vector of the May wave was &lt;strong&gt;cache poisoning&lt;/strong&gt;. It’s an unintuitive vector because the cache looks like your own infrastructure, and it isn’t: in GitHub Actions, &lt;strong&gt;a lower-privileged branch can write into a cache that the release branch later reads&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The minimum:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never use &lt;code&gt;pull_request_target&lt;/code&gt; without an explicit trust boundary.&lt;/strong&gt; That trigger runs with the base repository’s secrets and the PR’s code in front of it; it’s the foot in the door.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Segregate cache keys by branch or by purpose.&lt;/strong&gt; The release workflow should not share a key with the PR workflow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In the publishing job, don’t restore a cache at all.&lt;/strong&gt; That job should be as boring as possible: download an artifact, upload it. Nothing else.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;4-stop-reading-the-provenance-badge-as-a-verdict&quot;&gt;4. Stop reading the provenance badge as a verdict&lt;/h3&gt;
&lt;p&gt;Provenance is still useful: it tells you &lt;strong&gt;where&lt;/strong&gt; a package was built. What you can’t do after May is read it as “this is safe.”&lt;/p&gt;
&lt;p&gt;In practice that means dependency review doesn’t get delegated to a badge. What does work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pin with &lt;code&gt;package-lock.json&lt;/code&gt; and always use &lt;code&gt;npm ci&lt;/code&gt; in CI&lt;/strong&gt;, never &lt;code&gt;npm install&lt;/code&gt;. That’s the difference between reproducing a tree and resolving a new one every time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Watch for odd version jumps.&lt;/strong&gt; The worm bumped versions by &lt;strong&gt;exactly +3&lt;/strong&gt;. A patch that skips three numbers with no changelog is a cheap and surprisingly good signal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rotate credentials when a wave hits you&lt;/strong&gt;, and rotate everything: npm, GitHub, Actions secrets, AWS, Vault, Kubernetes, SSH. These payloads sweep the entire runner, not just the publish token.&lt;/li&gt;
&lt;/ul&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #3b82f6&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#3b82f61a;color:#3b82f6&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; JWT Decoder and Verifier &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Decode the header and payload of a JSON Web Token and verify its HS256/384/512 or RS256/384/512 signature. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/jwt-decoder/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;decodificador-jwt&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;and-what-npm-changed-on-its-own&quot;&gt;And what npm changed on its own&lt;/h2&gt;
&lt;p&gt;Part of the work has already been done for you, and it’s worth knowing because it affects scripts you may still have lying around:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Classic tokens have been revoked since December 9, 2025.&lt;/strong&gt; Not deprecated: revoked. If an old pipeline of yours stopped publishing on that date, this is why.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;npm login&lt;/code&gt; no longer hands you a long-lived token&lt;/strong&gt;, but a &lt;strong&gt;two-hour&lt;/strong&gt; session that expires on its own and enforces 2FA for publishing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Granular write tokens expire after 90 days at most.&lt;/strong&gt; The eternal token no longer exists, even if you ask for it.&lt;/li&gt;
&lt;li&gt;And since npm CLI 11.10.0 you can &lt;strong&gt;configure OIDC in bulk&lt;/strong&gt; across many packages at once, which was the real friction in migrating an org with fifty packages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you publish packages, migrating to trusted publishing is literally four lines of YAML — the &lt;code&gt;id-token: write&lt;/code&gt; and the &lt;code&gt;--provenance&lt;/code&gt; from the example above — plus setting the trusted publisher on the package page. &lt;strong&gt;Do it.&lt;/strong&gt; But do it knowing you’ve just closed the 2025 vector, not the 2026 one.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-this-month-in-order&quot;&gt;What I’d do this month, in order&lt;/h2&gt;
&lt;p&gt;If you have half an hour, in decreasing order of return:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Set &lt;code&gt;min-release-age=7&lt;/code&gt;&lt;/strong&gt; (or your manager’s equivalent) in every repository. Five minutes, cuts off most known campaigns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swap &lt;code&gt;npm install&lt;/code&gt; for &lt;code&gt;npm ci --ignore-scripts&lt;/code&gt;&lt;/strong&gt; in every CI workflow that doesn’t need hooks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you publish packages, split the workflow&lt;/strong&gt; into build and publish, with &lt;code&gt;id-token: write&lt;/code&gt; only in the second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate to trusted publishing&lt;/strong&gt; anything still publishing with a granular token.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit your &lt;code&gt;pull_request_target&lt;/code&gt; triggers&lt;/strong&gt; and any cache keys shared across branches.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of the five is expensive. And here’s the uncomfortable part: &lt;strong&gt;the first one, the most effective of all, requires trusting nobody and understanding no attack.&lt;/strong&gt; Just waiting a week.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/secure-vibe-coding-avoid-ai-code-vulnerabilities/&quot;&gt;Secure Vibe Coding: Avoiding Vulnerabilities in AI-Written Code&lt;/a&gt; — the other front through which unreviewed code walks in.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/complete-docker-guide-2026/&quot;&gt;The Complete Docker Guide for 2026&lt;/a&gt; — isolating the build is half the defence in this article.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/tools-to-audit-domain-security/&quot;&gt;Tools to Audit Domain Security&lt;/a&gt; — the same review, pointed outward.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is trusted publishing with OIDC still worth it after the May 2026 attack? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes, and you should migrate anyway. OIDC completely eliminates the long-lived token, which was the dominant vector through 2025: there is nothing to steal from a laptop, from a CI log, or from a maintainer via phishing. What it does not cover is the process that issues the publish: if the CI runner is compromised during the build, the attacker exchanges the OIDC token exactly as the legitimate workflow would. It is a layer, not a perimeter. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How did the Shai-Hulud worm generate valid Sigstore provenance? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because it did not forge it: it genuinely generated it. The malicious payload ran inside the project&amp;#39;s own GitHub Actions runner, requested an OIDC token using the workflow&amp;#39;s id-token: write permission, exchanged it for npm publish access, and minted a Sigstore bundle through Fulcio and Rekor from that trusted context. Provenance answers &amp;quot;was this built where it says?&amp;quot;, and the answer was yes. It does not answer &amp;quot;is what was built what the maintainer wrote?&amp;quot;. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is a dependency cooldown and how do I configure it? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It tells your package manager to ignore versions published less than N days ago, so you never install a malicious version inside its lifetime, which is usually measured in hours. In npm it is min-release-age in .npmrc, measured in days (since CLI 11.10.0, February 2026); in pnpm it is minimumReleaseAge, measured in minutes (since 10.16); in Yarn it is npmMinimalAgeGate in minutes, and in Bun minimumReleaseAge in seconds. A seven-day threshold would have blocked eight of the ten most recent attacks analysed. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why should the install job be separate from the publish job in CI? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because if the same job runs npm ci and npm publish, the third-party code that executes during installation lives in the same process that has access to the OIDC token. That is how the TanStack attack got in: a malicious dependency ran in a lifecycle hook, scraped the runner memory and exchanged the token. The fix is for id-token: write to exist only in a publish job that installs nothing from third parties and just uploads an already-built artifact. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What share of npm packages already use trusted publishing? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not many: of the 51,370 most-downloaded packages, 11,001 use it — 21.4%, covering around 25% of total download volume. In other words, three quarters of what gets installed daily is still published with tokens. Adoption spikes with each incident (372 new packages during the week of Shai-Hulud 2.0, against a baseline of about 35 per week) and falls back once the urgency passes. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Do npm classic tokens still work? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. npm permanently revoked every classic token on December 9, 2025. Since then npm login issues a two-hour session instead of a long-lived token, and granular write tokens expire after 90 days at most. If an old pipeline stopped publishing around those dates, that is the cause. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Security</category><category>Security</category><category>npm</category><category>Supply Chain</category><category>JavaScript</category><category>DevSecOps</category><category>CI/CD</category><author>Marco Orta</author></item><item><title>Ollama on an AMD GPU: Five Failures That Drop You to CPU Without Telling You</title><link>https://ortamarco.me/en/blog/ollama-amd-gpu-silent-cpu-fallback/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/ollama-amd-gpu-silent-cpu-fallback/</guid><description>Ollama throws no error when it loses the GPU: it keeps answering, eight times slower. The five silent failure modes measured on a Radeon RX 7900 XTX, with the numbers, what does not fix them, and how to verify in thirty seconds.</description><pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;The worst Ollama failure on an AMD GPU isn’t the one that takes your server down. It’s the one that takes nothing down: Ollama starts, responds, answers correctly — and does it on CPU, at 15.89 tokens per second instead of 132.48.&lt;/strong&gt; An 8.3x penalty that shows up in no log unless you go looking, because as far as Ollama is concerned that isn’t an error. It’s a fallback.&lt;/p&gt;
&lt;p&gt;This article is the summary of three weeks measuring a &lt;strong&gt;24 GB Radeon RX 7900 XTX&lt;/strong&gt; on a Ryzen 9 7950X3D, across Windows, native Linux and inside WSL2. It isn’t an install guide — there are plenty of those — but an inventory of &lt;strong&gt;the five ways this setup breaks without saying so&lt;/strong&gt;, with the numbers for each and with what I proved &lt;em&gt;doesn’t&lt;/em&gt; fix them, which is usually the more useful half.&lt;/p&gt;
&lt;p&gt;The tok/s here belong to one specific card in one specific environment. What does transfer verbatim are the failure modes and the method for catching them.&lt;/p&gt;
&lt;h2 id=&quot;first-the-thirty-second-check&quot;&gt;First: the thirty-second check&lt;/h2&gt;
&lt;p&gt;If you take one thing from this article, take this. Ollama &lt;strong&gt;probes the GPU exactly once, at startup, and never looks again&lt;/strong&gt;. So there are two questions to answer every time, and neither is answered by noticing that the chat “feels fast”:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Which backend did the server declare at startup?&lt;/strong&gt; It’s in the startup log (&lt;code&gt;%LOCALAPPDATA%\Ollama\server.log&lt;/code&gt; on Windows, &lt;code&gt;journalctl -u ollama&lt;/code&gt; on Linux):&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;library=Vulkan compute=0.0 name=Vulkan0 description=&amp;quot;AMD Radeon RX 7900 XTX&amp;quot;
libdirs=ollama,vulkan  total=&amp;quot;24.0 GiB&amp;quot; available=&amp;quot;23.2 GiB&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you’re looking for is &lt;code&gt;library=&lt;/code&gt;. If it says &lt;code&gt;cpu&lt;/code&gt;, that’s it: there is no GPU, and nothing else is going to tell you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Did the model load in full?&lt;/strong&gt; &lt;code&gt;ollama ps&lt;/code&gt; gives you the split:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;ollama &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# NAME              SIZE     PROCESSOR&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# gpt-oss:20b       16 GB    100% GPU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anything that isn’t &lt;strong&gt;100% GPU&lt;/strong&gt; is a model split between GPU and CPU, and that doesn’t raise an error either.&lt;/p&gt;
&lt;p&gt;One detail that cost me a whole verifier script: &lt;strong&gt;don’t require &lt;code&gt;compute=gfx1100&lt;/code&gt; or a &lt;code&gt;pci_id&lt;/code&gt;&lt;/strong&gt;. The Vulkan backend reports &lt;code&gt;compute=0.0&lt;/code&gt; and &lt;code&gt;pci_id=&amp;quot;&amp;quot;&lt;/code&gt; where ROCm gave real values, so a script checking those fields will fail a perfectly healthy system. Read &lt;code&gt;library=&lt;/code&gt; and &lt;code&gt;libdirs=&lt;/code&gt;, nothing else.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;failure-1-the-cpus-integrated-gpu-steals-the-job&quot;&gt;Failure 1: the CPU’s integrated GPU steals the job&lt;/h2&gt;
&lt;p&gt;This one is treacherous because the guilty hardware isn’t the hardware you’re looking at.&lt;/p&gt;
&lt;p&gt;If you have a desktop Ryzen with integrated graphics — nearly all of the 7000 series and later do — your machine has &lt;strong&gt;two AMD GPUs&lt;/strong&gt;, not one. And ROCm’s enumeration orders them however it likes:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;ROCm0: AMD Radeon(TM) Graphics (36694 MiB)   &amp;lt;- the integrated one
ROCm1: AMD Radeon RX 7900 XTX (24560 MiB)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The integrated one comes first and reports 36 GB of VRAM (which it doesn’t have: that’s system RAM). &lt;code&gt;llama.cpp&lt;/code&gt; binaries pick it, load kernels compiled for &lt;code&gt;gfx1100&lt;/code&gt;, and blow up:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;ggml-cuda.cu:106: ROCm error
ROCm error: device kernel image is invalid
  current device: 1, in function ggml_cuda_kernel_launch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What doesn’t fix it&lt;/strong&gt; — and I tried everything before giving up:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Attempt&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--device ROCm1&lt;/code&gt; (pick it by name)&lt;/td&gt;&lt;td&gt;⛔ same error&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;HIP_VISIBLE_DEVICES=0&lt;/code&gt; and &lt;code&gt;=1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;⛔ same error, or “no usable GPU found”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ROCR_VISIBLE_DEVICES=1&lt;/code&gt; + &lt;code&gt;--device ROCm0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;⛔ same&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ROCBLAS_TENSILE_LIBPATH&lt;/code&gt; to the kernel directory&lt;/td&gt;&lt;td&gt;⛔ same (the &lt;code&gt;gfx1100&lt;/code&gt; kernels &lt;strong&gt;were there&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;What does fix it: disabling the integrated GPU in the BIOS.&lt;/strong&gt; With a single device there’s nothing to pick wrong and it starts first try, with no flags:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;before&lt;/th&gt;&lt;th&gt;after&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--list-devices&lt;/code&gt;&lt;/td&gt;&lt;td&gt;integrated + dedicated&lt;/td&gt;&lt;td&gt;&lt;strong&gt;only the RX 7900 XTX&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;standalone &lt;code&gt;llama-server&lt;/code&gt;&lt;/td&gt;&lt;td&gt;⛔ &lt;code&gt;kernel image is invalid&lt;/code&gt;&lt;/td&gt;&lt;td&gt;✅ &lt;strong&gt;136 tok/s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ollama&lt;/td&gt;&lt;td&gt;118 tok/s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;132.5 tok/s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The reasonable fear was losing VRAM by moving the monitor onto the dedicated card. &lt;strong&gt;Measured: it costs 0.2 GiB.&lt;/strong&gt; With the display running 3440×1440 at 165 Hz off the big card, Ollama still reports &lt;code&gt;total 24.0 GiB / available 23.8 GiB&lt;/code&gt;. The trade is free.&lt;/p&gt;
&lt;p&gt;And a note so you don’t lose your mind: &lt;strong&gt;under Ollama this doesn’t happen&lt;/strong&gt;, because Ollama masks the integrated GPU on its own and to it the dedicated card is &lt;code&gt;ROCm0&lt;/code&gt;. That’s why you can have Ollama working and &lt;code&gt;llama-server&lt;/code&gt; failing on the same machine with the same weights. You’re not imagining it: they’re two different enumerations.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;failure-2-updating-anything-resets-the-backend&quot;&gt;Failure 2: updating anything resets the backend&lt;/h2&gt;
&lt;p&gt;I said it above, but it earns its own section because it’s the most expensive one: &lt;strong&gt;Ollama probes the GPU at startup and that’s it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;After an AMD driver reinstall, the service just carried on like this:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;inference compute  id=cpu  library=cpu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No error. No warning. &lt;strong&gt;15.89 tok/s against 132.48.&lt;/strong&gt; Restarting Ollama put it back on &lt;code&gt;library=ROCm compute=gfx1100&lt;/code&gt; and back to 132.&lt;/p&gt;
&lt;p&gt;There’s a second version of the same failure, and it’s worse because you don’t trigger it: &lt;strong&gt;every Ollama update reinstalls the ROCm bundle&lt;/strong&gt;. If you’d deliberately switched to Vulkan, the update quietly puts you back on ROCm. Nobody tells you; the number you’d been measuring for weeks simply stops being the same number.&lt;/p&gt;
&lt;p&gt;The operating rule that came out of this: &lt;strong&gt;run the check after touching drivers, BIOS or Ollama version.&lt;/strong&gt; Not after something goes wrong — after touching anything, always, even when nothing seems wrong.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;failure-3-inside-wsl2-there-is-no-gpu-for-ollama-and-there-wont-be&quot;&gt;Failure 3: inside WSL2 there is no GPU for Ollama, and there won’t be&lt;/h2&gt;
&lt;p&gt;This is the one that ate the most time, so here’s the detail so it doesn’t eat yours.&lt;/p&gt;
&lt;p&gt;The question was reasonable: if native Linux beats Windows by 6.7 to 19.5% in tok/s, can I take it inside WSL and skip the reboot? &lt;strong&gt;The answer is that Ollama in WSL doesn’t use the GPU at all.&lt;/strong&gt; And it isn’t a driver problem.&lt;/p&gt;
&lt;p&gt;What I measured, in order:&lt;/p&gt;





































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/dev/kfd&lt;/code&gt; and &lt;code&gt;/sys/class/kfd&lt;/code&gt;&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;don’t exist&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/dev/dxg&lt;/code&gt;&lt;/td&gt;&lt;td&gt;✅ exists (the WSL path)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Does HIP compute in WSL?&lt;/td&gt;&lt;td&gt;✅ &lt;strong&gt;YES&lt;/strong&gt; — probe built with &lt;code&gt;hipcc&lt;/code&gt;: &lt;code&gt;hipGetDeviceCount rc=0 n=1&lt;/code&gt;, &lt;code&gt;gfx1100&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ollama detects GPU&lt;/td&gt;&lt;td&gt;⛔ &lt;code&gt;library=cpu&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;HSA_ENABLE_DXG_DETECTION=1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;⛔ changes nothing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;llama-server --list-devices&lt;/code&gt; with ROCm&lt;/td&gt;&lt;td&gt;⛔ &lt;code&gt;Available devices: (none)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;llama-server --list-devices&lt;/code&gt; with Vulkan&lt;/td&gt;&lt;td&gt;⛔ &lt;code&gt;Available devices: (none)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The paradox that explains it:&lt;/strong&gt; HIP &lt;em&gt;does&lt;/em&gt; work in WSL, through the HSA runtime that talks to &lt;code&gt;/dev/dxg&lt;/code&gt;. But &lt;strong&gt;Ollama enumerates GPUs by reading the KFD topology from &lt;code&gt;sysfs&lt;/code&gt;&lt;/strong&gt;, which WSL doesn’t expose. The driver isn’t missing — Ollama is looking through a door WSL doesn’t have.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;OLLAMA_DEBUG=1&lt;/code&gt; the real cause surfaces, otherwise buried:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;failure during llama-server GPU discovery
error=&amp;quot;llama-server --list-devices failed: signal: segmentation fault (core dumped)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s a recipe going around — &lt;a href=&quot;https://github.com/ollama/ollama/issues/16551&quot;&gt;Ollama issue 16551&lt;/a&gt;, which is the identical case: same card, same WSL2, same ROCm 7.2 — whose author reports &lt;code&gt;library=ROCm compute=gfx1100&lt;/code&gt; at 100% GPU by dropping in the ROCm bundle and setting &lt;code&gt;HSA_ENABLE_DXG_DETECTION=1&lt;/code&gt;. &lt;strong&gt;Tried here, does not reproduce.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What about the Vulkan backend? In WSL it would need the &lt;strong&gt;&lt;code&gt;dzn&lt;/code&gt;&lt;/strong&gt; ICD (Vulkan over D3D12), which Ubuntu doesn’t package. You could build Mesa with &lt;code&gt;-Dvulkan-drivers=microsoft-experimental&lt;/code&gt; and see what happens, but that’s a lot of work for an uncertain payoff.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Practical conclusion: for Ollama, WSL is out.&lt;/strong&gt; The two exits are Windows Ollama (which does use the GPU, and is what exists today) or booting native Linux. And if you develop inside WSL like I do, the good news is &lt;strong&gt;you don’t need to move Ollama&lt;/strong&gt;: with WSL2 in &lt;code&gt;networkingMode=mirrored&lt;/code&gt;, &lt;code&gt;localhost:11434&lt;/code&gt; from WSL reaches Windows Ollama with zero configuration.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;failure-4-the-model-doesnt-fit-gets-split-and-keeps-answering&quot;&gt;Failure 4: the model doesn’t fit, gets split, and keeps answering&lt;/h2&gt;
&lt;p&gt;When you ask for more context than fits in VRAM, Ollama doesn’t say no. &lt;strong&gt;It splits the model between GPU and CPU and carries on.&lt;/strong&gt; The symptom is &lt;code&gt;size_vram &amp;lt; size&lt;/code&gt; in &lt;code&gt;/api/ps&lt;/code&gt;, and nothing else.&lt;/p&gt;
&lt;p&gt;Measured with &lt;code&gt;qwen3.8&lt;/code&gt; (27.3B) on the 7900 XTX, with &lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt; and the KV cache at &lt;code&gt;q8_0&lt;/code&gt;:&lt;/p&gt;





















&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th style=&quot;text-align:right&quot;&gt;&lt;code&gt;num_ctx&lt;/code&gt; requested&lt;/th&gt;&lt;th&gt;On GPU&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td style=&quot;text-align:right&quot;&gt;262,144&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;63.4%&lt;/strong&gt; — a third on CPU&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td style=&quot;text-align:right&quot;&gt;131,072&lt;/td&gt;&lt;td&gt;⛔ 92.4%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td style=&quot;text-align:right&quot;&gt;122,880&lt;/td&gt;&lt;td&gt;✅ 100% (with &lt;strong&gt;0.6 GiB&lt;/strong&gt; of headroom)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;With 0.6 GiB free, anything you open on the desktop knocks it over. That ceiling belongs to that afternoon and that display, not to the model.&lt;/p&gt;
&lt;p&gt;And here’s the warning that nearly cost me a bad decision: &lt;strong&gt;&lt;code&gt;ollama ps&lt;/code&gt; underestimates real VRAM by 4.3 to 6.8 GiB.&lt;/strong&gt; Cross-checked against the system counter, across four models:&lt;/p&gt;






























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;&lt;code&gt;ollama ps&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;Real VRAM&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-oss:20b&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;12.33 GiB&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;16.59 GiB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;muse-glimmer&lt;/code&gt; (27.9B)&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;15.49 GiB&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;20.77 GiB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gemma4&lt;/code&gt; (25.8B)&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;17.24 GiB&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;22.37 GiB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;qwen3.8&lt;/code&gt; (27.3B)&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;16.61 GiB&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;23.36 GiB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;For memory decisions, the &lt;code&gt;ps&lt;/code&gt; column is useless.&lt;/strong&gt; It’s only good for telling you whether Ollama split to CPU. If you’re working out whether a model fits, measure system VRAM, not what Ollama declares.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #a855f7&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#a855f71a;color:#a855f7&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; LLM Token Counter &amp;amp; AI Cost Calculator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Count the tokens in your text or prompt and estimate the cost across GPT-5, Claude, Gemini, Kimi K3, Grok and DeepSeek. Exact OpenAI counting, per-call and monthly costs, with prompt caching. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/llm-token-counter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;contador-tokens&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;failure-5-it-trims-your-context-and-lets-you-believe-you-have-it&quot;&gt;Failure 5: it trims your context and lets you believe you have it&lt;/h2&gt;
&lt;p&gt;A subtler variant of the previous one. Ask &lt;code&gt;muse-glimmer&lt;/code&gt; or &lt;code&gt;gpt-oss&lt;/code&gt; for &lt;code&gt;num_ctx: 262144&lt;/code&gt; and &lt;strong&gt;they load perfectly happily&lt;/strong&gt; — and the &lt;code&gt;context_length&lt;/code&gt; returned by &lt;code&gt;/api/ps&lt;/code&gt; says &lt;code&gt;131072&lt;/code&gt;. What you asked for isn’t what you got.&lt;/p&gt;
&lt;p&gt;The underlying cause is that these models have an architecture ceiling below the card’s. And it explains something counterintuitive that shows up when you measure: &lt;strong&gt;on-disk size does not predict maximum context&lt;/strong&gt;. What decides is whether the model uses a &lt;strong&gt;sliding window&lt;/strong&gt; (SWA) or dense KV:&lt;/p&gt;






























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th&gt;SWA&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;KV growth from 16K to 128K&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;muse-glimmer&lt;/code&gt;&lt;/td&gt;&lt;td&gt;2048&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;~0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-oss&lt;/code&gt;&lt;/td&gt;&lt;td&gt;128&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;+0.36 GiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gemma4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1024&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;+0.40 GiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;qwen3.8&lt;/code&gt;&lt;/td&gt;&lt;td&gt;— (dense KV)&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;+1.35 GiB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three of the four hit their architecture ceiling and never notice the VRAM. The only one that actually pays for context is the only one that overflows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always read the real &lt;code&gt;context_length&lt;/code&gt; from &lt;code&gt;/api/ps&lt;/code&gt;, not the one you sent.&lt;/strong&gt;&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;rocm-or-vulkan-in-2026-what-each-one-wins&quot;&gt;ROCm or Vulkan in 2026: what each one wins&lt;/h2&gt;
&lt;p&gt;This is the year’s underlying shift, and the reason this article lands now. Ollama added &lt;a href=&quot;https://www.phoronix.com/news/ollama-Experimental-Vulkan&quot;&gt;experimental Vulkan support&lt;/a&gt; in 0.12.6 and it has been reaching the binaries through 2026, with the argument of opening the door to the AMD and Intel GPUs ROCm never supported — which is most consumer cards.&lt;/p&gt;
&lt;p&gt;On my machine, forcing each backend with the same Ollama and the same weights, &lt;strong&gt;Vulkan wins&lt;/strong&gt;:&lt;/p&gt;





















&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th style=&quot;text-align:right&quot;&gt;Vulkan’s gain over ROCm&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gpt-oss:20b&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;+17.8%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;gemma4&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;+30.1%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;muse-glimmer&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;+30.3%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Disjoint ranges across two independent rounds, so the signal is real. And it lines up with what others report: on an RX 9070 XT, Llama 2 7B Q4_0 decodes at &lt;a href=&quot;https://runaihome.com/blog/rdna4-vulkan-vs-rocm-local-llm-benchmark-2026/&quot;&gt;137 tok/s on Vulkan against 101 on ROCm&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With two caveats almost nobody adds.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;First: the delta compares two things at once. On Windows it’s ROCm 7.1 against AMDVLK; on Linux it’s ROCm 7.2.4 against RADV. A worse ROCm inflates exactly the same number as a better Vulkan, and they aren’t separated.&lt;/p&gt;
&lt;p&gt;Second is a cost I didn’t see coming. &lt;strong&gt;Vulkan leaves 0.6 GiB less VRAM available&lt;/strong&gt;: 23.2 GiB declared against 23.8 with ROCm. For three of my four models it makes no difference because they weren’t near the limit. For &lt;code&gt;qwen3.8&lt;/code&gt;, which had exactly 0.6 GiB of headroom, it eats the lot:&lt;/p&gt;

















&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th style=&quot;text-align:right&quot;&gt;&lt;code&gt;num_ctx&lt;/code&gt;&lt;/th&gt;&lt;th&gt;On Vulkan&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td style=&quot;text-align:right&quot;&gt;122,880 (the ROCm ceiling)&lt;/td&gt;&lt;td&gt;⛔ &lt;strong&gt;93.8%&lt;/strong&gt; on GPU&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td style=&quot;text-align:right&quot;&gt;98,304&lt;/td&gt;&lt;td&gt;✅ 100%&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The real trade with this lever isn’t speed against stability: it’s speed against context.&lt;/strong&gt; If your model is near the card’s limit, Vulkan costs you window. If it isn’t, it’s free.&lt;/p&gt;
&lt;p&gt;What remains unmeasured — by me and by almost everyone — is &lt;strong&gt;Vulkan’s stability over multi-hour sessions&lt;/strong&gt;. For a ten-minute chat it doesn’t matter; for an agent looping all afternoon, it does.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-full-checklist&quot;&gt;The full checklist&lt;/h2&gt;
&lt;p&gt;What I do now every time I touch this machine:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Disable the integrated GPU in the BIOS&lt;/strong&gt; if you have a Ryzen with graphics. It costs 0.2 GiB and removes an entire axis of ambiguity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restart Ollama after touching drivers, BIOS or version.&lt;/strong&gt; Not optional: it probes once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check &lt;code&gt;library=&lt;/code&gt; in the startup log.&lt;/strong&gt; Not &lt;code&gt;compute=&lt;/code&gt;, not &lt;code&gt;pci_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check that &lt;code&gt;ollama ps&lt;/code&gt; says 100% GPU&lt;/strong&gt; before trusting any measurement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read the real &lt;code&gt;context_length&lt;/code&gt; from &lt;code&gt;/api/ps&lt;/code&gt;&lt;/strong&gt;, not the one you sent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For memory decisions, measure system VRAM&lt;/strong&gt;, not the &lt;code&gt;ollama ps&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don’t try it in WSL.&lt;/strong&gt; Point at &lt;code&gt;localhost:11434&lt;/code&gt; against Windows Ollama.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Close Chrome, Spotify and Slack before measuring.&lt;/strong&gt; Less for the tok/s than for the range: a real 2% difference disappears if noise widens it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And the one that covers all of them: &lt;strong&gt;an empty result is not a zero.&lt;/strong&gt; When an instrument returns nothing, the reading isn’t “there is none”; it’s “I couldn’t look.” On this machine, &lt;code&gt;Get-Process | Modules&lt;/code&gt; returned &lt;code&gt;0&lt;/code&gt; modules and I nearly read it as “no layers loaded.” It was 0 because the process was running elevated and I couldn’t see inside. What saved it was having a control case next to it — &lt;code&gt;explorer&lt;/code&gt;, same user, 389 modules. Without that contrast, the zero reads as data.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;if-youre-weighing-up-the-hardware&quot;&gt;If you’re weighing up the hardware&lt;/h2&gt;
&lt;p&gt;The honest conclusion after all of this: &lt;strong&gt;a consumer Radeon runs local models perfectly well in 2026&lt;/strong&gt;, and the numbers sit alongside what gets published for NVIDIA in the same bracket. ROCm 7.2 is the first release where Ollama, LM Studio, llama.cpp and vLLM all work on Radeon without hand-patching.&lt;/p&gt;
&lt;p&gt;What still costs more than on NVIDIA isn’t the tok/s: &lt;strong&gt;it’s the diagnosis&lt;/strong&gt;. Everything in this article is time you wouldn’t have spent on a 4090 — not because the hardware is worse, but because the path is less trodden and the failures are silent instead of loud.&lt;/p&gt;
&lt;p&gt;If that trade works for you — and it works for me, because 24 GB of VRAM at this price doesn’t exist on the other side — this article is the shortcut past the five holes.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/local-llm-business-privacy/&quot;&gt;AI Without Sending Data Out: Local LLMs for Law Firms and Small Businesses&lt;/a&gt; — the why behind all of this: what a local model actually solves, and what it doesn’t.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/how-much-openai-claude-gemini-api-costs-2026/&quot;&gt;How Much Do the OpenAI, Claude and Gemini APIs Cost in 2026&lt;/a&gt; — the number to compare against before buying a card.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/ai-model-retirements-2026/&quot;&gt;The AI model your business runs on is being retired&lt;/a&gt; — why keeping the model in-house stops being a luxury.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I know whether Ollama is using my AMD GPU? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; With two checks, not one. First, the server startup log (%LOCALAPPDATA%\Ollama\server.log on Windows, journalctl -u ollama on Linux) must declare library=ROCm or library=Vulkan; if it says library=cpu, there is no GPU. Second, ollama ps must say 100% GPU: any other value means the model is split between GPU and CPU. Ollama raises no error in either case, it just runs slower. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does Ollama run on CPU after a driver update? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because Ollama probes the GPU only once, at startup, and never looks again. If you update drivers, change the BIOS or install a new version while the service is running, it keeps the stale detection and falls back to CPU with no error at all. Measured on an RX 7900 XTX: 15.89 tok/s on CPU against 132.48 on GPU, an 8.3x penalty. The fix is to restart Ollama and re-check the library= line in the log. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can I use an AMD GPU with Ollama inside WSL2? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. Verified in August 2026 with an RX 7900 XTX and ROCm 7.2: Ollama enumerates GPUs by reading the KFD topology from sysfs, and WSL exposes neither /dev/kfd nor /sys/class/kfd. HIP does compute in WSL through /dev/dxg, but Ollama does not look there, and HSA_ENABLE_DXG_DETECTION=1 does not change it. Both ROCm and Vulkan return &amp;quot;Available devices: (none)&amp;quot;. The practical exit is to point at localhost:11434 against Windows Ollama: with WSL2 in networkingMode=mirrored it works with no configuration. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is ROCm or Vulkan better for Ollama on an AMD GPU? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It depends on whether your model is near the VRAM limit. On an RX 7900 XTX, Vulkan wins between 17.8% and 30.3% in tokens per second depending on the model, with disjoint ranges across two rounds. But Vulkan leaves 0.6 GiB less VRAM available (23.2 against 23.8 GiB), so a model that was tight on context stops fitting. The trade is not speed against stability: it is speed against context window. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why does llama.cpp fail with &amp;quot;device kernel image is invalid&amp;quot; on a Ryzen? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because the machine has two AMD GPUs: the dedicated card and the CPU&amp;#39;s integrated graphics. ROCm enumerates the integrated one first, the binary picks it, and kernels compiled for gfx1100 do not work on that chip. Neither --device by name nor HIP_VISIBLE_DEVICES nor ROCR_VISIBLE_DEVICES fixes it. The only thing that works is disabling the integrated GPU in the BIOS, and it costs just 0.2 GiB of VRAM. Ollama does not suffer from this because it masks the integrated GPU on its own. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much VRAM does a model in Ollama actually need? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; More than ollama ps says, which underestimates by 4.3 to 6.8 GiB. Measured across four models on a 24 GB card: gpt-oss:20b declares 12.33 GiB and occupies 16.59; qwen3.8 declares 16.61 and occupies 23.36. To decide whether a model fits you have to read the system VRAM counter; the ollama ps column is only good for telling you whether the model was split to CPU. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Tutorials</category><category>AI</category><category>Ollama</category><category>AMD</category><category>GPU</category><category>Local LLM</category><category>ROCm</category><author>Marco Orta</author></item><item><title>GPT-6 Astra for Developers: The Pricing Cliff Nobody Put in the Headline</title><link>https://ortamarco.me/en/blog/gpt-6-astra-api-developers/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/gpt-6-astra-api-developers/</guid><description>GPT-6 Astra ships a 1.05M context window, but crossing 272K input tokens reprices the whole request at 2x. Plus the access gating, what it does not support, and why it is the wrong migration target for the models dying on 23 October.</description><pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;OpenAI released GPT-6 Astra on 3 September 2026 with a 1,050,000-token context window. What the launch coverage skipped: the moment a request crosses 272,000 input tokens, the &lt;em&gt;entire&lt;/em&gt; request is repriced at 2x input and 1.5x output. Not the excess — the whole thing.&lt;/strong&gt; One token past the line roughly doubles the bill for that call. The window you are being sold is the window you are billed double to use.&lt;/p&gt;
&lt;p&gt;That is the first of five things that matter if you are the one writing the code rather than reading the benchmark chart. This post is the developer-side read: pricing, access gating, what Astra explicitly does &lt;strong&gt;not&lt;/strong&gt; support, the parameter that will break your validation, and — the one I care about most for the people who email me — why Astra is the wrong destination for the models OpenAI shuts down on 23 October.&lt;/p&gt;
&lt;h2 id=&quot;what-astra-actually-is-in-one-screen&quot;&gt;What Astra actually is, in one screen&lt;/h2&gt;





























































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;GPT-6 Astra&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;API model ID&lt;/td&gt;&lt;td&gt;&lt;code&gt;gpt-6-astra&lt;/code&gt; (single snapshot)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Context window&lt;/td&gt;&lt;td&gt;1,050,000 tokens&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max input&lt;/td&gt;&lt;td&gt;922,000 tokens&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max output&lt;/td&gt;&lt;td&gt;128,000 tokens&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Knowledge cutoff&lt;/td&gt;&lt;td&gt;30 April 2026&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Input / output&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$10 / $50&lt;/strong&gt; per million&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cached input / cache write&lt;/td&gt;&lt;td&gt;$1.00 / $12.50 per million&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Batch and Flex&lt;/td&gt;&lt;td&gt;50% of standard&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Fast mode&lt;/td&gt;&lt;td&gt;2x standard, up to ~2.5x speed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Over 272K input&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2x input and cache, 1.5x output — applied to the full request&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reasoning effort&lt;/td&gt;&lt;td&gt;&lt;code&gt;low&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt;, &lt;strong&gt;&lt;code&gt;xhigh&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;max&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Endpoints&lt;/td&gt;&lt;td&gt;Chat Completions, Responses, Batch&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;td&gt;Realtime, Assistants, fine-tuning, embeddings, image generation, audio, moderation, legacy Completions&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Availability is staged: a limited set of organizations first (OpenAI’s Daybreak and Trusted Access programs), then ChatGPT Plus, Pro, Business and Enterprise “over the coming days”, then the API and Amazon Bedrock. Enterprise workspaces have it &lt;strong&gt;off by default&lt;/strong&gt; — an admin has to switch it on, which is the first support ticket your team will file.&lt;/p&gt;
&lt;h2 id=&quot;1-the-272k-cliff-with-the-arithmetic&quot;&gt;1. The 272K cliff, with the arithmetic&lt;/h2&gt;
&lt;p&gt;This is the part worth internalizing before you wire Astra into anything that runs on a loop.&lt;/p&gt;
&lt;p&gt;The surcharge is not marginal pricing. It is not “the tokens above 272K cost double.” Per OpenAI’s own model page, prompts above 272,000 input tokens are billed at 2x input and cache rates and 1.5x output &lt;strong&gt;for the full request&lt;/strong&gt;. Which produces this:&lt;/p&gt;













































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Request&lt;/th&gt;&lt;th&gt;Input tokens&lt;/th&gt;&lt;th&gt;Output tokens&lt;/th&gt;&lt;th&gt;Input cost&lt;/th&gt;&lt;th&gt;Output cost&lt;/th&gt;&lt;th&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Just under&lt;/td&gt;&lt;td&gt;272,000&lt;/td&gt;&lt;td&gt;8,000&lt;/td&gt;&lt;td&gt;$2.72&lt;/td&gt;&lt;td&gt;$0.40&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$3.12&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Just over&lt;/td&gt;&lt;td&gt;273,000&lt;/td&gt;&lt;td&gt;8,000&lt;/td&gt;&lt;td&gt;$5.46&lt;/td&gt;&lt;td&gt;$0.60&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$6.06&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max input&lt;/td&gt;&lt;td&gt;922,000&lt;/td&gt;&lt;td&gt;8,000&lt;/td&gt;&lt;td&gt;$18.44&lt;/td&gt;&lt;td&gt;$0.60&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$19.04&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max input, max output&lt;/td&gt;&lt;td&gt;922,000&lt;/td&gt;&lt;td&gt;128,000&lt;/td&gt;&lt;td&gt;$18.44&lt;/td&gt;&lt;td&gt;$9.60&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$28.04&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;A thousand extra input tokens — a medium-sized file, a couple of tool results — takes a $3.12 call to $6.06. &lt;strong&gt;That is a 94% increase for a 0.4% increase in input.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Now put that in an agent loop. Agents do not send one big prompt; they accumulate. The context grows with every tool result, every file read, every retry. So an agent that starts each session comfortably under the line will, somewhere around hour two of a long refactor, silently cross it — and every subsequent call in that session is priced on the far side. Nothing errors. Nothing warns you. You find out on the invoice.&lt;/p&gt;
&lt;p&gt;If you have ever measured what an agent actually burns, you know the shape of this problem: &lt;a href=&quot;https://ortamarco.me/en/blog/how-much-ai-coding-agents-cost/&quot;&gt;in 35 days of Claude Code I logged 7.97 billion billable tokens&lt;/a&gt;. Apply a 2x step function to a curve like that and the number stops being theoretical.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What to actually do:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Set a hard input ceiling below the line, not at it.&lt;/strong&gt; 260,000 leaves room for the tool result you did not predict. Enforce it in your own code before the call, because the API will not do it for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Count before you send.&lt;/strong&gt; Token counting is cheap and the cliff is not. If your stack does not already meter input per request, that is the instrumentation to add first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remember prompt caching sits on the same side of the cliff.&lt;/strong&gt; Cached input is $1/M normally and $2/M past 272K. Caching softens the slope, it does not move the step.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Batch where latency allows.&lt;/strong&gt; 50% off applies before the multiplier, so batch plus long context is still cheaper than standard plus long context.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Before you commit a prompt template to production, sanity check its size. The cliff is a
number you can design around — but only if you know which side of it you are on.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #a855f7&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#a855f71a;color:#a855f7&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; LLM Token Counter &amp;amp; AI Cost Calculator &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Count the tokens in your text or prompt and estimate the cost across GPT-5, Claude, Gemini, Kimi K3, Grok and DeepSeek. Exact OpenAI counting, per-call and monthly costs, with prompt caching. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/llm-token-counter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;contador-tokens&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;2-the-gating-is-real-and-it-lands-on-unrelated-work&quot;&gt;2. The gating is real, and it lands on unrelated work&lt;/h2&gt;
&lt;p&gt;Astra is the first model OpenAI has classified at the &lt;strong&gt;Critical&lt;/strong&gt; cybersecurity level under its Preparedness Framework. In OpenAI’s own plain-language framing: with the right tools and access, it can find previously unknown security flaws and develop new ways to exploit them across many well-protected systems without a person guiding each step. During evaluation it developed exploits for hardened browsers and operating systems and found two previously unknown V8 vulnerabilities.&lt;/p&gt;
&lt;p&gt;The consequence for developers is not abstract:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Standard API access refuses advanced cybersecurity work outright.&lt;/strong&gt; Exploit discovery and development are not “paused for review” — they are declined.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The advanced capability is gated behind Daybreak and Trusted Access.&lt;/strong&gt; If you do legitimate defensive security work, you apply; you do not just pay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accounts outside those programs may hit slowdowns, pauses or blocks — reportedly “sometimes during unrelated work.”&lt;/strong&gt; That last clause is the operationally important one. A safety classifier tuned for a Critical-rated capability will have false positives, and false positives on a synchronous API call are latency spikes and failed requests in your product.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are putting Astra behind a user-facing feature, build for that: timeouts, a fallback model, and a retry path that degrades to a smaller model instead of surfacing an error. Treat “the safety layer said no” as a normal failure mode with a code path, not an exception you log and forget.&lt;/p&gt;
&lt;p&gt;There is a second flag worth reading honestly rather than dismissing. The UK AI Safety Institute’s evaluation found supply-chain attack behavior in &lt;strong&gt;2 out of 500 sampled runs&lt;/strong&gt;, and OpenAI itself notes a substantial decrease in chain-of-thought monitorability compared with previous models — meaning the model can complete tasks without surfacing its reasoning. If you are running Astra with shell access in CI, &lt;code&gt;approval_policy = &amp;quot;never&amp;quot;&lt;/code&gt; is not a defensible default anymore. This is the same argument I made about &lt;a href=&quot;https://ortamarco.me/en/blog/secure-vibe-coding-avoid-ai-code-vulnerabilities/&quot;&gt;reviewing AI-written code before it ships&lt;/a&gt;, except the blast radius now includes your build pipeline.&lt;/p&gt;
&lt;h2 id=&quot;3-what-it-does-not-support-the-drop-in-replacement-trap&quot;&gt;3. What it does not support (the drop-in replacement trap)&lt;/h2&gt;
&lt;p&gt;Astra is a reasoning, computer-use and long-context model. It is not a general replacement for your OpenAI account. The unsupported list is long and specific:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Realtime. Assistants. Fine-tuning. Embeddings. Image generation. Audio. Moderation. Legacy Completions.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Two of those deserve a callout because they are load-bearing in real systems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No fine-tuning.&lt;/strong&gt; If your product depends on a fine-tuned model, Astra is not a path forward for it, and OpenAI is separately closing new self-serve fine-tuning job creation on 6 January 2027. Those two facts together are a strategy decision, not a config change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No embeddings.&lt;/strong&gt; Your RAG pipeline’s embedding step stays where it is. Astra changes the generation half of RAG and nothing about the retrieval half — and with a million-token window, the more interesting question is whether parts of your RAG layer are now solving a problem you no longer have. (Given the pricing above: probably not. Retrieval is still cheaper than stuffing.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;4-xhigh-and-max-the-parameter-that-breaks-validation&quot;&gt;4. &lt;code&gt;xhigh&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt;: the parameter that breaks validation&lt;/h2&gt;
&lt;p&gt;Astra adds two reasoning-effort levels above &lt;code&gt;high&lt;/code&gt;: &lt;code&gt;xhigh&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt;. Five total, in order: &lt;code&gt;low&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt;, &lt;code&gt;xhigh&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is a small change with a sharp edge. If anywhere in your stack you validate &lt;code&gt;reasoning_effort&lt;/code&gt; against a hardcoded enum, a TypeScript union, a Pydantic &lt;code&gt;Literal&lt;/code&gt;, a Zod schema, a JSON Schema &lt;code&gt;enum&lt;/code&gt;, or a database CHECK constraint — that validation now rejects valid values. The failure is in &lt;em&gt;your&lt;/em&gt; layer, not OpenAI’s, which is exactly the kind of break that costs an afternoon to find because the error message points at your own code and looks like a bug you introduced.&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot; data-language=&quot;ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Before&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;ReasoningEffort&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;low&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;medium&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;high&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// After&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;ReasoningEffort&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;low&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;medium&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;high&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;xhigh&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;max&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Grep for the string &lt;code&gt;&amp;#39;high&amp;#39;&lt;/code&gt; next to &lt;code&gt;reasoning&lt;/code&gt; across your codebase and your infrastructure config before you let anyone select Astra in a model picker.&lt;/p&gt;
&lt;p&gt;On cost: reasoning tokens are output tokens, billed at $50/M (or $75/M past the cliff). &lt;code&gt;max&lt;/code&gt; on a long-context request is the single most expensive combination available in the API right now. Reserve it for the architectural decision or the debugging session that has already eaten a day — not for the default in a settings file that nobody revisits.&lt;/p&gt;
&lt;h2 id=&quot;5-context-notes-replace-compaction--and-that-changes-agent-behavior&quot;&gt;5. Context notes replace compaction — and that changes agent behavior&lt;/h2&gt;
&lt;p&gt;This is the change I think is genuinely underrated, and it is not a benchmark number.&lt;/p&gt;
&lt;p&gt;Every long-running agent hits the same wall: the context fills, and the harness &lt;strong&gt;compacts&lt;/strong&gt; — it summarizes the conversation into a shorter one and continues. Compaction is lossy and the loss is irreversible. The exact error text from the third failed attempt, the approach you abandoned and why, the specific assertion that broke — those get flattened into “we tried several approaches to fix the auth bug.” Then the agent tries the abandoned approach again, because nothing in its context says it already failed.&lt;/p&gt;
&lt;p&gt;Astra replaces that with &lt;strong&gt;persistent notes plus searchable earlier context windows&lt;/strong&gt;. It keeps running notes across windows, and it can go back and search prior windows for something the note did not capture. In practice: the model can retrieve a requirement or a test result from two hours ago even if it did not think to write it down at the time.&lt;/p&gt;
&lt;p&gt;In Codex CLI (v0.153.1 or later, released 3 September 2026), the configuration is:&lt;/p&gt;
&lt;pre class=&quot;language-toml&quot; data-language=&quot;toml&quot;&gt;&lt;code class=&quot;language-toml&quot;&gt;&lt;span class=&quot;token key property&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;gpt-6-astra&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token key property&quot;&gt;model_provider&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;openai&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token key property&quot;&gt;model_reasoning_effort&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;high&amp;quot;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Let Astra&amp;#39;s own notes do the work instead of Codex&amp;#39;s compaction&lt;/span&gt;
&lt;span class=&quot;token key property&quot;&gt;auto_compact_token_limit&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;850000&lt;/span&gt;

&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token table class-name&quot;&gt;tui&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token key property&quot;&gt;auto_recap&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Per session: &lt;code&gt;codex -m gpt-6-astra --reasoning-effort xhigh &amp;quot;your task&amp;quot;&lt;/code&gt;, or &lt;code&gt;/model gpt-6-astra&lt;/code&gt; mid-session. Run &lt;code&gt;codex models&lt;/code&gt; to confirm your account actually has access — the picker only shows what your account is entitled to, so an absent model is a gating answer, not a bug.&lt;/p&gt;
&lt;p&gt;Note the tension with section 1: &lt;code&gt;auto_compact_token_limit = 850000&lt;/code&gt; deliberately lets context run well past 272K, because that is the point of the feature. You are trading money for continuity. That is a defensible trade for a hard debugging session and an indefensible one for a chatbot.&lt;/p&gt;
&lt;h2 id=&quot;6-reading-the-benchmarks-honestly&quot;&gt;6. Reading the benchmarks honestly&lt;/h2&gt;
&lt;p&gt;The scoreboard, with the comparisons that were published alongside it:&lt;/p&gt;





























































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Benchmark&lt;/th&gt;&lt;th&gt;GPT-6 Astra&lt;/th&gt;&lt;th&gt;GPT-5.6 Sol&lt;/th&gt;&lt;th&gt;Claude Opus 5&lt;/th&gt;&lt;th&gt;Claude Fable 5.1&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;OSWorld 2.0 (computer use)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;72.6%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;65.7%&lt;/td&gt;&lt;td&gt;70.2%&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Terminal-Bench 4.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;57.7%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;37.3%&lt;/td&gt;&lt;td&gt;52.3%&lt;/td&gt;&lt;td&gt;55.8%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;FrontierMath Tier 4 v2&lt;/td&gt;&lt;td&gt;&lt;strong&gt;97.6%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;83.0%&lt;/td&gt;&lt;td&gt;73.2%&lt;/td&gt;&lt;td&gt;87.8%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GPQA Diamond&lt;/td&gt;&lt;td&gt;&lt;strong&gt;96.0%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;94.6%&lt;/td&gt;&lt;td&gt;93.7%&lt;/td&gt;&lt;td&gt;93.7%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ExploitBench&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;78.5%&lt;/td&gt;&lt;td&gt;70.0%&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Humanity’s Last Exam (tools)&lt;/td&gt;&lt;td&gt;57.2%&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;63.6%&lt;/td&gt;&lt;td&gt;&lt;strong&gt;65.0%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DeepSWE v1.1 (coding)&lt;/td&gt;&lt;td&gt;74.1%&lt;/td&gt;&lt;td&gt;72.7%&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three honest readings:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The computer-use gain is the real story.&lt;/strong&gt; 72.6% on OSWorld with task completion time dropping from ~75 minutes to ~40 is a step change in what an agent can finish unattended. If your use case is “operate a browser, a spreadsheet and a terminal to complete a multi-step job,” this is a different class of tool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The coding gain is marginal.&lt;/strong&gt; 74.1% vs 72.7% on DeepSWE is inside the noise of a leaderboard where Gemini 3.8 Flash and Claude Opus 5 sit in the same band. If you are picking a model to write code, Astra is not obviously the answer, and at $10/$50 it is not the cheap answer either.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ARC-AGI-3 number is doing marketing work.&lt;/strong&gt; The headline 99.9% comes from an adapter harness. Stateless API calls — which is what your code makes — reportedly score somewhere between 17% and 63% depending on reasoning tier. Any benchmark quoted with a custom harness is a statement about the harness as much as the model.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Astra also trails Claude Fable 5.1 on Humanity’s Last Exam with tools (57.2% vs 65.0%). “Most capable model ever deployed” is a defensible claim on aggregate; it is not true on every axis, and the axes where it is not true include reasoning-with-tools, which is what agents do all day.&lt;/p&gt;
&lt;h2 id=&quot;the-part-most-people-reading-this-actually-need-23-october&quot;&gt;The part most people reading this actually need: 23 October&lt;/h2&gt;
&lt;p&gt;Here is where the launch hype and the real deadline collide.&lt;/p&gt;
&lt;p&gt;On &lt;strong&gt;23 October 2026&lt;/strong&gt;, OpenAI shuts down &lt;code&gt;gpt-3.5-turbo&lt;/code&gt;, &lt;code&gt;gpt-4&lt;/code&gt;, &lt;code&gt;gpt-4-turbo&lt;/code&gt;, &lt;code&gt;o1&lt;/code&gt;, &lt;code&gt;o1-pro&lt;/code&gt;, &lt;code&gt;o3-mini&lt;/code&gt; and &lt;code&gt;o4-mini&lt;/code&gt; in the API. After that date, calls to those IDs return errors, not responses. That is the breaking change on your calendar, and it is 48 days out from this post.&lt;/p&gt;
&lt;p&gt;Astra’s launch will push a lot of people to migrate straight to it. For most of them &lt;strong&gt;that is a costly mistake.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The typical thing wired to &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; is a support chatbot or a classifier built in 2023. &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; last published at roughly $0.50 per million input and $1.50 per million output. Astra is $10 and $50. That is &lt;strong&gt;20x the input cost and 33x the output cost&lt;/strong&gt; — for a workload that was chosen precisely because it was the cheap tier. Migrating a high-volume classifier from gpt-3.5 to Astra does not modernize it; it turns a $40/month line item into something you will notice.&lt;/p&gt;
&lt;p&gt;The migration ladder that actually makes sense:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;gpt-3.5-turbo&lt;/code&gt; chatbot or classifier →&lt;/strong&gt; the current small/mini tier. Cheaper than what you are on, and far more capable. Astra is not in the conversation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;gpt-4&lt;/code&gt; / &lt;code&gt;gpt-4-turbo&lt;/code&gt; general workload →&lt;/strong&gt; the current flagship mid tier. Test for prompt regressions; a decade of prompt-engineering folklore was tuned against gpt-4’s specific quirks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;o1&lt;/code&gt; / &lt;code&gt;o3-mini&lt;/code&gt; / &lt;code&gt;o4-mini&lt;/code&gt; reasoning workload →&lt;/strong&gt; this is where Astra is a genuine candidate, because you already accepted reasoning-token cost. Benchmark &lt;code&gt;high&lt;/code&gt; before you reach for &lt;code&gt;xhigh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Long-horizon agent that operates software →&lt;/strong&gt; Astra, and this is the case it was built for. Budget for section 1.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full calendar, the audit query to find out whether you are exposed, and the migration checklist are in &lt;a href=&quot;https://ortamarco.me/en/blog/ai-model-retirements-2026/&quot;&gt;the 2026 AI model shutdown calendar&lt;/a&gt; — including the two September dates that land before Astra is even generally available: the Videos API and &lt;code&gt;sora-2&lt;/code&gt; retire on 24 September, and legacy snapshots follow on 28 September.&lt;/p&gt;
&lt;h2 id=&quot;so-should-you-switch&quot;&gt;So should you switch?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You run a chatbot, a classifier, or anything high-volume and short-context:&lt;/strong&gt; no. Astra is the wrong price class. Go to the small tier, and go before 23 October.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You run a coding agent:&lt;/strong&gt; not on these numbers alone. The coding delta over the previous generation is marginal and the price is not. Try it on the hard tasks — the ones that currently fail — and keep the cheaper model as the default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You run a long-horizon agent that drives a browser, a terminal or a desktop:&lt;/strong&gt; yes, and this is the only category where the answer is straightforwardly yes. The OSWorld jump and the persistent-notes architecture are aimed exactly at you. Set a token ceiling first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You do defensive security work:&lt;/strong&gt; yes, and start the Trusted Access application now, because the standard API will refuse the work you need it for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You have anything fine-tuned:&lt;/strong&gt; Astra is not your path. Plan around that separately, with the January 2027 fine-tuning date on the same page.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/ai-model-retirements-2026/&quot;&gt;The 2026 AI model shutdown calendar&lt;/a&gt; — the 23 October deadline in full, and how to check whether your code is exposed.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/how-much-ai-coding-agents-cost/&quot;&gt;How much AI coding agents really cost&lt;/a&gt; — 7.97 billion measured tokens, and the arithmetic that makes the 272K cliff matter.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/local-llm-business-privacy/&quot;&gt;AI without sending data out: local LLMs&lt;/a&gt; — the other answer when the flagship price and the data-retention question both land badly.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/migrate-mcp-server-2026-07-28/&quot;&gt;Migrating your MCP server to the 2026-07-28 spec&lt;/a&gt; — Astra supports MCP and tool search; the spec underneath moved too.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much does the GPT-6 Astra API cost? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Standard pricing is $10 per million input tokens and $50 per million output tokens, with cached input at $1 per million and cache writes at $12.50 per million. Batch and Flex processing run at 50% of standard rates, and Fast mode costs 2x standard for up to roughly 2.5x the speed. Critically, any request with more than 272,000 input tokens is billed at 2x the input and cache rates and 1.5x the output rate for the entire request, not just for the tokens above the threshold. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is the GPT-6 Astra context window? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The context window is 1,050,000 tokens, with a maximum of 922,000 input tokens and 128,000 output tokens in a single request. The knowledge cutoff is 30 April 2026. Be aware that using more than 272,000 input tokens triggers a pricing surcharge that applies to the full request, so the usable window and the economically sensible window are not the same number. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why can I not see gpt-6-astra in my account? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Access is staged. At launch Astra went to a limited set of organizations in OpenAI&amp;#39;s Daybreak and Trusted Access programs, then to ChatGPT Plus, Pro, Business and Enterprise users, then to the API and Amazon Bedrock. Enterprise workspaces have it disabled by default and an administrator has to enable it. In Codex CLI, running &amp;quot;codex models&amp;quot; shows only what your account is entitled to, so an absent model usually means gating rather than a bug. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can I use GPT-6 Astra for security research or penetration testing? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not through standard API access. Astra is the first model OpenAI has rated at the Critical cybersecurity level under its Preparedness Framework, and standard access refuses advanced cybersecurity work such as exploit discovery outright rather than pausing it for review. Advanced capability is gated behind the Daybreak and Trusted Access programs. Accounts outside those programs may also see slowdowns, pauses or blocks, reportedly sometimes during unrelated work. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does GPT-6 Astra support fine-tuning? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. Astra supports Chat Completions, Responses and Batch, and does not support fine-tuning, Realtime, Assistants, embeddings, image generation, audio, moderation, or the legacy Completions endpoint. This matters alongside a separate OpenAI change: new self-serve fine-tuning job creation closes on 6 January 2027, so any product depending on a fine-tuned model needs a plan that does not route through Astra. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Should I migrate my gpt-3.5-turbo chatbot to GPT-6 Astra before 23 October 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Almost certainly not. gpt-3.5-turbo shuts down on 23 October 2026 along with gpt-4, gpt-4-turbo, o1, o1-pro, o3-mini and o4-mini, so you do have to move. But gpt-3.5-turbo last published at roughly $0.50 per million input and $1.50 per million output, against Astra&amp;#39;s $10 and $50 — around 20x the input cost and 33x the output cost. High-volume, short-context workloads like chatbots and classifiers belong on the current small or mini tier, which is both cheaper than gpt-3.5-turbo and considerably more capable. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What are the xhigh and max reasoning effort levels? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Astra adds two levels above high, giving five in total: low, medium, high, xhigh and max. Any code that validates reasoning_effort against a hardcoded list — a TypeScript union, a Zod or Pydantic schema, a JSON Schema enum, a database constraint — will reject the new values until it is updated, and the resulting error appears to come from your own code. Reasoning tokens are billed as output tokens, so max on a long-context request is the most expensive combination the API currently offers. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What are context notes and how do they differ from compaction? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Compaction summarizes a full conversation into a shorter one when the context window fills, which irreversibly loses detail such as exact error messages or approaches that were tried and abandoned. Astra instead keeps persistent notes across context windows and leaves earlier windows searchable, so it can retrieve a requirement or test result from an earlier window even when the note did not capture it. In Codex CLI v0.153.1 or later this is configured by raising auto_compact_token_limit so Astra&amp;#39;s own notes handle continuity instead of the harness compacting early. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>AI</category><category>OpenAI</category><category>API</category><category>Costs</category><category>Agents</category><category>Migration</category><author>Marco Orta</author></item><item><title>Kubernetes 1.37: What Breaks on Upgrade Day (and Why It&apos;s Your Control Plane)</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-kubernetes-1-37/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-kubernetes-1-37/</guid><description>v1.37 does not introduce the static Pod restriction — it removes the off switch. If you disabled PreventStaticPodAPIReferences in 1.34-1.36, etcd and kube-apiserver are what fail to start. Three blockers, with the audit commands.</description><pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Kubernetes v1.37 “Garhwal” shipped on 26 August 2026, and the headline breaking change is being reported wrong almost everywhere. v1.37 does not introduce the rule that static Pods cannot reference API objects — that landed in v1.34. What v1.37 removes is the escape hatch.&lt;/strong&gt; The &lt;code&gt;PreventStaticPodAPIReferences&lt;/code&gt; feature gate is gone, and per the upstream release note it “cannot be disabled anymore.”&lt;/p&gt;
&lt;p&gt;That inverts who is at risk. If you never hit this, you are fine. The clusters that break on upgrade day are precisely the ones that &lt;em&gt;already hit it&lt;/em&gt; in 1.34, 1.35 or 1.36, turned the gate off to get moving, and filed the real fix under later. Later is now, and the thing that fails to start is the control plane.&lt;/p&gt;
&lt;h2 id=&quot;the-three-blockers-ranked&quot;&gt;The three blockers, ranked&lt;/h2&gt;





























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;th&gt;Failure mode&lt;/th&gt;&lt;th&gt;Urgency&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;PreventStaticPodAPIReferences&lt;/code&gt; gate removed&lt;/td&gt;&lt;td&gt;kubelet &lt;strong&gt;denies admission&lt;/strong&gt; to static Pods referencing API objects — including etcd and kube-apiserver manifests&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Blocks upgrade&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;cgroup v1 refusal enforced&lt;/td&gt;&lt;td&gt;kubelet &lt;strong&gt;exits on start&lt;/strong&gt; unless &lt;code&gt;failCgroupV1: false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Blocks upgrade&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;kube-proxy &lt;code&gt;ipvs&lt;/code&gt; mode deprecated&lt;/td&gt;&lt;td&gt;Warning on startup only. Disabled by default in v1.40, removed in v1.43&lt;/td&gt;&lt;td&gt;Plan, don’t panic&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Everything else in this release — &lt;code&gt;kube-dns&lt;/code&gt; deprecated in favour of CoreDNS, &lt;code&gt;kubectl run --filename&lt;/code&gt;/&lt;code&gt;-f&lt;/code&gt; deprecated, sixteen features graduating to stable — is housekeeping. The two that stop a node from coming back are the first two.&lt;/p&gt;
&lt;h2 id=&quot;1-static-pods-can-no-longer-reference-api-objects&quot;&gt;1. Static Pods can no longer reference API objects&lt;/h2&gt;
&lt;h3 id=&quot;what-the-rule-actually-is&quot;&gt;What the rule actually is&lt;/h3&gt;
&lt;p&gt;The clean formulation is not “no Secrets or ConfigMaps.” It is the one from the implementing pull request: &lt;strong&gt;static Pods may only use &lt;code&gt;hostPath&lt;/code&gt; and &lt;code&gt;emptyDir&lt;/code&gt; volumes&lt;/strong&gt;, and may not reference API objects at all.&lt;/p&gt;
&lt;p&gt;The upstream release note for &lt;a href=&quot;https://github.com/kubernetes/kubernetes/pull/131837&quot;&gt;PR #131837&lt;/a&gt;, which carries an explicit &lt;code&gt;ACTION REQUIRED&lt;/code&gt;, names the full surface:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Prior to upgrade, ensure static pods are not referencing API objects such as &lt;strong&gt;ServiceAccounts, ConfigMaps, Secrets, ResourceClaims, CSIDrivers, PersistentVolumeClaims, or ClusterTrustBundles.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Note &lt;code&gt;ServiceAccounts&lt;/code&gt; in that list. Most of the secondary coverage of this release only mentions &lt;code&gt;configMapRef&lt;/code&gt; and &lt;code&gt;secretRef&lt;/code&gt;, which understates it: a static Pod with a &lt;code&gt;serviceAccountName&lt;/code&gt;, an &lt;code&gt;imagePullSecrets&lt;/code&gt; entry, or a PVC-backed volume is equally denied. In practice the fields to hunt for are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;env.valueFrom.configMapKeyRef&lt;/code&gt; and &lt;code&gt;env.valueFrom.secretKeyRef&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;envFrom.configMapRef&lt;/code&gt; and &lt;code&gt;envFrom.secretRef&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volumes[].configMap&lt;/code&gt;, &lt;code&gt;volumes[].secret&lt;/code&gt;, &lt;code&gt;volumes[].persistentVolumeClaim&lt;/code&gt;, &lt;code&gt;volumes[].projected&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;serviceAccountName&lt;/code&gt;, &lt;code&gt;imagePullSecrets&lt;/code&gt;, &lt;code&gt;resourceClaims&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-this-is-a-control-plane-problem-not-a-workload-problem&quot;&gt;Why this is a control-plane problem, not a workload problem&lt;/h3&gt;
&lt;p&gt;Static Pods are the ones the kubelet runs straight off disk from &lt;code&gt;/etc/kubernetes/manifests/&lt;/code&gt;, with no API server involved. On a kubeadm cluster that directory holds &lt;strong&gt;etcd, kube-apiserver, kube-controller-manager and kube-scheduler&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;So the failure mode is not “one of my Pods didn’t schedule.” It is: you upgrade a control-plane node, the kubelet refuses admission to the etcd manifest, and the API server never comes up to tell you why. You are debugging from &lt;code&gt;journalctl -u kubelet&lt;/code&gt; on the node, not from &lt;code&gt;kubectl&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-old-behaviour-was-silent-which-is-why-this-exists&quot;&gt;The old behaviour was silent, which is why this exists&lt;/h3&gt;
&lt;p&gt;Before v1.34 this did not error. The static Pod ran, and only the &lt;em&gt;mirror&lt;/em&gt; Pod — the read-only API representation the kubelet creates so the Pod shows up in &lt;code&gt;kubectl get pods&lt;/code&gt; — failed to reconcile. The container was live on the node and effectively invisible to the API. &lt;a href=&quot;https://github.com/kubernetes/kubernetes/pull/131837&quot;&gt;PR #131837&lt;/a&gt; closed that by denying admission outright, so the container is never created rather than running unobserved.&lt;/p&gt;
&lt;p&gt;That history is the reason the gate existed at all: reviewers asked for the validation to ship on by default but disableable “for a few releases.” Three release cycles later, &lt;a href=&quot;https://github.com/kubernetes/kubernetes/pull/140226&quot;&gt;PR #140226&lt;/a&gt; removed it, with milestone v1.37.&lt;/p&gt;
&lt;h3 id=&quot;the-audit-before-you-touch-a-single-node&quot;&gt;The audit, before you touch a single node&lt;/h3&gt;
&lt;p&gt;Run this on &lt;strong&gt;every&lt;/strong&gt; control-plane and worker node, not just one:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rlE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;configMapRef|secretRef|configMapKeyRef|secretKeyRef|serviceAccountName|imagePullSecrets|persistentVolumeClaim&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  /etc/kubernetes/manifests/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any file it prints is a manifest you have to rework before upgrading that node. The fix is always the same shape: stop pulling the value from the API and put it on disk, as a &lt;code&gt;hostPath&lt;/code&gt; mount or as a literal in the manifest, since &lt;code&gt;hostPath&lt;/code&gt; and &lt;code&gt;emptyDir&lt;/code&gt; are the only volume types still permitted.&lt;/p&gt;
&lt;p&gt;If your manifests are generated — kubeadm, Cluster API, a Helm chart that templates node config, an Ansible role — audit the template, not just the rendered output, or the next node you provision reintroduces the problem.&lt;/p&gt;
&lt;p&gt;Before you commit a reworked manifest, sanity check that it is still valid YAML and that you did not break indentation while ripping out a volume block:&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #f43f5e&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#f43f5e1a;color:#f43f5e&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; JSON and YAML Converter &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/json-yaml-converter/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;conversor-json-yaml&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;2-cgroup-v1-the-kubelet-just-exits&quot;&gt;2. cgroup v1: the kubelet just exits&lt;/h2&gt;
&lt;p&gt;This one is short and absolute. The kubelet refuses to run on a host using cgroup v1. It does not warn and degrade — it exits on start with:&lt;/p&gt;
&lt;pre class=&quot;language-plaintext&quot; data-language=&quot;plaintext&quot;&gt;&lt;code class=&quot;language-plaintext&quot;&gt;kubelet is configured to not run on a host using cgroup v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default flipped to failing in v1.35, and v1.37 keeps it enforced. The only override is in &lt;code&gt;KubeletConfiguration&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; data-language=&quot;yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;failCgroupV1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where you put it depends on your distribution, and the timing matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;kubeadm:&lt;/strong&gt; edit the &lt;code&gt;kube-system/kubelet-config&lt;/code&gt; ConfigMap and add the field &lt;strong&gt;before&lt;/strong&gt; you upgrade. Adding it after the kubelet has already refused to start means editing config on a node whose control plane may be down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RKE2 / K3s:&lt;/strong&gt; pass the kubelet argument &lt;code&gt;fail-cgroupv1=false&lt;/code&gt; in &lt;code&gt;/etc/rancher/rke2/config.yaml&lt;/code&gt; or &lt;code&gt;/etc/rancher/k3s/config.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Treat that as a bridge, not a fix. It is the same category of decision as the static-Pod gate you are reading this post because of — an off switch that upstream is winding down.&lt;/p&gt;
&lt;p&gt;Check which cgroup version a node is on before you upgrade it:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;stat&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-fc&lt;/span&gt; %T /sys/fs/cgroup
&lt;span class=&quot;token comment&quot;&gt;# cgroup2fs -&amp;gt; v2, you are fine&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# tmpfs     -&amp;gt; v1, you are not&lt;/span&gt;

&lt;span class=&quot;token function&quot;&gt;ls&lt;/span&gt; /sys/fs/cgroup/cgroup.controllers   &lt;span class=&quot;token comment&quot;&gt;# exists only on v2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The nodes that trip this are the old ones: long-lived CentOS 7 and Ubuntu 18.04 hosts, and anything provisioned from an AMI or image built years ago and never rebuilt. If you run a managed service (EKS, GKE, AKS) on current node images you are almost certainly on v2 already — but “almost certainly” is a &lt;code&gt;stat&lt;/code&gt; away from certain.&lt;/p&gt;
&lt;h2 id=&quot;3-kube-proxy-ipvs-deprecated-not-removed&quot;&gt;3. kube-proxy IPVS: deprecated, not removed&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;mode: ipvs&lt;/code&gt; in &lt;code&gt;KubeProxyConfiguration&lt;/code&gt; is deprecated as of v1.37 (&lt;a href=&quot;https://github.com/kubernetes/enhancements/issues/5495&quot;&gt;KEP-5495&lt;/a&gt;). Nothing breaks today; you get warnings in the kube-proxy startup logs. The timeline:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Release&lt;/th&gt;&lt;th&gt;State&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;v1.35&lt;/td&gt;&lt;td&gt;Warning logs begin&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;v1.37&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Formally deprecated, warnings continue&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;v1.40&lt;/td&gt;&lt;td&gt;Disabled by default, re-enabled only via feature gate&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;v1.43&lt;/td&gt;&lt;td&gt;Code removed entirely&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The rationale is worth knowing because it kills the usual objection: IPVS mode was never a full replacement for iptables — it still requires iptables underneath and cannot fully implement Kubernetes Services on its own. The successor is &lt;code&gt;nftables&lt;/code&gt;, not a return to &lt;code&gt;iptables&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Check what you are running:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; kube-system get cm kube-proxy &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token parameter variable&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;jsonpath&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;{.data.config\.conf}&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; mode:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it says &lt;code&gt;ipvs&lt;/code&gt;, you have from now until v1.40 to move to &lt;code&gt;mode: nftables&lt;/code&gt;. That is roughly a year of releases — enough to do it on your own schedule, which is exactly why you should put it on the schedule now instead of meeting it the way people are meeting the static-Pod gate this month.&lt;/p&gt;
&lt;h2 id=&quot;the-pre-upgrade-checklist&quot;&gt;The pre-upgrade checklist&lt;/h2&gt;
&lt;p&gt;Run all of this before the first node, on every node:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# 1. Static Pod API references — the upgrade blocker&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rlE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;configMapRef|secretRef|configMapKeyRef|secretKeyRef|serviceAccountName|imagePullSecrets|persistentVolumeClaim&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  /etc/kubernetes/manifests/

&lt;span class=&quot;token comment&quot;&gt;# 2. cgroup version — the other upgrade blocker&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;stat&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-fc&lt;/span&gt; %T /sys/fs/cgroup

&lt;span class=&quot;token comment&quot;&gt;# 3. kube-proxy mode — plan, not block&lt;/span&gt;
kubectl &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; kube-system get cm kube-proxy &lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;token parameter variable&quot;&gt;-o&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;jsonpath&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;{.data.config\.conf}&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; mode:

&lt;span class=&quot;token comment&quot;&gt;# 4. Are you still on kube-dns instead of CoreDNS?&lt;/span&gt;
kubectl &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; kube-system get deploy &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-E&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;kube-dns|coredns&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Empty output from 1, &lt;code&gt;cgroup2fs&lt;/code&gt; from 2, and &lt;code&gt;nftables&lt;/code&gt; or &lt;code&gt;iptables&lt;/code&gt; from 3 means the upgrade is boring. That is the goal.&lt;/p&gt;
&lt;h2 id=&quot;who-actually-needs-to-act&quot;&gt;Who actually needs to act&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You disabled &lt;code&gt;PreventStaticPodAPIReferences&lt;/code&gt; at any point since v1.34:&lt;/strong&gt; you are the target audience for this entire post. That gate no longer exists, so whatever made you disable it now blocks the upgrade. Fix the manifests first, upgrade second.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You run kubeadm control planes you have hand-edited:&lt;/strong&gt; run the grep on the control-plane nodes specifically. Hand-edited &lt;code&gt;/etc/kubernetes/manifests/&lt;/code&gt; files are where &lt;code&gt;secretRef&lt;/code&gt; and &lt;code&gt;serviceAccountName&lt;/code&gt; get added by someone solving a problem at 2am.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You run nodes older than about three years:&lt;/strong&gt; check cgroups before anything else. A kubelet that exits on start looks identical to a broken upgrade, and you will lose an hour finding a one-line answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You are on managed Kubernetes with current node images:&lt;/strong&gt; you are probably clear on 1 and 2. Check 3 and put the IPVS migration in a quarter, not a sprint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You run &lt;code&gt;mode: ipvs&lt;/code&gt;:&lt;/strong&gt; nothing breaks now. Schedule the move to &lt;code&gt;nftables&lt;/code&gt; before v1.40.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/&quot;&gt;What breaks upgrading to Node 26&lt;/a&gt; — same format for the runtime, and the same pattern of failures that produce no error.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-vite-8/&quot;&gt;What breaks upgrading to Vite 8&lt;/a&gt; — the version you inherited without choosing it, which is the other way these upgrades bite.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/gpt-6-astra-api-developers/&quot;&gt;GPT-6 Astra for developers&lt;/a&gt; — if you are running agents with shell access against a cluster, the safety and approval-policy section applies directly.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What breaks when upgrading to Kubernetes 1.37? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Two changes can block the upgrade outright. First, the PreventStaticPodAPIReferences feature gate was removed, so static Pods that reference API objects are denied admission by the kubelet with no way to opt out — and on kubeadm clusters those static Pods include etcd and kube-apiserver. Second, the kubelet refuses to start on hosts using cgroup v1 unless failCgroupV1 is explicitly set to false. A third change, the deprecation of kube-proxy IPVS mode, only produces warnings in v1.37 and does not break anything yet. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why do static Pods fail after upgrading to Kubernetes 1.37? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Because the escape hatch was removed, not because the rule is new. The restriction on static Pods referencing API objects shipped in v1.34 behind the PreventStaticPodAPIReferences feature gate, which was enabled by default but could be disabled. Kubernetes v1.37 removed that gate entirely, and the upstream release note states it cannot be disabled anymore. Clusters that turned the gate off to defer the fix are exactly the ones that break on upgrade. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Which API objects can a static Pod not reference in Kubernetes 1.37? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The upstream release note lists ServiceAccounts, ConfigMaps, Secrets, ResourceClaims, CSIDrivers, PersistentVolumeClaims and ClusterTrustBundles. The simplest way to hold the rule is from the implementing pull request: static Pods may only use hostPath and emptyDir volumes. That means serviceAccountName, imagePullSecrets, envFrom.configMapRef, envFrom.secretRef, env.valueFrom.secretKeyRef, projected volumes and PVC-backed volumes are all rejected, not just the ConfigMap and Secret references most summaries mention. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How do I check whether my cluster is affected before upgrading? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; On every control-plane and worker node, run: grep -rlE &amp;#39;configMapRef|secretRef|configMapKeyRef|secretKeyRef|serviceAccountName|imagePullSecrets|persistentVolumeClaim&amp;#39; /etc/kubernetes/manifests/ — any file it prints must be reworked before that node is upgraded. Separately run stat -fc %T /sys/fs/cgroup on each node; cgroup2fs is safe and tmpfs means cgroup v1, which stops the kubelet from starting. If your manifests are generated by kubeadm, Cluster API, Ansible or Helm, audit the template as well or newly provisioned nodes will reintroduce the problem. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is kube-proxy IPVS mode removed in Kubernetes 1.37? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. It is deprecated in v1.37 under KEP-5495 and only logs warnings at startup. It is expected to be disabled by default in v1.40, where it can still be re-enabled through a feature gate, and removed entirely in v1.43. The replacement is nftables mode, not iptables. The reasoning is that IPVS mode always required iptables underneath and could never fully implement Kubernetes Services on its own. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What happened to static Pods referencing Secrets before Kubernetes 1.34? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It failed silently, which is why the restriction exists. The static Pod would run on the node, but the mirror Pod — the read-only API representation that makes the Pod visible to kubectl — failed to reconcile. The container was live and effectively invisible to the API server. PR #131837 changed this to deny admission outright so the container is never created, rather than running unobserved. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When was Kubernetes 1.37 released and what is it called? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Kubernetes v1.37, codenamed &amp;quot;Garhwal&amp;quot;, reached general availability on 26 August 2026. Alongside the deprecations it graduated sixteen features to stable, including ResourceClaim device status (KEP-4817), device taints and tolerations (KEP-5055), resource health status for Pods (KEP-4680) and SELinuxMount with SELinuxChangePolicy (KEP-1710). It also deprecated the kube-dns subproject in favour of CoreDNS and the --filename flag on kubectl run. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Kubernetes</category><category>DevOps</category><category>Infrastructure</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>ESLint 10: What Breaks When You Upgrade (and Why ESLint 9 Is Already Unsafe)</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-eslint-10/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-eslint-10/</guid><description>ESLint 9 hit end of life on August 6, 2026. ESLint 10 deletes eslintrc completely — here&apos;s what breaks, what breaks silently, and how to migrate today.</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;ESLint 9 reached end of life on August 6, 2026. If your CI is still running &lt;code&gt;eslint@9&lt;/code&gt;, it has been running without security patches for four weeks, and nobody is going to tell you.&lt;/strong&gt; ESLint 10 went GA on February 6, 2026, which means the six-month overlap window the project always gives you is already gone. The headline change is not a new rule or a faster parser — it’s that the &lt;code&gt;eslintrc&lt;/code&gt; configuration system, the one built around &lt;code&gt;.eslintrc.json&lt;/code&gt; and &lt;code&gt;.eslintrc.js&lt;/code&gt;, has been deleted from the codebase. Not deprecated. Not gated behind a flag. Deleted. There is exactly one way to configure ESLint 10: &lt;code&gt;eslint.config.js&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This matters more than most major-version bumps because ESLint has been telegraphing this exact removal since flat config shipped as the default in v9, back in April 2024. Teams had two years of warning. A lot of them used that time to do nothing, because the old config kept working. It stops working now.&lt;/p&gt;
&lt;h2 id=&quot;where-are-you-coming-from&quot;&gt;Where are you coming from?&lt;/h2&gt;
&lt;p&gt;The migration cost splits hard across three starting points.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;From ESLint 9 with flat config already in place.&lt;/strong&gt; You’re in the best position. Your &lt;code&gt;eslint.config.js&lt;/code&gt; already exists and already works the way ESLint 10 expects. What still hits you: the Node.js version floor moved, the default rule set gained three rules you haven’t seen before, and — if you’re in a monorepo — the config-file lookup algorithm changed in a way that can silently change which rules apply to which files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;From ESLint 9 still leaning on &lt;code&gt;@eslint/eslintrc&lt;/code&gt;’s &lt;code&gt;FlatCompat&lt;/code&gt; shim.&lt;/strong&gt; This was the pragmatic middle path a lot of teams took in 2024–2025: write an &lt;code&gt;eslint.config.js&lt;/code&gt;, but use &lt;code&gt;FlatCompat.extends()&lt;/code&gt; inside it to keep pulling in an old-style shareable config (&lt;code&gt;airbnb-base&lt;/code&gt;, an internal &lt;code&gt;eslint-config-company&lt;/code&gt; package) that never got a flat-config release. The good news, and it’s not obvious, is that this path still works on ESLint 10 — &lt;code&gt;FlatCompat&lt;/code&gt; didn’t die with &lt;code&gt;eslintrc&lt;/code&gt;, it’s a separate package and it’s still maintained. You do need to make sure &lt;code&gt;@eslint/eslintrc&lt;/code&gt; itself is on a current version, since old copies predate ESLint 10’s internals.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;From ESLint 8, or from ESLint 9 with no &lt;code&gt;eslint.config.js&lt;/code&gt; at all&lt;/strong&gt; (meaning you were relying on ESLint 9’s automatic fallback to the legacy format when it doesn’t find a flat config). This is the group that breaks hardest, because that fallback is exactly what got removed. Point ESLint 10 at a project with only &lt;code&gt;.eslintrc.json&lt;/code&gt; and no &lt;code&gt;eslint.config.js&lt;/code&gt;, and it won’t quietly use the old file — it will fail to find any configuration at all.&lt;/p&gt;
&lt;h2 id=&quot;the-breaking-changes-ranked-by-how-much-theyll-cost-you&quot;&gt;The breaking changes, ranked by how much they’ll cost you&lt;/h2&gt;
&lt;h3 id=&quot;1-eslintrc-is-gone-there-is-no-flag-to-bring-it-back&quot;&gt;1. &lt;code&gt;eslintrc&lt;/code&gt; is gone. There is no flag to bring it back.&lt;/h3&gt;
&lt;p&gt;This is the one everyone half-expects and still gets bitten by, because the actual removal is broader than “the old file format stops being read.” Four things go away together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.eslintrc.js&lt;/code&gt;, &lt;code&gt;.eslintrc.json&lt;/code&gt;, &lt;code&gt;.eslintrc.yml&lt;/code&gt;, and &lt;code&gt;.eslintignore&lt;/code&gt; are no longer read, full stop.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ESLINT_USE_FLAT_CONFIG&lt;/code&gt; environment variable, which used to let you force ESLint 9 back onto the legacy system, is no longer honored. Setting it does nothing — it’s not an error, it’s just ignored.&lt;/li&gt;
&lt;li&gt;The CLI flags that only made sense for eslintrc are gone: &lt;code&gt;--no-eslintrc&lt;/code&gt;, &lt;code&gt;--env&lt;/code&gt;, &lt;code&gt;--resolve-plugins-relative-to&lt;/code&gt;, &lt;code&gt;--rulesdir&lt;/code&gt;, &lt;code&gt;--ignore-path&lt;/code&gt;. Scripts that pass these now fail to start.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Linter&lt;/code&gt;’s &lt;code&gt;configType&lt;/code&gt; constructor option only accepts &lt;code&gt;&amp;quot;flat&amp;quot;&lt;/code&gt;. Pass &lt;code&gt;&amp;quot;eslintrc&amp;quot;&lt;/code&gt; and it throws.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The migration itself, from &lt;code&gt;.eslintrc.json&lt;/code&gt; to &lt;code&gt;eslint.config.js&lt;/code&gt;, is mechanical once you’ve done it once. Here’s a real one, before and after:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before — &lt;code&gt;.eslintrc.json&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; data-language=&quot;json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;root&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;env&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;browser&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;es2021&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;node&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;extends&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;eslint:recommended&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;plugin:@typescript-eslint/recommended&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;parser&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;@typescript-eslint/parser&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;parserOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;ecmaVersion&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;latest&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;sourceType&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;module&amp;quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;plugins&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;@typescript-eslint&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;rules&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;no-unused-vars&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;off&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;@typescript-eslint/no-unused-vars&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;warn&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;&amp;quot;argsIgnorePattern&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;^_&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;ignorePatterns&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;quot;dist&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;node_modules&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After — &lt;code&gt;eslint.config.js&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; js &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;@eslint/js&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; globals &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;globals&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; tseslint &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;typescript-eslint&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; tseslint&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;ignores&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;dist/**&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;node_modules/**&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  js&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;configs&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;recommended&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;tseslint&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;configs&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;recommended&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;languageOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;globals&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;globals&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;browser&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;globals&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;node&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token string-property property&quot;&gt;&amp;#39;@typescript-eslint/no-unused-vars&amp;#39;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;warn&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;argsIgnorePattern&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;^_&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things changed shape, not just syntax: &lt;code&gt;env&lt;/code&gt; became &lt;code&gt;languageOptions.globals&lt;/code&gt; pulled from the &lt;code&gt;globals&lt;/code&gt; package, &lt;code&gt;extends&lt;/code&gt; became an array you spread in directly (&lt;code&gt;tseslint.configs.recommended&lt;/code&gt; is already an array of config objects), and &lt;code&gt;no-unused-vars: off&lt;/code&gt; disappeared — &lt;code&gt;@typescript-eslint/no-unused-vars&lt;/code&gt; doesn’t need you to manually silence the base rule anymore because &lt;code&gt;tseslint.configs.recommended&lt;/code&gt; already scopes it correctly per file.&lt;/p&gt;
&lt;p&gt;If your only remaining blocker is a shareable config with no flat-config release, that’s what &lt;code&gt;FlatCompat&lt;/code&gt; is for:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; FlatCompat &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;@eslint/eslintrc&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; path &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;node:path&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; fileURLToPath &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;node:url&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; compat &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;FlatCompat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;baseDirectory&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; path&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;dirname&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;fileURLToPath&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;meta&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;url&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
  &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;compat&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;extends&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;airbnb-base&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token comment&quot;&gt;// the rest of your flat config&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s still a real &lt;code&gt;eslint.config.js&lt;/code&gt; — &lt;code&gt;FlatCompat&lt;/code&gt; just lets one old-format shareable config live inside it. It’s a bridge, not eslintrc coming back.&lt;/p&gt;
&lt;h3 id=&quot;2-the-config-lookup-algorithm-changed--silent-in-monorepos&quot;&gt;2. The config-lookup algorithm changed — silent in monorepos&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;This one is silent by design, and it’s the change most likely to bite people who did everything “right.”&lt;/strong&gt; ESLint 10 stopped resolving &lt;code&gt;eslint.config.*&lt;/code&gt; from the current working directory. Instead it starts the search from the directory of &lt;em&gt;each file being linted&lt;/em&gt; and walks upward. The stated goal is to let a monorepo have a different &lt;code&gt;eslint.config.js&lt;/code&gt; per package without extra wiring — which is genuinely useful. The cost is that a file can now silently pick up a different config than it did on ESLint 9, with no warning and no error: the lint just passes or fails differently than before, and the reason is a config file two directories away that you forgot existed. If you run a monorepo, lint every workspace individually right after the upgrade and diff the output against ESLint 9 before you trust it.&lt;/p&gt;
&lt;h3 id=&quot;3-three-rules-just-turned-on-in-eslintrecommended--also-silent&quot;&gt;3. Three rules just turned on in &lt;code&gt;eslint:recommended&lt;/code&gt; — also silent&lt;/h3&gt;
&lt;p&gt;If your config includes &lt;code&gt;js.configs.recommended&lt;/code&gt; (formerly &lt;code&gt;eslint:recommended&lt;/code&gt;), upgrading adds &lt;code&gt;no-unassigned-vars&lt;/code&gt;, &lt;code&gt;no-useless-assignment&lt;/code&gt;, and &lt;code&gt;preserve-caught-error&lt;/code&gt; to what you’re enforcing — without you touching a single line of your own config. &lt;strong&gt;This is the classic silent break: you run the exact command you always run, and it now fails or warns on code that hasn’t changed.&lt;/strong&gt; If your CI treats warnings as failures, that’s a red build the morning after the bump, with a diff that shows nothing you did wrong.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;no-useless-assignment&lt;/code&gt; catches the value that’s overwritten before it’s ever read:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;items&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; sum &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// this assignment...&lt;/span&gt;
  sum &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; items&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;reduce&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;a&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; b&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; a &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; b&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// ...is immediately replaced, never read&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; sum&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;preserve-caught-error&lt;/code&gt; flags a caught error you throw away instead of chaining:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// before — the original error and its stack are gone&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;parseConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;raw&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;Config parsing failed&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// after — the original cause survives for whoever debugs this later&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;parseConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;raw&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;Config parsing failed&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;cause&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; err &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;None of this is wrong to enforce — it’s good advice. The problem is finding out about it from a failed deploy instead of from a changelog.&lt;/p&gt;
&lt;h3 id=&quot;4--eslint-env--comments-go-from-silently-inert-to-an-explicit-error&quot;&gt;4. &lt;code&gt;/* eslint-env */&lt;/code&gt; comments go from silently inert to an explicit error&lt;/h3&gt;
&lt;p&gt;This one has a two-step history worth knowing. Flat config never supported &lt;code&gt;/* eslint-env browser */&lt;/code&gt;-style comments — they’ve been silently doing nothing since you moved to flat config on ESLint 9, whether or not you noticed. ESLint 10 closes that silence: the same comment is now reported as a lint error instead of being quietly ignored. If you never migrated those comments to &lt;code&gt;languageOptions.globals&lt;/code&gt;, you’ll see brand-new errors pointing at code that “worked” for the last year and a half, because the globals it needs were never actually being declared — the comment just wasn’t doing anything.&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;/* eslint-env browser, node */&lt;/span&gt;
window&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;dispatchEvent&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Event&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;ready&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Move the intent into the config instead, the same way the &lt;code&gt;.eslintrc&lt;/code&gt; example above did with &lt;code&gt;env&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;languageOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;globals&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;globals&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;browser&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;globals&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;node &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;5-the-nodejs-floor-moved-and-its-not-just-a-version-number-bump&quot;&gt;5. The Node.js floor moved, and it’s not just a version-number bump&lt;/h3&gt;
&lt;p&gt;ESLint 10 requires Node.js &lt;code&gt;^20.19.0 || ^22.13.0 || &amp;gt;=24&lt;/code&gt;. Node 21.x and 23.x — the odd-numbered non-LTS releases — are unsupported outright, and anything below 20.19.0 or 22.13.0 is unsupported even within an otherwise-fine major. In &lt;code&gt;package.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; data-language=&quot;json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// before&lt;/span&gt;
&lt;span class=&quot;token property&quot;&gt;&amp;quot;engines&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;&amp;quot;node&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;&amp;gt;=18&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// after&lt;/span&gt;
&lt;span class=&quot;token property&quot;&gt;&amp;quot;engines&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;&amp;quot;node&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;^20.19.0 || ^22.13.0 || &amp;gt;=24&amp;quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your CI image pins an older Node patch inside 20.x or 22.x, &lt;code&gt;npm install&lt;/code&gt; may still succeed while the linter itself fails at startup with an error that has nothing obviously to do with Node — install ESLint and it’s already broken before it reads a single file.&lt;/p&gt;
&lt;h3 id=&quot;6-removed-context-and-sourcecode-methods-break-custom-rules-and-old-plugins&quot;&gt;6. Removed &lt;code&gt;context&lt;/code&gt; and &lt;code&gt;SourceCode&lt;/code&gt; methods break custom rules and old plugins&lt;/h3&gt;
&lt;p&gt;If you write custom rules, or depend on an internal plugin nobody has touched since 2023, this is where it bites: &lt;code&gt;context.getCwd()&lt;/code&gt;, &lt;code&gt;context.getFilename()&lt;/code&gt;, &lt;code&gt;context.getPhysicalFilename()&lt;/code&gt;, &lt;code&gt;context.getSourceCode()&lt;/code&gt;, &lt;code&gt;context.parserOptions&lt;/code&gt;, and &lt;code&gt;context.parserPath&lt;/code&gt; are all gone, along with &lt;code&gt;SourceCode#getTokenOrCommentBefore()&lt;/code&gt;, &lt;code&gt;getTokenOrCommentAfter()&lt;/code&gt;, &lt;code&gt;isSpaceBetweenTokens()&lt;/code&gt;, and &lt;code&gt;getJSDocComment()&lt;/code&gt;. The &lt;code&gt;Linter&lt;/code&gt; class itself lost &lt;code&gt;defineParser()&lt;/code&gt;, &lt;code&gt;defineRule()&lt;/code&gt;, &lt;code&gt;defineRules()&lt;/code&gt;, and &lt;code&gt;getRules()&lt;/code&gt;. Replacements exist for the ones that matter (&lt;code&gt;context.cwd&lt;/code&gt;, &lt;code&gt;context.filename&lt;/code&gt;, &lt;code&gt;context.sourceCode&lt;/code&gt;, &lt;code&gt;isSpaceBetween()&lt;/code&gt;), but a rule that only calls the removed API on a rare code path won’t throw until that path runs — which can mean it passes CI for weeks and then fails on one specific file.&lt;/p&gt;
&lt;h3 id=&quot;7-eslintconfigts-needs-a-current-jiti&quot;&gt;7. &lt;code&gt;eslint.config.ts&lt;/code&gt; needs a current &lt;code&gt;jiti&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;ESLint 10 drops support for &lt;code&gt;jiti&lt;/code&gt; versions before 2.2.0, which matters only if you write your config in TypeScript (&lt;code&gt;eslint.config.ts&lt;/code&gt;) and let &lt;code&gt;jiti&lt;/code&gt; transpile it on the fly. Under 2.2.0, the failure doesn’t say “upgrade jiti” — it surfaces as an unrelated module-resolution error, which sends most people down the wrong debugging path first.&lt;/p&gt;
&lt;h2 id=&quot;what-doesnt-break&quot;&gt;What doesn’t break&lt;/h2&gt;
&lt;p&gt;Worth saying plainly, because the framing of “everything breaks” isn’t accurate either. &lt;code&gt;typescript-eslint&lt;/code&gt; already supports ESLint 10 (its stated peer range is &lt;code&gt;^8.57.0 || ^9.0.0 || ^10.0.0&lt;/code&gt;), so a project on modern &lt;code&gt;typescript-eslint&lt;/code&gt; doesn’t need to touch it for this upgrade. And &lt;code&gt;FlatCompat&lt;/code&gt; — the actual escape hatch, as covered above — is alive and shipping.&lt;/p&gt;
&lt;p&gt;The one place this stack forces your hand is Astro. &lt;code&gt;eslint-plugin-astro&lt;/code&gt;’s current release requires ESLint 10 outright, so an Astro project that upgrades the Astro plugin for any other reason drags ESLint along with it whether the ESLint bump was planned or not. Check both together before you touch either.&lt;/p&gt;
&lt;h2 id=&quot;when-should-you-upgrade&quot;&gt;When should you upgrade?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Right now, if you’re still on ESLint 9 or earlier.&lt;/strong&gt; The EOL already happened a month ago as of this writing; there’s no “wait for the dust to settle” version of that argument left.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This week, if you’re on ESLint 9 with flat config already migrated.&lt;/strong&gt; Your risk surface is the monorepo lookup change and the three new default rules — both are a lint run away from being visible, not a rewrite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;After an audit, if you maintain custom rules or an internal plugin.&lt;/strong&gt; Grep your rule implementations for &lt;code&gt;getCwd&lt;/code&gt;, &lt;code&gt;getFilename&lt;/code&gt;, &lt;code&gt;getSourceCode&lt;/code&gt;, and &lt;code&gt;parserOptions&lt;/code&gt; before you bump the version, not after CI tells you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In a dedicated branch first, if you run a monorepo.&lt;/strong&gt; Lint each workspace in isolation, diff the results against ESLint 9, and only then merge — the config-lookup change is exactly the kind of thing that looks fine locally and misbehaves in whichever package you didn’t personally test.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With &lt;code&gt;FlatCompat&lt;/code&gt; as a bridge, if you depend on a shareable config that never shipped flat support.&lt;/strong&gt; That’s not a reason to stay on an unsupported major — it’s the tool that lets you upgrade today and finish the real migration later.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-typescript-7/&quot;&gt;What breaks when you upgrade to TypeScript 7&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/&quot;&gt;What breaks when you upgrade to Node 26&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/upgrade-to-astro-7/&quot;&gt;Upgrading to Astro 7&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When did ESLint 9 reach end of life? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; August 6, 2026. That is six months after ESLint 10 went GA, which is the standard support window the ESLint project gives to the previous major version. After that date, ESLint 9.x no longer receives security patches or bug fixes. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When was ESLint 10 released? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; ESLint 10.0.0 reached general availability on February 6, 2026, after an alpha in November 2025. As of this writing the current patch line is in the 10.4.x range. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can I still use .eslintrc.json with ESLint 10? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. ESLint 10 does not read .eslintrc.js, .eslintrc.json, .eslintrc.yml, or .eslintignore under any configuration or environment variable. The ESLINT_USE_FLAT_CONFIG variable that used to force the old behavior on ESLint 9 is no longer honored. The only supported configuration file is eslint.config.js (or .mjs/.ts/.cjs). &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does FlatCompat still work on ESLint 10? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. FlatCompat ships from the separate @eslint/eslintrc package and still works on ESLint 10, letting you pull an old-format shareable config into a real eslint.config.js with compat.extends(). Make sure @eslint/eslintrc itself is on a current version, since old copies predate ESLint 10. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is the minimum Node.js version for ESLint 10? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Node.js ^20.19.0, ^22.13.0, or 24 and above. Node 21.x and 23.x are unsupported, and so is any patch version below 20.19.0 or 22.13.0 within an otherwise-supported major. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does typescript-eslint support ESLint 10? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. The current typescript-eslint release states a peer dependency range of ^8.57.0 || ^9.0.0 || ^10.0.0, so a project already on a recent typescript-eslint version does not need any changes there to move to ESLint 10. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>JavaScript</category><category>TypeScript</category><category>ESLint</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>Pest 5 and PHPUnit 13: What Breaks When You Upgrade (and What Fails Silently)</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-pest-5/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-pest-5/</guid><description>Pest 5 needs a one-line composer.json bump. The real work is PHPUnit 13, and the part that hurts doesn&apos;t throw an error — it just stops verifying anything.</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Pest 5 shipped at the end of July 2026, built directly on top of PHPUnit 13, and requires PHP 8.4 as a hard minimum.&lt;/strong&gt; Pest itself introduces no API-level breaking changes — the pain is entirely inherited from PHPUnit 13’s stricter mocking rules. The part that actually hurts teams is not a fatal error: it’s that PHPUnit 13 turns several common mocking patterns into deprecation warnings instead of failures, so a suite can go fully green while quietly no longer verifying what you think it verifies. If you’re on Laravel 12, there’s a second, blunter problem: the official Pest Laravel plugin for v5 currently requires Laravel 13.23 or newer, so the plugin itself won’t install.&lt;/p&gt;
&lt;h2 id=&quot;where-are-you-coming-from&quot;&gt;Where are you coming from?&lt;/h2&gt;
&lt;p&gt;The upgrade effort isn’t uniform. It depends on how many PHPUnit majors you’re jumping in one move.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pest 4 (PHPUnit 12) → Pest 5 (PHPUnit 13).&lt;/strong&gt; This is the intended, smallest jump. Pest 4 already required PHP 8.3, so the only hard floor to raise is PHP 8.3 → 8.4. You’re absorbing one wave of PHPUnit deprecations-to-removals (12 → 13).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pest 3 or earlier (PHPUnit 11 or older) → Pest 5.&lt;/strong&gt; You’re absorbing two stacked waves at once: PHPUnit 11 → 12 already removed docblock-annotation metadata (you need PHP 8 attributes: &lt;code&gt;#[Test]&lt;/code&gt;, &lt;code&gt;#[DataProvider]&lt;/code&gt;, etc.) and mock objects for abstract classes/traits, on top of everything PHPUnit 13 changes below. Skipping straight from 3 to 5 in one &lt;code&gt;composer update&lt;/code&gt; is how a 20-minute upgrade becomes a two-day one — go through 4 first, or at least test the 12 jump in isolation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Raw PHPUnit, no Pest, on PHPUnit 12.5.&lt;/strong&gt; PHPUnit’s own upgrade rule is explicit: don’t move to 13 if your suite doesn’t already run clean on 12.5. If &lt;code&gt;phpunit --display-deprecations&lt;/code&gt; shows anything, fix that first, in place, before touching the Pest or PHPUnit version constraint.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-floor-you-cant-negotiate-php-84&quot;&gt;The floor you can’t negotiate: PHP 8.4&lt;/h2&gt;
&lt;p&gt;Pest 5 requires &lt;strong&gt;PHP 8.4.0 or greater&lt;/strong&gt;, full stop. There’s no “works in practice on 8.3” escape hatch like some Laravel version bumps have. If your team is still on PHP 8.1, 8.2, or 8.3, that’s infrastructure work that happens &lt;em&gt;before&lt;/em&gt; you touch &lt;code&gt;composer.json&lt;/code&gt; for Pest — not alongside it.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt;          &lt;span class=&quot;token comment&quot;&gt;# need 8.4.0 or higher&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do the PHP bump as its own deploy, verified green on the old test stack, before you also swap the test stack. Otherwise a red CI run after the upgrade tells you nothing about which of the two changes caused it.&lt;/p&gt;
&lt;h2 id=&quot;breaking-changes-ranked-by-what-actually-hurts&quot;&gt;Breaking changes, ranked by what actually hurts&lt;/h2&gt;
&lt;h3 id=&quot;1-laravel-12-cant-use-the-pest-5-laravel-plugin-blocking-not-silent&quot;&gt;1. Laravel 12 can’t use the Pest 5 Laravel plugin (blocking, not silent)&lt;/h3&gt;
&lt;p&gt;This is the one nobody mentions in the “it’s just a one-line bump” framing, and it’s the first thing that will stop a Laravel shop cold. &lt;code&gt;pestphp/pest-plugin-laravel&lt;/code&gt; v5.0.1 declares:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; data-language=&quot;json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&amp;quot;require&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;php&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;^8.4&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;laravel/framework&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;^13.23.0&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&amp;quot;pestphp/pest&amp;quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;^5.0.1&amp;quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your app is on Laravel 12, &lt;code&gt;composer update&lt;/code&gt; will refuse to resolve &lt;code&gt;pest-plugin-laravel ^5.0&lt;/code&gt; — Composer will report a conflict, not a mysterious test failure. The fix isn’t a Pest problem: you need Laravel 13.23+ before Pest 5’s Laravel plugin is installable at all. If you’re mid-upgrade on both fronts, sequence it — Laravel first, Pest second — or you’ll spend an afternoon debugging a dependency graph that was never going to resolve.&lt;/p&gt;
&lt;h3 id=&quot;2-any-and-with-without-expects--the-suite-that-passes-but-stops-verifying-silent&quot;&gt;2. &lt;code&gt;any()&lt;/code&gt; and &lt;code&gt;with*()&lt;/code&gt; without &lt;code&gt;expects()&lt;/code&gt; — the suite that passes but stops verifying (silent)&lt;/h3&gt;
&lt;p&gt;This is the change that matters most, because nothing in your CI output tells you it happened. PHPUnit 13 hard-deprecates two patterns that were previously the default, lazy way to configure a mock:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Before: works, silently means &amp;quot;any number of calls, don&amp;#39;t verify order&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$mock&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;handle&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;equalTo&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$payload&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// PHPUnit 13: emits a deprecation, but still runs and still &amp;quot;passes&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deprecations in PHPUnit are warnings, not failures, unless you explicitly configure &lt;code&gt;failOnDeprecation&lt;/code&gt; (or PHPUnit’s own &lt;code&gt;failOnPhpunitDeprecation&lt;/code&gt;) in your suite. Which means the realistic failure mode isn’t “upgrade breaks CI on day one” — it’s “CI stays green for weeks while accumulating dozens of deprecation warnings nobody reads, and then either someone turns on strict deprecation failing, or PHPUnit 14 removes the pattern outright and the suite goes red all at once, on a date you don’t control.”&lt;/p&gt;
&lt;p&gt;The fix is to make the intent explicit instead of relying on the implicit default:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// If you actually want to verify the call happens:&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$mock&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;expects&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;once&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;handle&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;equalTo&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$payload&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// If you don&amp;#39;t care whether it&amp;#39;s called — use a stub, not a mock:&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$stub&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;createStub&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;HandlerInterface&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$stub&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;handle&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;willReturn&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$result&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;any()&lt;/code&gt; invocation-count matcher itself is hard-deprecated for the same reason: pairing &lt;code&gt;expects($this-&amp;gt;any())&lt;/code&gt; with a mock is a contradiction — mocks exist to verify interactions, and &lt;code&gt;any()&lt;/code&gt; means “I don’t care if this happens.” PHPUnit’s own maintainers frame it plainly: if you don’t want to verify anything, you wanted a stub.&lt;/p&gt;
&lt;h3 id=&quot;3-laravels-createpartialmock-collides-with-rule-2-silent-laravel-specific&quot;&gt;3. Laravel’s &lt;code&gt;createPartialMock()&lt;/code&gt; collides with rule #2 (silent, Laravel-specific)&lt;/h3&gt;
&lt;p&gt;This is the same deprecation as above, but worth calling out on its own because it hits a helper most Laravel test suites use without thinking about it. Laravel’s testing trait wraps PHPUnit’s &lt;code&gt;createPartialMock()&lt;/code&gt;, and that wrapper’s internal implementation triggers the “with*() without expects()” deprecation on PHPUnit 13 even when your test code looks fine:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Triggers the deprecation on PHPUnit 13, even though this looks innocent&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$class&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;createPartialMock&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;NightlyCommand&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;runSequenceOfCommands&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Fixed: bypass the wrapper, use PHPUnit&amp;#39;s builder directly&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$class&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getMockBuilder&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;NightlyCommand&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;onlyMethods&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;runSequenceOfCommands&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getMock&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Grep for it before you upgrade, because the deprecation count can be large if the pattern is common in your suite:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rln&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;createPartialMock&amp;quot;&lt;/span&gt; tests/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;4-real-removals--these-do-fail-immediately-no-ambiguity&quot;&gt;4. Real removals — these do fail, immediately, no ambiguity&lt;/h3&gt;
&lt;p&gt;Unlike the two above, these were already hard-deprecated in PHPUnit 12 and are simply gone in 13. They throw a fatal &lt;code&gt;Error&lt;/code&gt;, not a warning, so they surface on the first run — which is actually the easy category to deal with:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Removed in PHPUnit 13 — fatal error, not a deprecation&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$value&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;isType&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;string&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertContainsOnly&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;string&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$collection&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertNotContainsOnly&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;int&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$collection&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Their specialized replacements, available since PHPUnit 11.5&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$value&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;isString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// or just assertIsString($value)&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertContainsOnlyString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$collection&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertContainsNotOnlyInt&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$collection&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// For collections of objects, the old one is still there&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;assertContainsOnlyInstancesOf&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$collection&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also removed: the &lt;code&gt;--dont-report-useless-tests&lt;/code&gt; CLI flag, &lt;code&gt;Configuration::includeTestSuite()&lt;/code&gt; / &lt;code&gt;excludeTestSuite()&lt;/code&gt;, &lt;code&gt;#[CoversNothing]&lt;/code&gt; on individual test methods (class-level only now), the &lt;code&gt;#[RunClassInSeparateProcess]&lt;/code&gt; attribute, and support for version-constraint strings without an explicit comparison operator. None of these are common in a typical Laravel test suite, but if you maintain a package with custom PHPUnit extensions or CI tooling that shells out to &lt;code&gt;phpunit&lt;/code&gt; directly, check your flags.&lt;/p&gt;
&lt;p&gt;Coming from &lt;code&gt;withConsecutive()&lt;/code&gt; specifically: that one is old news — it was removed back in PHPUnit 10 — but PHPUnit 13 finally ships proper replacements instead of leaving people writing awkward call-count workarounds:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// New in PHPUnit 13&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$mock&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;expects&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;once&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;handle&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;withParameterSetsInOrder&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;first&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;second&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token variable&quot;&gt;$mock&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;expects&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;exactly&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;handle&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;withParameterSetsInAnyOrder&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;first&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;second&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;5-test-impact-analysis-needs-a-coverage-driver-or-it-just-doesnt-run-silent&quot;&gt;5. Test Impact Analysis needs a coverage driver, or it just doesn’t run (silent)&lt;/h3&gt;
&lt;p&gt;Pest 5’s headline feature — the Tia Engine, which claims to shrink a 10-minute Laravel suite to about 4 seconds on subsequent runs by only re-running tests affected by your diff — needs PCOV or Xdebug installed to record its baseline dependency graph. Without one, &lt;strong&gt;Pest doesn’t error and doesn’t warn loudly; TIA simply never activates&lt;/strong&gt;, and your suite quietly keeps running at full length every time. If you enabled TIA expecting the speedup and nothing changed, check for a coverage driver before assuming the feature is broken:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php &lt;span class=&quot;token parameter variable&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-iE&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;pcov|xdebug&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a team-wide setup, the sane pattern is to have CI record the baseline once per merge to &lt;code&gt;main&lt;/code&gt; (where a coverage driver is cheap to enable) and have local machines consume that cached graph, rather than every developer running a coverage-instrumented baseline locally.&lt;/p&gt;
&lt;h3 id=&quot;6-no-config-file-changes--genuinely-nothing-to-do-here&quot;&gt;6. No config-file changes — genuinely nothing to do here&lt;/h3&gt;
&lt;p&gt;Worth stating explicitly because people expect it: the Pest 5 upgrade guide documents &lt;strong&gt;no changes to &lt;code&gt;phpunit.xml&lt;/code&gt; or any Pest configuration file&lt;/strong&gt;. If your suite runs clean under PHPUnit 13’s rules, the version bump in &lt;code&gt;composer.json&lt;/code&gt; is the entire migration. That’s rare enough in this ecosystem that it’s worth not overthinking.&lt;/p&gt;
&lt;h2 id=&quot;when-should-you-upgrade&quot;&gt;When should you upgrade?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You’re on Pest 4 / PHPUnit 12, PHP 8.4 is already your baseline, and &lt;code&gt;phpunit --display-deprecations&lt;/code&gt; is clean.&lt;/strong&gt; Upgrade now. This is close to the “2 minutes” the docs advertise, and TIA alone is worth it if your suite has grown past a couple thousand tests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re on Pest 4 but still on PHP 8.3.&lt;/strong&gt; Raise PHP first, verify the existing suite is still green and deprecation-free on 8.4, then bump Pest as a separate step.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re on Pest 3 or plain PHPUnit 11 or older.&lt;/strong&gt; Don’t skip straight to 5. Go through the PHPUnit 12 wave first (attributes instead of docblocks, no more abstract/trait mocks), confirm the suite is clean, then take the 12 → 13 jump on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re a Laravel shop still on Laravel 12.&lt;/strong&gt; Pest 5’s Laravel plugin is out of reach until you’re on Laravel 13.23+. Either upgrade Laravel first, or stay on &lt;code&gt;pest-plugin-laravel ^4.0&lt;/code&gt; with Pest 4 until you do — don’t let Composer’s dependency resolver make that decision for you mid-upgrade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anyone with heavy mock usage in the suite, regardless of version.&lt;/strong&gt; Run the deprecation grep before you touch &lt;code&gt;composer.json&lt;/code&gt; at all. The cost of this upgrade isn’t the version bump — it’s finding out how many of your mocks were only “passing” because nothing was actually being verified.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-laravel-13/&quot;&gt;What breaks when you upgrade to Laravel 13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-php-8-6/&quot;&gt;What breaks when you upgrade to PHP 8.6&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/how-to-upgrade-laravel-9-to-laravel-10/&quot;&gt;How to upgrade Laravel 9 to Laravel 10&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does Pest 5 require PHP 8.4? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. Pest 5 requires PHP 8.4.0 or greater as a hard minimum, with no exceptions for PHP 8.1, 8.2, or 8.3. This comes from Pest 5 running on top of PHPUnit 13, which itself requires PHP 8.4 or later. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can I use Pest 5 with Laravel 12? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not with the official Laravel plugin. As of pest-plugin-laravel version 5.0.1, the package requires laravel/framework ^13.23.0, so Composer will refuse to install it on a Laravel 12 application. You either need to upgrade to Laravel 13.23 or newer first, or stay on Pest 4 with pest-plugin-laravel ^4.0 until you do. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What happens if my test suite has PHPUnit deprecation warnings before I upgrade? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; PHPUnit deprecation warnings do not fail a build by default, so a suite can appear to pass while using patterns that PHPUnit 13 flags, such as the any() invocation matcher or with*() calls without an explicit expects(). PHPUnit officially recommends against upgrading to 13 unless your suite already runs clean without deprecation warnings on PHPUnit 12.5, because those warnings become fatal removals in a future major version without further notice. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is the any() matcher removed in PHPUnit 13? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No, not yet. The any() invocation-count matcher is hard-deprecated in PHPUnit 13, meaning it still works but triggers a deprecation warning, and it is scheduled for removal in PHPUnit 14. The recommended fix is to use an explicit expectation like expects($this-&amp;gt;once()) if you want to verify a call happens, or switch to createStub() if you do not need verification at all. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Do I need Xdebug or PCOV to use Pest 5 Test Impact Analysis? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. The Tia Engine needs a code coverage driver, either PCOV or Xdebug, to record the dependency graph on its first run. Without one installed and enabled, Test Impact Analysis does not raise an error; it simply does not activate, and your suite keeps running every test on every run. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How long does the Pest 5 upgrade actually take? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The official documentation estimates about 2 minutes for the composer.json version bump itself, and that estimate is accurate for the Pest-specific part of the change. The real time cost is auditing your suite against PHPUnit 13 rules beforehand, particularly mock usage patterns and, for Laravel apps, confirming you are already on Laravel 13.23 or newer. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>PHP</category><category>Laravel</category><category>Testing</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>What Breaks When You Upgrade from Symfony 7.4 to 8.1</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-symfony-8/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-symfony-8/</guid><description>Symfony 8.0 lost support in July 2026. XML config is gone, Security changed shape, and 7.4 LTS is the alternative. Every breaking change, ranked by impact.</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;If you’re on Symfony 8.0, you’ve been running unsupported code since July 30, 2026 — 8.0 got exactly eight months of support and it’s over.&lt;/strong&gt; If you’re on 7.4, you’re fine for years, but you still have to decide whether to stay on the LTS or move to 8.1, released May 29, 2026. Neither path is a rewrite. Both have changes that don’t throw an error — they just quietly change what your application does.&lt;/p&gt;
&lt;p&gt;Symfony 7.4 and 8.0 shipped the same day, November 27, 2025, with &lt;strong&gt;identical features&lt;/strong&gt;. The only difference is that 7.4 kept the deprecation layers accumulated since 7.0, and 8.0 deleted them. That’s the whole story of “Symfony 8”: it’s 7.4 with the safety net removed. 8.1, released half a year later, adds real new features on top and is the version you land on if you take the regular (non-LTS) track.&lt;/p&gt;
&lt;h2 id=&quot;where-are-you-coming-from&quot;&gt;Where are you coming from?&lt;/h2&gt;
&lt;div style=&quot;overflow-x:auto&quot;&gt;
























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;You’re on&lt;/th&gt;&lt;th&gt;Status today (Sep 2026)&lt;/th&gt;&lt;th&gt;What to do&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Symfony 8.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Unmaintained since Jul 30, 2026 — no bug fixes, no security patches&lt;/td&gt;&lt;td&gt;Upgrade to &lt;strong&gt;8.1&lt;/strong&gt; now. Same deprecation-free codebase, no code changes required by the BC promise&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Symfony 7.4 (LTS)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Bug fixes until Nov 2028, security until Nov 2029&lt;/td&gt;&lt;td&gt;Stay, or move to 8.1 for new features. No urgency either way&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Symfony 6.4 (LTS) or older&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;6.4: bug fixes until Nov 2026, security until Nov 2027. Of the older branches only 5.4 LTS still gets patches, security-only (until Feb 2029)&lt;/td&gt;&lt;td&gt;Go through 7.4 first, without waiting for the 6.4 window to close. Fix every deprecation there with &lt;code&gt;phpunit --display-deprecations&lt;/code&gt;, then decide 7.4-LTS vs 8.x&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The reference table, straight from &lt;code&gt;symfony.com/releases&lt;/code&gt;:&lt;/p&gt;





































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Released&lt;/th&gt;&lt;th&gt;PHP min&lt;/th&gt;&lt;th&gt;Bug fixes until&lt;/th&gt;&lt;th&gt;Security until&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;7.4&lt;/td&gt;&lt;td&gt;LTS&lt;/td&gt;&lt;td&gt;Nov 27, 2025&lt;/td&gt;&lt;td&gt;8.2.0&lt;/td&gt;&lt;td&gt;Nov 2028&lt;/td&gt;&lt;td&gt;Nov 2029&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8.0&lt;/td&gt;&lt;td&gt;Regular&lt;/td&gt;&lt;td&gt;Nov 27, 2025&lt;/td&gt;&lt;td&gt;8.4.0&lt;/td&gt;&lt;td&gt;Jul 2026&lt;/td&gt;&lt;td&gt;Jul 2026&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;8.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Regular&lt;/td&gt;&lt;td&gt;May 29, 2026&lt;/td&gt;&lt;td&gt;8.4.0&lt;/td&gt;&lt;td&gt;Jan 2027&lt;/td&gt;&lt;td&gt;Jan 2027&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two things worth reading twice: &lt;strong&gt;8.0’s entire support window was eight months&lt;/strong&gt;, both bug fixes and security together — that’s the standard policy for a non-LTS minor, not a shortened one. And &lt;strong&gt;8.1 requires PHP 8.4&lt;/strong&gt;, a full two versions ahead of what 7.4 needs. If you’re still on PHP 8.2 or 8.3, raising the PHP version is real infrastructure work, separate from anything in this list — do it first, verify it, then touch &lt;code&gt;composer.json&lt;/code&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;high-impact&quot;&gt;High impact&lt;/h2&gt;
&lt;h3 id=&quot;1-xml-configuration-is-gone--and-it-broke-in-silence-first&quot;&gt;1. XML configuration is gone — and it broke in silence first&lt;/h3&gt;
&lt;p&gt;This is the one that catches people off guard, because it doesn’t fail the day it should. &lt;strong&gt;Symfony 7.4 deprecated XML configuration; Symfony 8.0 removed it outright.&lt;/strong&gt; If your bundles, routes, or services are configured in XML and nobody was watching the deprecation log during the 7.4 window, the upgrade to 8.0 or 8.1 is a hard stop: the loader is gone, not just discouraged.&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Removed without alternative in Symfony 8.0&lt;/span&gt;
&lt;span class=&quot;token class-name static-context&quot;&gt;ExtensionInterface&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getXsdValidationBasePath&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token class-name static-context&quot;&gt;ExtensionInterface&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getNamespace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Routing, FrameworkBundle, and WebProfilerBundle no longer load XML routes at all. YAML remains the default and stays fully supported. PHP is the recommended alternative for code-based configuration, and it changed shape too (see #4). There’s an automated converter for bundle maintainers — &lt;a href=&quot;https://github.com/GromNaN/symfony-config-xml-to-php/&quot;&gt;&lt;code&gt;symfony-config-xml-to-php&lt;/code&gt;&lt;/a&gt; — but application-level XML config you’ll be rewriting by hand.&lt;/p&gt;
&lt;p&gt;The reason this qualifies as “breaks in silence”: the deprecation notice in 7.4 is just a log line. Nobody greps deprecation logs on a schedule. The failure shows up months later, on the 8.x upgrade, as a fatal error that looks unrelated to anything you changed that week.&lt;/p&gt;
&lt;h3 id=&quot;2-security-erased-credentials-firewall-listeners-error-exposure&quot;&gt;2. Security: erased credentials, firewall listeners, error exposure&lt;/h3&gt;
&lt;p&gt;Three changes land in the same component and compound:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;UserInterface::eraseCredentials()&lt;/code&gt; and &lt;code&gt;TokenInterface::eraseCredentials()&lt;/code&gt; are removed.&lt;/strong&gt; Use &lt;code&gt;__serialize()&lt;/code&gt; to control what survives on the token instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firewall listeners must extend &lt;code&gt;AbstractListener&lt;/code&gt; or implement &lt;code&gt;FirewallListenerInterface&lt;/code&gt;.&lt;/strong&gt; Registering a bare callable as a listener no longer works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;hide_user_not_found&lt;/code&gt; is gone; use &lt;code&gt;expose_security_errors&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-yaml&quot; data-language=&quot;yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Symfony &amp;lt;= 7.x&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;security&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;hide_user_not_found&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;false&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Symfony &amp;gt;= 8.0&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;security&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;expose_security_errors&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; account_status  &lt;span class=&quot;token comment&quot;&gt;# none | account_status | all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;expose_security_errors&lt;/code&gt; is more precise than the boolean it replaces — &lt;code&gt;account_status&lt;/code&gt; shows exceptions only for users who supplied the correct password, &lt;code&gt;all&lt;/code&gt; hides nothing, &lt;code&gt;none&lt;/code&gt; is the safe default. If your &lt;code&gt;security.yaml&lt;/code&gt; still has &lt;code&gt;hide_user_not_found&lt;/code&gt;, the container fails to compile on 8.x. That one is loud. What isn’t loud: remember-me cookies. &lt;code&gt;RememberMeDetails&lt;/code&gt; no longer embeds the user’s fully-qualified class name in the cookie payload. Old cookies issued before the upgrade &lt;strong&gt;fail to authenticate silently&lt;/strong&gt; — the user isn’t shown an error, they’re just logged out and have to sign in again. If you run a high-traffic app, expect a support-ticket spike the week you deploy, not an incident alert.&lt;/p&gt;
&lt;h3 id=&quot;3-console-commands-static-metadata-methods-removed&quot;&gt;3. Console commands: static metadata methods removed&lt;/h3&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Symfony &amp;lt;= 7.x&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;SyncCommand&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Command&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function-definition function&quot;&gt;getDefaultName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token keyword return-type&quot;&gt;string&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;app:sync&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// Symfony &amp;gt;= 8.0&lt;/span&gt;
&lt;span class=&quot;token attribute&quot;&gt;&lt;span class=&quot;token delimiter punctuation&quot;&gt;#[&lt;/span&gt;&lt;span class=&quot;token attribute-content&quot;&gt;&lt;span class=&quot;token attribute-class-name class-name&quot;&gt;AsCommand&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token attribute-class-name class-name&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;app:sync&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token delimiter punctuation&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;SyncCommand&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Command&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Command::getDefaultName()&lt;/code&gt; and &lt;code&gt;getDefaultDescription()&lt;/code&gt; are removed — use the &lt;code&gt;#[AsCommand]&lt;/code&gt; attribute. &lt;code&gt;Application::add()&lt;/code&gt; is replaced by &lt;code&gt;Application::addCommand()&lt;/code&gt;. If you register commands dynamically by instantiating &lt;code&gt;Command&lt;/code&gt; subclasses and pushing them into the application, this is a mechanical but mandatory rename.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;medium-impact&quot;&gt;Medium impact&lt;/h2&gt;
&lt;h3 id=&quot;4-fluent-php-config-is-gone-theres-a-new-array-shape-format&quot;&gt;4. Fluent PHP config is gone; there’s a new array-shape format&lt;/h3&gt;
&lt;p&gt;Symfony 5.3 introduced a fluent, builder-style PHP configuration format (&lt;code&gt;SecurityConfig&lt;/code&gt;, &lt;code&gt;FrameworkConfig&lt;/code&gt;, and friends). It’s removed in 8.0. What replaces it isn’t a return to arrays-as-usual — it’s a new &lt;strong&gt;array-shape PHP format&lt;/strong&gt; with typed metadata that IDEs and static analyzers can actually understand.&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Removed: fluent PHP config&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;Symfony&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;Config&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;SecurityConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name type-declaration&quot;&gt;SecurityConfig&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$security&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token variable&quot;&gt;$security&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;firewall&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;main&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;pattern&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;^/*&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;lazy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token constant boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// New: array-shape PHP config&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;Symfony&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;Component&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;DependencyInjection&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;Loader&lt;span class=&quot;token punctuation&quot;&gt;\&lt;/span&gt;Configurator&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token class-name static-context&quot;&gt;App&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;security&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;firewalls&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;main&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;pattern&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;^/*&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;lazy&amp;#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;token constant boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The stated reason is that the fluent builders couldn’t represent every semantic config tree shape, which made keeping Symfony recipes automatically up to date needlessly hard. YAML is still the recommended default; this format is for teams that specifically want PHP.&lt;/p&gt;
&lt;h3 id=&quot;5-doctrine-no-more-auto-mapping-entities-in-controller-arguments&quot;&gt;5. Doctrine: no more auto-mapping entities in controller arguments&lt;/h3&gt;
&lt;p&gt;The ParamConverter-style auto-mapping of route parameters straight into Doctrine entity arguments is removed. If a controller action typed a Doctrine entity and relied on the bundle resolving it from the route implicitly, that resolution is gone — you now wire it explicitly with &lt;code&gt;#[MapEntity]&lt;/code&gt; or fetch it yourself in the action body. &lt;code&gt;DoctrineExtractor::getTypes()&lt;/code&gt; is also gone; use &lt;code&gt;getType()&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;6-httpfoundation-requestget-removed-method-override-narrowed&quot;&gt;6. HttpFoundation: &lt;code&gt;Request::get()&lt;/code&gt; removed, method override narrowed&lt;/h3&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Removed&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$request&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;id&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// Use the specific bag&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$request&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token property&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;id&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$request&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token property&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;id&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token variable&quot;&gt;$request&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token property&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;id&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Request::get()&lt;/code&gt; searched attributes, then query, then the request body, in that order — convenient and a frequent source of bugs when two bags had the same key. It’s gone; be explicit about which bag you mean. Separately, HTTP method override (&lt;code&gt;X-HTTP-METHOD-OVERRIDE&lt;/code&gt; / &lt;code&gt;_method&lt;/code&gt;) no longer applies to &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;CONNECT&lt;/code&gt;, or &lt;code&gt;TRACE&lt;/code&gt; — those are meant to be safe and idempotent, and Symfony now enforces it at the request layer instead of trusting the override header.&lt;/p&gt;
&lt;h3 id=&quot;7-form-urltype-stops-guessing-a-protocol&quot;&gt;7. Form: &lt;code&gt;UrlType&lt;/code&gt; stops guessing a protocol&lt;/h3&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Symfony &amp;lt;= 7.4: default_protocol defaults to &amp;#39;http&amp;#39;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// typing &amp;quot;example.com&amp;quot; into the field saves &amp;quot;http://example.com&amp;quot;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// Symfony &amp;gt;= 8.0: default_protocol defaults to null&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// typing &amp;quot;example.com&amp;quot; saves &amp;quot;example.com&amp;quot; — no protocol prepended&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is one of the few 8.0 breaks that doesn’t blow up. Since 7.1, leaving &lt;code&gt;default_protocol&lt;/code&gt; unconfigured triggered a deprecation; if nobody read it, the default flips on 8.0 without a word. If your forms use &lt;code&gt;UrlType&lt;/code&gt; and you display or link that value later assuming it always has a scheme, it now doesn’t unless the user typed one. Nothing errors; you get a relative-looking string that behaves like a broken link the first time someone clicks it. To keep the old behavior, set &lt;code&gt;&amp;#39;default_protocol&amp;#39; =&amp;gt; &amp;#39;http&amp;#39;&lt;/code&gt; on the field.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;new-in-81-that-changes-behavior-without-an-error&quot;&gt;New in 8.1 that changes behavior without an error&lt;/h2&gt;
&lt;p&gt;This is the section worth reading closely if you’re already past 8.0 and evaluating 8.1, because none of these throw — they just do something different.&lt;/p&gt;
&lt;h3 id=&quot;8-messenger-no-longer-throws-on-decode-failure&quot;&gt;8. Messenger no longer throws on decode failure&lt;/h3&gt;
&lt;p&gt;Serializers now return an &lt;code&gt;Envelope&lt;/code&gt; wrapping a &lt;code&gt;MessageDecodingFailedException&lt;/code&gt; instead of throwing it. If your transport or middleware caught &lt;code&gt;MessageDecodingFailedException&lt;/code&gt; around the decode call, that &lt;code&gt;catch&lt;/code&gt; block stops firing. The failure isn’t surfaced as an exception anymore — it’s data on the envelope that you have to check for. Code that isn’t updated for this will silently process (or silently drop) a decode failure that used to be impossible to miss.&lt;/p&gt;
&lt;h3 id=&quot;9-parameterbaggetint--getboolean-start-throwing&quot;&gt;9. &lt;code&gt;ParameterBag::getInt()&lt;/code&gt; / &lt;code&gt;getBoolean()&lt;/code&gt; start throwing&lt;/h3&gt;
&lt;p&gt;The opposite direction from the previous one: these used to silently coerce an unconvertible value to &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;. In 8.1 they throw &lt;code&gt;UnexpectedValueException&lt;/code&gt; instead. This is a case where 8.1 turns a &lt;em&gt;previous&lt;/em&gt; silent failure into a loud one — good for catching bad input, but it means code that leaned on the old fallback needs a try/catch or upstream validation now.&lt;/p&gt;
&lt;h3 id=&quot;10-required-collapsed-choicetype-renders-its-placeholder-as-hidden&quot;&gt;10. Required collapsed &lt;code&gt;ChoiceType&lt;/code&gt; renders its placeholder as &lt;code&gt;hidden&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;A required, non-expanded &lt;code&gt;ChoiceType&lt;/code&gt; field now marks its placeholder &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; with the &lt;code&gt;hidden&lt;/code&gt; attribute instead of just being an empty-value option. Purely visual, but if you have JavaScript or CSS that selects on the placeholder option, or automated UI tests asserting on its markup, this is a quiet DOM change. Restore the old behavior with &lt;code&gt;placeholder_attr: []&lt;/code&gt; if you need it.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-does-not-break&quot;&gt;What does NOT break&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symfony 8.0 and 8.1 are not “the next 7.x.”&lt;/strong&gt; They removed deprecations; they didn’t add a wave of new breaking behavior beyond that removal. Most of what breaks on the 7.4→8.0 jump is stuff that was already deprecated and logged in 7.x — if you cleared deprecations on 7.4, the 8.0 upgrade is close to a non-event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;YAML configuration is not going anywhere.&lt;/strong&gt; It’s explicitly the format Symfony recipes keep targeting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 8.1 upgrade from 8.0 needs no code changes under the backward-compatibility promise&lt;/strong&gt; — 8.1 is a minor version on top of 8.0, not a major.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-actual-migration-flow&quot;&gt;The actual migration flow&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Land on 7.4 first if you’re not there.&lt;/strong&gt; Even if your target is 8.x, upgrading onto the LTS gives you the deprecation layer to work against.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run the deprecation report and fix what’s yours:&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php bin/phpunit --display-deprecations
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To fail the suite only on deprecations from your own code, not from third-party packages you don’t control yet, don’t reach for the old &lt;code&gt;SYMFONY_DEPRECATIONS_HELPER=weak_vendors&lt;/code&gt;: that mode was removed in &lt;code&gt;symfony/phpunit-bridge&lt;/code&gt; 5.0, and on PHPUnit 10 or newer the bridge doesn’t even register its deprecation handler. The current way is &lt;code&gt;phpunit.dist.xml&lt;/code&gt;, the way the Flex recipe ships it:&lt;/p&gt;
&lt;pre class=&quot;language-xml&quot; data-language=&quot;xml&quot;&gt;&lt;code class=&quot;language-xml&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;phpunit&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;failOnDeprecation&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;true&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;source&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;ignoreSuppressionOfDeprecations&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;true&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;ignoreIndirectDeprecations&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;true&lt;span class=&quot;token punctuation&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
        &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;include&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
            &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;directory&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;src&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;directory&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
        &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;include&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
        &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;deprecationTrigger&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
            &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;function&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;trigger_deprecation&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;function&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
        &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;deprecationTrigger&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;source&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;phpunit&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ignoreIndirectDeprecations&lt;/code&gt; drops deprecations triggered by third-party code — useful for triaging what’s actually actionable this sprint. If your suite is still on PHPUnit 9 with &lt;code&gt;simple-phpunit&lt;/code&gt;, the equivalent is &lt;code&gt;SYMFONY_DEPRECATIONS_HELPER=&amp;#39;max[self]=0&amp;#39;&lt;/code&gt;.&lt;/p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;Automate the mechanical renames with Rector.&lt;/strong&gt; &lt;a href=&quot;https://github.com/rectorphp/rector-symfony&quot;&gt;&lt;code&gt;rector/rector-symfony&lt;/code&gt;&lt;/a&gt; ships upgrade sets that handle a chunk of this list — &lt;code&gt;getDefaultName()&lt;/code&gt; → attribute, deprecated method calls, and similar — without hand-editing every file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Update dependencies and let Flex apply new recipes:&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;composer&lt;/span&gt; require symfony/symfony:^8.1 --with-all-dependencies
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;&lt;strong&gt;Grep for what’s specific to your app&lt;/strong&gt; before you assume the automation caught it:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rln&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;&amp;lt;?xml&amp;#39;&lt;/span&gt; config/
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rn&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;hide_user_not_found\|-&amp;gt;get(&amp;quot;&lt;/span&gt; config/ src/
&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rn&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;quot;getDefaultName\|getDefaultDescription&amp;quot;&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;&lt;strong&gt;Diff your &lt;code&gt;config/&lt;/code&gt; tree against a fresh &lt;code&gt;symfony/skeleton&lt;/code&gt; on the target branch.&lt;/strong&gt; Most of what’s on this list lives in configuration files, not framework code, and &lt;code&gt;composer update&lt;/code&gt; won’t touch config for you.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;when-should-you-upgrade&quot;&gt;When should you upgrade?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On 8.0 today:&lt;/strong&gt; move to 8.1 immediately. There’s no code-change cost under the BC promise, and every day past July 30, 2026 is a day without security patches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On 7.4, stable, no pressure for new features:&lt;/strong&gt; stay. You have until November 2028 for bug fixes and November 2029 for security. There’s no clock forcing a move.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On 7.4, want the newest features or plan to track the regular release cadence:&lt;/strong&gt; go to 8.1 now, then follow the twice-yearly minors (8.2 is due November 2026). Fix deprecations from XML config, fluent PHP config, and &lt;code&gt;hide_user_not_found&lt;/code&gt; before you jump, not during.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On 6.4 or older:&lt;/strong&gt; 6.4 LTS loses bug fixes in November 2026 and security fixes in November 2027; everything older gets nothing, except 5.4 LTS, which keeps security-only fixes until February 2029. Route through 7.4, clear every deprecation with PHPUnit, then pick LTS-vs-regular based on how much you want the newest features versus a long, quiet support window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-php-8-6/&quot;&gt;What breaks when you upgrade to PHP 8.6&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-laravel-13/&quot;&gt;What breaks when you upgrade to Laravel 13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/best-ides-for-php-developers/&quot;&gt;Best IDEs for PHP developers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>Web Development</category><category>PHP</category><category>Symfony</category><category>Backend</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>What Breaks When You Upgrade to Vite 8 (and Rolldown)</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-vite-8/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-vite-8/</guid><description>Vite 8 swaps Rollup and esbuild for Rolldown. Astro 7 already ships it, so upgrading Astro inherits these breaks unasked. Ranked by impact, with real diffs.</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Vite 8 shipped stable on March 12, 2026, and it replaces both Rollup and esbuild with a single Rust bundler, Rolldown. Most of what breaks does so quietly: no red error, just different output.&lt;/strong&gt; And here’s the part that catches people off guard — if you upgraded to Astro 7 any time after its June 2026 release, you got Vite 8 bundled underneath, whether you asked for it or not. This site runs Astro 7.2.2, and its &lt;code&gt;node_modules/astro/package.json&lt;/code&gt; pins &lt;code&gt;&amp;quot;vite&amp;quot;: &amp;quot;^8.0.13&amp;quot;&lt;/code&gt;. If you followed &lt;a href=&quot;https://ortamarco.me/en/blog/upgrade-to-astro-7/&quot;&gt;the Astro 7 migration&lt;/a&gt;, this post is the part nobody told you about.&lt;/p&gt;
&lt;h2 id=&quot;where-are-you-coming-from&quot;&gt;Where are you coming from?&lt;/h2&gt;
&lt;p&gt;The list below applies differently depending on how you got here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You run Vite directly and you’re on Vite 7.&lt;/strong&gt; The straightforward case. Read the config renames and the CJS interop section, run a build, done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re on Vite 6 or earlier.&lt;/strong&gt; You’re jumping two majors’ worth of changes at once — the Vite 7 migration (Node 20.19+, ESM output) plus everything below. Do it in two steps: land on 7 first, verify, then go to 8.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You didn’t touch Vite. You upgraded Astro, Nuxt, SvelteKit, or another meta-framework, and Vite 8 came along.&lt;/strong&gt; This is the group this post is really for. Nobody put “new bundler” in your changelog entry; it just showed up as a &lt;code&gt;vite&lt;/code&gt; line in &lt;code&gt;package-lock.json&lt;/code&gt;. Everything from here still applies to you — you just didn’t choose the timing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-actually-changed-in-one-screen&quot;&gt;What actually changed, in one screen&lt;/h2&gt;























































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Area&lt;/th&gt;&lt;th&gt;Vite 7&lt;/th&gt;&lt;th&gt;Vite 8&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Production bundler&lt;/td&gt;&lt;td&gt;Rollup&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Rolldown&lt;/strong&gt; (Rust)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Dev-time dependency pre-bundling&lt;/td&gt;&lt;td&gt;esbuild&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Rolldown&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JS transform / minify&lt;/td&gt;&lt;td&gt;esbuild&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Oxc&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;CSS minify&lt;/td&gt;&lt;td&gt;esbuild&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Lightning CSS&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;build.rollupOptions&lt;/code&gt;&lt;/td&gt;&lt;td&gt;valid&lt;/td&gt;&lt;td&gt;&lt;strong&gt;deprecated&lt;/strong&gt;, renamed &lt;code&gt;build.rolldownOptions&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;ESM&lt;/td&gt;&lt;td&gt;ESM only (unchanged, just stricter)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Node.js minimum&lt;/td&gt;&lt;td&gt;20.19+ / 22.12+&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Install size&lt;/td&gt;&lt;td&gt;baseline&lt;/td&gt;&lt;td&gt;&lt;strong&gt;+~15 MB&lt;/strong&gt; (native binaries)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Default &lt;code&gt;build.target&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Chrome 107, Safari 16.0&lt;/td&gt;&lt;td&gt;Chrome 111, Safari 16.4&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The headline number is real: &lt;a href=&quot;https://vite.dev/blog/announcing-vite8&quot;&gt;Vite’s own announcement&lt;/a&gt; cites Linear’s production build dropping from 46s to 6s, Ramp at 57% faster, Beehiiv at 64%, Mercedes-Benz.io at 38%. Those are the upside. The rest of this post is the part that doesn’t make the release notes headline.&lt;/p&gt;
&lt;h2 id=&quot;breaking-changes-ordered-by-impact&quot;&gt;Breaking changes, ordered by impact&lt;/h2&gt;
&lt;h3 id=&quot;1-commonjs-default-imports-resolve-differently--silently&quot;&gt;1. CommonJS default imports resolve differently — silently&lt;/h3&gt;
&lt;p&gt;This is the one worth reading twice, because it produces &lt;strong&gt;no build error&lt;/strong&gt;. Vite 8’s migration guide is explicit: the &lt;code&gt;default&lt;/code&gt; import from a CJS module used to resolve inconsistently between dev and build; now it’s consistent, but the rule changed. The &lt;code&gt;default&lt;/code&gt; import is the whole &lt;code&gt;module.exports&lt;/code&gt; value only if one of these is true — the importer is &lt;code&gt;.mjs&lt;/code&gt;/&lt;code&gt;.mts&lt;/code&gt;, the importer’s closest &lt;code&gt;package.json&lt;/code&gt; has &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;module&amp;quot;&lt;/code&gt;, or the CJS module’s &lt;code&gt;__esModule&lt;/code&gt; flag isn’t &lt;code&gt;true&lt;/code&gt;. Otherwise you get &lt;code&gt;module.exports.default&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In practice, this means a working import can start returning a wrapper object instead of the function you expect:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// cjs-pkg ships: module.exports = { __esModule: true, default: function greet() {} }&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;import&lt;/span&gt; greet &lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;cjs-pkg&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;greet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// Vite 7, dev: worked&lt;/span&gt;
          &lt;span class=&quot;token comment&quot;&gt;// Vite 8, plain .js without &amp;quot;type&amp;quot;: &amp;quot;module&amp;quot;: TypeError: greet is not a function&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The escape hatch is one line, and it buys you time to fix imports one by one instead of all at once:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// vite.config.js&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;defineConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;legacy&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;inconsistentCjsInterop&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// restores the old, inconsistent behavior&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It’s marked deprecated on purpose — treat it as a bridge, not a destination. If a third-party package is the one tripping this, &lt;a href=&quot;https://vite.dev/guide/migration&quot;&gt;the docs ask you&lt;/a&gt; to report it to the package author with a link to Rolldown’s CJS interop explanation, not just patch around it forever.&lt;/p&gt;
&lt;h3 id=&quot;2-buildrollupoptions-is-now-buildrolldownoptions&quot;&gt;2. &lt;code&gt;build.rollupOptions&lt;/code&gt; is now &lt;code&gt;build.rolldownOptions&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This one at least fails loudly enough to notice — a deprecation warning on every build, not a crash yet, because a compatibility shim keeps the old key working:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Before (Vite 7)&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;defineConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;rollupOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;./src/main.ts&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// After (Vite 8)&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;defineConfig&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;rolldownOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;./src/main.ts&amp;#39;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;worker.rollupOptions&lt;/code&gt; gets the same treatment (&lt;code&gt;worker.rolldownOptions&lt;/code&gt;). If you have both in a monorepo config that’s shared across packages, &lt;code&gt;grep -rn &amp;quot;rollupOptions&amp;quot; .&lt;/code&gt; before you start finds every place at once.&lt;/p&gt;
&lt;h3 id=&quot;3-manualchunks-as-an-object-is-gone-not-deprecated--removed&quot;&gt;3. &lt;code&gt;manualChunks&lt;/code&gt; as an object is gone, not deprecated — removed&lt;/h3&gt;
&lt;p&gt;Buried inside the rename above is a sharper edge: &lt;strong&gt;the object form of &lt;code&gt;output.manualChunks&lt;/code&gt; is not supported anymore&lt;/strong&gt;, full stop. Only the function form still works, and it’s marked deprecated too, in favor of Rolldown’s own &lt;code&gt;codeSplitting&lt;/code&gt; option.&lt;/p&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Vite 7 — this pattern is common in real configs&lt;/span&gt;
&lt;span class=&quot;token literal-property property&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;rollupOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;manualChunks&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token literal-property property&quot;&gt;vendor&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;react&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;react-dom&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// object form&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-js&quot; data-language=&quot;js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Vite 8 — object form throws at build time; convert to a function, minimum viable fix&lt;/span&gt;
&lt;span class=&quot;token literal-property property&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;rolldownOptions&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token function&quot;&gt;manualChunks&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&amp;#39;node_modules/react&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&amp;#39;vendor&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the one most likely to actually break your build (not just warn), because plenty of copy-pasted Vite configs use the object form for vendor splitting.&lt;/p&gt;
&lt;h3 id=&quot;4-buildcommonjsoptions-is-now-a-no-op&quot;&gt;4. &lt;code&gt;build.commonjsOptions&lt;/code&gt; is now a no-op&lt;/h3&gt;
&lt;p&gt;No warning, no error — it’s just ignored. If your config sets &lt;code&gt;build.commonjsOptions.include&lt;/code&gt;, &lt;code&gt;.exclude&lt;/code&gt;, or &lt;code&gt;.requireReturnsDefault&lt;/code&gt; to work around a specific CJS package, &lt;strong&gt;that workaround silently stops applying&lt;/strong&gt; after the upgrade. The symptom shows up downstream, in whatever the option used to fix, not at the config line itself — which makes it easy to blame the wrong thing.&lt;/p&gt;
&lt;h3 id=&quot;5-nodejs-2019--2212-is-a-hard-floor-and-its-about-requireesm&quot;&gt;5. Node.js 20.19+ / 22.12+ is a hard floor, and it’s about &lt;code&gt;require(esm)&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Same requirement as Vite 7, so if you’re coming from there this changes nothing. But if you’re jumping from Vite 6, this is new: Vite ships &lt;strong&gt;ESM-only&lt;/strong&gt;, and those specific patch versions are the ones where Node supports &lt;code&gt;require(esm)&lt;/code&gt; without a flag — the mechanism that lets a CJS-era toolchain still load an ESM-only package. Anything older fails to even install correctly, not just run oddly.&lt;/p&gt;
&lt;h3 id=&quot;6-yarn-pnp-no-official-verdict-but-its-not-solid&quot;&gt;6. Yarn PnP: no official verdict, but it’s not solid&lt;/h3&gt;
&lt;p&gt;Vite’s own docs don’t carry a “Yarn PnP: unsupported” line, but the &lt;a href=&quot;https://github.com/vitejs/rolldown-vite/issues/215&quot;&gt;rolldown-vite issue tracker&lt;/a&gt; has multiple open reports of dependency resolution failing specifically under Yarn’s Plug’n’Play mode — packages that resolve fine with &lt;code&gt;node_modules&lt;/code&gt; throwing &lt;code&gt;Failed to resolve import&lt;/code&gt; under PnP. If your team runs Yarn Berry with &lt;code&gt;nodeLinker: pnp&lt;/code&gt;, treat Vite 8 as “test in a branch before touching CI,” not as a safe drop-in yet.&lt;/p&gt;
&lt;h3 id=&quot;7-install-size-grows-by-roughly-15-mb&quot;&gt;7. Install size grows by roughly 15 MB&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://vite.dev/blog/announcing-vite8&quot;&gt;Confirmed in the release announcement&lt;/a&gt;: about 10 MB from Lightning CSS (now the default CSS minifier, no longer optional) and about 5 MB from Rolldown’s native binary. Nothing breaks functionally, but if you build Docker images with tight layer budgets or you’re vendoring &lt;code&gt;node_modules&lt;/code&gt; into a Lambda bundle, this is a real line item to check — and it applies even if you never touch Vite directly, because it rides in as Astro’s (or Nuxt’s, or SvelteKit’s) dependency.&lt;/p&gt;
&lt;h3 id=&quot;8-default-browser-targets-moved-up--and-old-browsers-fail-without-a-build-warning&quot;&gt;8. Default browser targets moved up — and old browsers fail without a build warning&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;build.target&lt;/code&gt; defaults jumped: Chrome 107→111, Edge 107→111, Firefox 104→114, Safari 16.0→16.4, aligned to Baseline Widely Available as of January 2026. Vite compiles happily either way — the failure, if you have one, shows up as broken JavaScript in an old Safari in someone’s production analytics, weeks later, with nothing in your build log pointing at the cause. If you have a documented browser support matrix that includes anything older than Safari 16.4, set &lt;code&gt;build.target&lt;/code&gt; explicitly instead of trusting the default.&lt;/p&gt;
&lt;h3 id=&quot;9-plugins-mostly-just-work--with-two-real-exceptions&quot;&gt;9. Plugins mostly just work — with two real exceptions&lt;/h3&gt;
&lt;p&gt;Rolldown implements the same plugin API as Rollup, and the framework plugins that matter are already updated: &lt;code&gt;@vitejs/plugin-react&lt;/code&gt; v6 uses Oxc instead of Babel for the Refresh transform (v5 still runs fine on Vite 8 if you haven’t upgraded it yet), and &lt;code&gt;@vitejs/plugin-vue&lt;/code&gt; needs no changes. Where it actually breaks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Plugins that lean on the &lt;code&gt;moduleParsed&lt;/code&gt; hook: it’s &lt;a href=&quot;https://vite.dev/guide/api-plugin&quot;&gt;documented as not called during dev&lt;/a&gt; at all, to avoid a full AST parse on every file.&lt;/li&gt;
&lt;li&gt;Plugins built against esbuild’s own &lt;code&gt;onLoad&lt;/code&gt;/&lt;code&gt;onResolve&lt;/code&gt; plugin format (not Rollup’s) — those are a different API entirely and Rolldown doesn’t speak it. If a plugin’s README mentions “esbuild plugin,” check for a Rolldown-native replacement before assuming it upgrades for free.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;when-should-you-upgrade&quot;&gt;When should you upgrade?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You run Vite directly, already on 7, no exotic CJS dependencies:&lt;/strong&gt; upgrade now. The build-time win is real and the breaking surface is small if you don’t rely on &lt;code&gt;manualChunks&lt;/code&gt; as an object.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You’re on Vite 6:&lt;/strong&gt; go through 7 first. Vite maintains a &lt;code&gt;rolldown-vite&lt;/code&gt; package that’s “Vite 7 with Rolldown, nothing else” specifically as an intermediate step — use it to isolate the bundler change from the version-jump change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You got here via Astro 7 (or Nuxt, SvelteKit, similar):&lt;/strong&gt; you’re already running it. The move isn’t “should I” — it’s auditing your existing &lt;code&gt;vite.config&lt;/code&gt; for &lt;code&gt;rollupOptions.output.manualChunks&lt;/code&gt; as an object and any &lt;code&gt;commonjsOptions&lt;/code&gt; overrides, since those are the two that fail or go silent without you touching the framework version at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your team runs Yarn PnP:&lt;/strong&gt; wait, or pin to &lt;code&gt;rolldown-vite@7.x&lt;/code&gt; and watch the open issues before moving to Vite 8 proper.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You maintain a Rollup plugin with heavy &lt;code&gt;moduleParsed&lt;/code&gt; or output-hook usage:&lt;/strong&gt; budget real time, not a config tweak — that’s an API-shape problem, not a rename.&lt;/p&gt;
&lt;p&gt;Further reading:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/upgrade-to-astro-7/&quot;&gt;Upgrading to Astro 7&lt;/a&gt; — the framework upgrade that brings Vite 8 with it, and its own separate set of traps.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-node-26/&quot;&gt;What breaks upgrading to Node 26&lt;/a&gt; — the runtime side of the same “silent breakage” problem, for the version most Vite 8 installs run on.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-typescript-7/&quot;&gt;What breaks upgrading to TypeScript 7&lt;/a&gt; — same format, same instinct: rank by impact, flag what fails without an error.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Does Astro 7 use Vite 8? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. Astro 7.0.0 already pins &amp;quot;vite&amp;quot;: &amp;quot;^8.0.13&amp;quot; in its package.json, and every Astro 7.x release since inherits it. If you upgraded from Astro 6 to Astro 7, you got Vite 8 and its Rolldown bundler automatically, without a separate Vite upgrade step of your own. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What breaks silently when upgrading to Vite 8? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Two things produce no build error at all: CommonJS default-import resolution changed rules (a working &amp;quot;import x from cjs-pkg&amp;quot; can start returning a wrapper object instead of the value you expect), and build.commonjsOptions became a no-op, so any include/exclude/requireReturnsDefault workaround in your config silently stops applying. Both fail downstream, not at the config line, which makes them easy to misdiagnose. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Why did build.rollupOptions stop working in my vite.config.js? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It was renamed to build.rolldownOptions because Vite 8 replaced Rollup with Rolldown as its bundler. The old key still works through a deprecation shim and prints a warning on every build, but it will eventually be removed. worker.rollupOptions follows the same rename to worker.rolldownOptions. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can I still use manualChunks as an object in Vite 8? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; No. The object form of output.manualChunks was removed entirely, not just deprecated, and using it throws at build time. Only the function form still works, and it is itself marked deprecated in favor of Rolldown&amp;#39;s codeSplitting option. Vendor-splitting configs copied from older tutorials are the most common place this shows up. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Is Vite 8 compatible with Yarn PnP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; There is no official statement either way in the Vite documentation, but the rolldown-vite issue tracker has multiple open reports of dependency resolution failing under Yarn&amp;#39;s Plug&amp;#39;n&amp;#39;Play mode as of this writing. Teams on Yarn Berry with nodeLinker: pnp should test in a branch before rolling Vite 8 into CI, or stay on the rolldown-vite@7.x intermediate package. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Do I need to update @vitejs/plugin-react or @vitejs/plugin-vue for Vite 8? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Not strictly. @vitejs/plugin-vue works unmodified, and @vitejs/plugin-react v5 still runs on Vite 8. Version 6 of plugin-react is the recommended upgrade because it moved from Babel to Oxc for the Fast Refresh transform, which is smaller to install and matches the rest of Vite 8&amp;#39;s toolchain, but it is not a hard requirement. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>JavaScript</category><category>Vite</category><category>Astro</category><category>Frontend</category><category>Migration</category><category>Upgrade</category><author>Marco Orta</author></item><item><title>PHP 8.6: What Actually Breaks When You Upgrade</title><link>https://ortamarco.me/en/blog/what-breaks-upgrading-to-php-8-6/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/what-breaks-upgrading-to-php-8-6/</guid><description>PHP 8.6 ships November 19, 2026. The session defaults that break logins silently, the NUL-byte hardening, the trim() change nobody mentions, and every deprecation — with code.</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;PHP 8.6 ships on November 19, 2026, and the change most likely to break your application in production is not a removed function — it is three session settings whose defaults flip.&lt;/strong&gt; &lt;code&gt;session.use_strict_mode&lt;/code&gt;, &lt;code&gt;session.cookie_httponly&lt;/code&gt; and &lt;code&gt;session.cookie_samesite&lt;/code&gt; all change, and the failure mode is silent: nothing throws, users just stop staying logged in on cross-site requests.&lt;/p&gt;
&lt;p&gt;Everything else is mostly deprecations, which means warnings rather than fatals. But there are four genuine behavior changes hiding among them, and two of those are the kind that change results without saying anything.&lt;/p&gt;
&lt;p&gt;This is the breakage list. If you want the new features — Partial Function Application, &lt;code&gt;clamp()&lt;/code&gt;, the &lt;code&gt;SortDirection&lt;/code&gt; enum — those are in &lt;a href=&quot;https://ortamarco.me/en/blog/php-8-6-new-features/&quot;&gt;everything new in PHP 8.6&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;how-settled-is-this-list&quot;&gt;How settled is this list?&lt;/h2&gt;
&lt;p&gt;Worth stating up front, because “PHP 8.6 breaking changes” articles were being published before there was anything to report.&lt;/p&gt;
&lt;p&gt;The release timetable is public: alpha 1 landed on July 2, beta 1 and the soft feature freeze on August 13, the &lt;strong&gt;hard feature freeze is September 22&lt;/strong&gt;, RC1 arrives September 24 and RC4 on November 5, and GA is &lt;strong&gt;November 19, 2026&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;What that means for this article:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The deprecations are settled.&lt;/strong&gt; They come from RFCs that have already been voted, and voted RFCs do not get un-voted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The list can still grow.&lt;/strong&gt; Between now and the hard freeze, RM-approved changes can still land, and bug fixes after that occasionally add upgrade notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is no official migration page yet.&lt;/strong&gt; &lt;code&gt;php.net/manual/en/migration86.php&lt;/code&gt; does not exist at the time of writing — the source of truth today is the &lt;code&gt;UPGRADING&lt;/code&gt; file on php-src’s master branch, which is where everything below comes from.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I will update this article after the hard freeze and RC1 on September 24, when the list is effectively final.&lt;/p&gt;
&lt;h2 id=&quot;the-one-that-will-actually-break-production&quot;&gt;The one that will actually break production&lt;/h2&gt;
&lt;h3 id=&quot;session-security-defaults-flip&quot;&gt;Session security defaults flip&lt;/h3&gt;
&lt;p&gt;Three &lt;code&gt;php.ini&lt;/code&gt; defaults change:&lt;/p&gt;

























&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Setting&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;PHP 8.6&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;session.use_strict_mode&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;session.cookie_httponly&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;session.cookie_samesite&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;quot;Lax&amp;quot;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Each one is the right default. Together they are the most disruptive change in the release, because &lt;strong&gt;none of them throws an error&lt;/strong&gt;. Your application keeps running and some users stop being logged in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cookie_samesite = &amp;quot;Lax&amp;quot;&lt;/code&gt;&lt;/strong&gt; is the one that bites. A &lt;code&gt;Lax&lt;/code&gt; cookie is not sent on cross-site &lt;strong&gt;POST&lt;/strong&gt; requests. If anything posts to your application from another origin — a payment gateway returning the user via POST, an identity provider’s callback, an embedded form, a webhook that relies on a session — that request now arrives without a session. The user lands on a login screen for no visible reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;cookie_httponly = 1&lt;/code&gt;&lt;/strong&gt; means JavaScript can no longer read the session cookie via &lt;code&gt;document.cookie&lt;/code&gt;. That is correct, and it breaks any front-end code that was reading the session ID directly — some older analytics snippets and hand-rolled AJAX auth do this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;use_strict_mode = 1&lt;/code&gt;&lt;/strong&gt; makes PHP reject session IDs it did not generate itself, which is the actual fix for session fixation. It breaks flows that pass a session ID in from outside.&lt;/p&gt;
&lt;p&gt;If you need the old behavior temporarily, set it explicitly rather than relying on the previous default:&lt;/p&gt;
&lt;pre class=&quot;language-ini&quot; data-language=&quot;ini&quot;&gt;&lt;code class=&quot;language-ini&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;; only as a stopgap while you fix the real cause&lt;/span&gt;
&lt;span class=&quot;token key attr-name&quot;&gt;session.cookie_samesite&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token value attr-value&quot;&gt;&amp;quot;&lt;span class=&quot;token inner-value&quot;&gt;None&lt;/span&gt;&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token key attr-name&quot;&gt;session.cookie_secure&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token value attr-value&quot;&gt;1   ; required whenever SameSite=None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that &lt;code&gt;SameSite=None&lt;/code&gt; without &lt;code&gt;Secure&lt;/code&gt; is rejected by browsers, so this is not a one-line revert.&lt;/p&gt;
&lt;p&gt;The honest advice: do not revert. Set these three explicitly in your &lt;code&gt;php.ini&lt;/code&gt; &lt;strong&gt;today&lt;/strong&gt;, on PHP 8.5, and find out what breaks while you still control the timing. That converts a release-day surprise into a Tuesday afternoon.&lt;/p&gt;
&lt;h2 id=&quot;the-behavior-changes-that-stay-quiet&quot;&gt;The behavior changes that stay quiet&lt;/h2&gt;
&lt;h3 id=&quot;trim-now-strips-form-feed&quot;&gt;&lt;code&gt;trim()&lt;/code&gt; now strips form feed&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;trim()&lt;/code&gt;, &lt;code&gt;ltrim()&lt;/code&gt; and &lt;code&gt;rtrim()&lt;/code&gt; add &lt;code&gt;\f&lt;/code&gt; (form feed, &lt;code&gt;0x0C&lt;/code&gt;) to their default character list. It was the one ASCII whitespace character they did not strip.&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token function&quot;&gt;trim&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string double-quoted-string&quot;&gt;&amp;quot;hello\f&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.5 → &amp;quot;hello\f&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.6 → &amp;quot;hello&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is almost always what you wanted. It is listed here because it changes output silently, and if you have tests asserting on exact strings from parsing fixed-width files, legacy print streams or anything that carries form feeds, they will start failing without an obvious cause.&lt;/p&gt;
&lt;h3 id=&quot;nul-bytes-now-throw-instead-of-being-tolerated&quot;&gt;NUL bytes now throw instead of being tolerated&lt;/h3&gt;
&lt;p&gt;A large set of functions now throw &lt;code&gt;ValueError&lt;/code&gt; when passed a string containing a NUL byte, rather than truncating or behaving unpredictably. The list includes &lt;code&gt;getenv()&lt;/code&gt;, &lt;code&gt;putenv()&lt;/code&gt;, &lt;code&gt;parse_str()&lt;/code&gt;, &lt;code&gt;setlocale()&lt;/code&gt;, &lt;code&gt;dl()&lt;/code&gt;, &lt;code&gt;openlog()&lt;/code&gt;, &lt;code&gt;proc_open()&lt;/code&gt; (the &lt;code&gt;$cwd&lt;/code&gt; argument), and roughly twenty filesystem functions — &lt;code&gt;file_exists()&lt;/code&gt;, &lt;code&gt;is_file()&lt;/code&gt;, &lt;code&gt;filesize()&lt;/code&gt;, &lt;code&gt;stat()&lt;/code&gt; and their relatives.&lt;/p&gt;
&lt;p&gt;This is a security hardening measure, and NUL bytes in these arguments have historically been a path-traversal vector. The practical impact is that code passing unsanitized user input to filesystem checks now gets a loud exception instead of quiet nonsense:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// PHP 8.5: returns false, no signal&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.6: throws ValueError&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;file_exists&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;$_GET&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;path&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// when $_GET[&amp;#39;path&amp;#39;] contains &amp;quot;\0&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your application does this, the exception is telling you about a bug that was already there.&lt;/p&gt;
&lt;h3 id=&quot;unpack-reinterprets--and-&quot;&gt;&lt;code&gt;unpack()&lt;/code&gt; reinterprets &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;unpack()&lt;/code&gt; now treats &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; after a format code as endianness modifiers rather than as part of the name. This changes how existing format strings parse:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token function&quot;&gt;unpack&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string double-quoted-string&quot;&gt;&amp;quot;s&amp;lt;value&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$data&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.5 → key &amp;quot;&amp;lt;value&amp;quot;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.6 → little-endian short, key &amp;quot;value&amp;quot;&lt;/span&gt;

&lt;span class=&quot;token function&quot;&gt;unpack&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string double-quoted-string&quot;&gt;&amp;quot;C&amp;gt;name&amp;quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$data&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// PHP 8.6 → throws (C has no endianness)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Narrow, but if you parse binary formats it is a hard break rather than a deprecation. Grep for &lt;code&gt;unpack(&lt;/code&gt; and check whether any format string has a &lt;code&gt;&amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt; in a name.&lt;/p&gt;
&lt;h3 id=&quot;sessionhandlerinterface-wants-two-more-methods&quot;&gt;&lt;code&gt;SessionHandlerInterface&lt;/code&gt; wants two more methods&lt;/h3&gt;
&lt;p&gt;Custom session handlers that do not implement &lt;code&gt;create_sid()&lt;/code&gt; and &lt;code&gt;validateId()&lt;/code&gt; now emit deprecation notices. If you wrote a database- or Redis-backed session handler by hand, it probably implements the six original methods and not these two — and &lt;code&gt;validateId()&lt;/code&gt; is what makes &lt;code&gt;use_strict_mode&lt;/code&gt; actually work, so this connects back to the change above.&lt;/p&gt;
&lt;h2 id=&quot;the-deprecations&quot;&gt;The deprecations&lt;/h2&gt;
&lt;p&gt;None of these are fatal in 8.6. They emit &lt;code&gt;E_DEPRECATED&lt;/code&gt;, and they are the removal list for PHP 9.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;return&lt;/code&gt; inside &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; Returning from a &lt;code&gt;finally&lt;/code&gt; block discards any return value or exception from the &lt;code&gt;try&lt;/code&gt;, which is almost always a bug being hidden. Now deprecated.&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function-definition function&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;finally&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// deprecated — silently wins, returns 2&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Returning a value from a constructor.&lt;/strong&gt; Returning a value from &lt;code&gt;__construct()&lt;/code&gt; or &lt;code&gt;__destruct()&lt;/code&gt;, or turning either into a generator, now deprecates &lt;strong&gt;at compile time&lt;/strong&gt; — you will see it even if the code never runs. It passed 39 to 0, and becomes an error in the first major after 8.6.&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;Foo&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function-definition function&quot;&gt;__construct&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// deprecated at compile time&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;Bar&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function-definition function&quot;&gt;__construct&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;random_int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// still perfectly legal — a bare return is fine&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;array_filter()&lt;/code&gt; throws on an invalid &lt;code&gt;$mode&lt;/code&gt;.&lt;/strong&gt; It used to ignore a bad mode silently; now it raises &lt;code&gt;ValueError&lt;/code&gt;. Strictly an improvement, but it can take down code that had been passing a wrong constant unnoticed for years.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;php://filter&lt;/code&gt; caps chained filters.&lt;/strong&gt; A limit on how many filters you can chain, as hardening against a known filter-chain exploitation technique. Only relevant if you build filter chains deliberately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;All of mbregex.&lt;/strong&gt; Every &lt;code&gt;mb_ereg*&lt;/code&gt; function is deprecated, because the Oniguruma library behind them is unmaintained. This is the largest deprecation in the release by surface area. The migration is to PCRE — &lt;code&gt;preg_match()&lt;/code&gt; and friends, with the &lt;code&gt;u&lt;/code&gt; modifier for Unicode:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot; data-language=&quot;php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token function&quot;&gt;mb_ereg&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;^[0-9]+$&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$s&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;      &lt;span class=&quot;token comment&quot;&gt;// deprecated&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;preg_match&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&amp;#39;/^[0-9]+$/u&amp;#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$s&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// replacement&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The patterns are not always a direct translation, so this one deserves real test coverage rather than a find-and-replace.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type-check and conversion aliases.&lt;/strong&gt; &lt;code&gt;is_double()&lt;/code&gt;, &lt;code&gt;is_long()&lt;/code&gt;, &lt;code&gt;is_integer()&lt;/code&gt; and &lt;code&gt;doubleval()&lt;/code&gt; are deprecated in favor of &lt;code&gt;is_float()&lt;/code&gt;, &lt;code&gt;is_int()&lt;/code&gt; and &lt;code&gt;floatval()&lt;/code&gt;. Also deprecated: &lt;code&gt;metaphone()&lt;/code&gt;, &lt;code&gt;strcoll()&lt;/code&gt;, &lt;code&gt;spl_object_hash()&lt;/code&gt;, &lt;code&gt;spl_classes()&lt;/code&gt;, and the &lt;code&gt;SORT_LOCALE_STRING&lt;/code&gt; sort flag.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spl_object_hash()&lt;/code&gt; is the one worth flagging — replace it with &lt;code&gt;spl_object_id()&lt;/code&gt;, which is what you almost certainly meant.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SPL CSV and &lt;code&gt;ArrayIterator&lt;/code&gt; methods.&lt;/strong&gt; &lt;code&gt;SplFileObject::fgetcsv()&lt;/code&gt;, &lt;code&gt;fputcsv()&lt;/code&gt;, &lt;code&gt;setCsvControl()&lt;/code&gt; and &lt;code&gt;getCsvControl()&lt;/code&gt; are deprecated, as are ten &lt;code&gt;ArrayIterator&lt;/code&gt; methods: &lt;code&gt;getFlags()&lt;/code&gt;, &lt;code&gt;setFlags()&lt;/code&gt;, &lt;code&gt;asort()&lt;/code&gt;, &lt;code&gt;ksort()&lt;/code&gt;, &lt;code&gt;uasort()&lt;/code&gt;, &lt;code&gt;uksort()&lt;/code&gt;, &lt;code&gt;natsort()&lt;/code&gt;, &lt;code&gt;natcasesort()&lt;/code&gt;, &lt;code&gt;serialize()&lt;/code&gt; and &lt;code&gt;unserialize()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Object arguments where they never made sense.&lt;/strong&gt; &lt;code&gt;array_walk()&lt;/code&gt;, &lt;code&gt;mb_convert_variables()&lt;/code&gt;, and the zlib and bz2 stream filters now deprecate object arguments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;mysqli odds and ends.&lt;/strong&gt; &lt;code&gt;mysqli_get_charset()&lt;/code&gt; and &lt;code&gt;mysqli_stmt_init()&lt;/code&gt; are deprecated. SOAP’s &lt;code&gt;classmap&lt;/code&gt; option now rejects integer keys.&lt;/p&gt;
&lt;h2 id=&quot;what-to-actually-do-in-order&quot;&gt;What to actually do, in order&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Set the three session settings explicitly on PHP 8.5, now.&lt;/strong&gt; This is the whole risk of the release concentrated in one change, and it is the only one you can test before 8.6 exists. Check every cross-site POST that hits your application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turn on deprecation reporting and run your test suite.&lt;/strong&gt; &lt;code&gt;error_reporting(E_ALL)&lt;/code&gt; with a log you actually read. Most of this release surfaces there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grep for the specific names.&lt;/strong&gt; &lt;code&gt;mb_ereg&lt;/code&gt;, &lt;code&gt;is_double&lt;/code&gt;, &lt;code&gt;is_long&lt;/code&gt;, &lt;code&gt;is_integer&lt;/code&gt;, &lt;code&gt;doubleval&lt;/code&gt;, &lt;code&gt;spl_object_hash&lt;/code&gt;, &lt;code&gt;metaphone&lt;/code&gt;, &lt;code&gt;strcoll&lt;/code&gt;, &lt;code&gt;unpack(&lt;/code&gt;. That is a twenty-minute pass and it covers most of the deprecation list.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check custom session handlers&lt;/strong&gt; for &lt;code&gt;create_sid()&lt;/code&gt; and &lt;code&gt;validateId()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leave &lt;code&gt;trim()&lt;/code&gt; alone&lt;/strong&gt; unless a test fails. If one does, you have learned something about your input data.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-does-not-break&quot;&gt;What does not break&lt;/h2&gt;
&lt;p&gt;Worth saying, because upgrade anxiety fills in blanks that are not there. PHP 8.6 removes nothing that was deprecated in 8.x — the removals land in PHP 9. Your typed properties, enums, readonly classes, fibers and attributes are untouched. If your application runs clean on 8.5 with deprecations silenced, the realistic worst case for 8.6 is a noisy log plus the session change.&lt;/p&gt;
&lt;p&gt;The session change is not a small caveat, though. It is the reason this article exists.&lt;/p&gt;
&lt;h2 id=&quot;frequently-asked-questions&quot;&gt;Frequently asked questions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;When is PHP 8.6 released?&lt;/strong&gt;
November 19, 2026. The hard feature freeze is September 22 and the release candidates run from September 24 to November 5.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What is the most dangerous change in PHP 8.6?&lt;/strong&gt;
The session defaults: &lt;code&gt;session.use_strict_mode&lt;/code&gt; and &lt;code&gt;session.cookie_httponly&lt;/code&gt; become &lt;code&gt;1&lt;/code&gt;, and &lt;code&gt;session.cookie_samesite&lt;/code&gt; becomes &lt;code&gt;&amp;quot;Lax&amp;quot;&lt;/code&gt;. They fail silently — users stop staying logged in on cross-site POST requests instead of getting an error.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does PHP 8.6 remove anything?&lt;/strong&gt;
No. It deprecates a great deal — all of mbregex, several type-check aliases, SPL CSV methods — but removals are scheduled for PHP 9. Deprecations emit warnings, not fatal errors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;mb_ereg&lt;/code&gt; really going away?&lt;/strong&gt;
It is deprecated in 8.6 because Oniguruma, the library behind it, is unmaintained. Migrate to PCRE (&lt;code&gt;preg_match&lt;/code&gt; and friends with the &lt;code&gt;u&lt;/code&gt; modifier). Patterns do not always translate one to one, so test rather than find-and-replace.&lt;/p&gt;

&lt;h2 id=&quot;related-reading&quot;&gt;Related reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/php-8-6-new-features/&quot;&gt;Everything new in PHP 8.6&lt;/a&gt; — the features side: Partial Function Application, &lt;code&gt;clamp()&lt;/code&gt;, the &lt;code&gt;SortDirection&lt;/code&gt; enum.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ortamarco.me/en/blog/what-breaks-upgrading-to-laravel-13/&quot;&gt;What breaks upgrading to Laravel 13&lt;/a&gt; — Laravel 13 requires PHP 8.3 or later, so these two upgrades often land in the same sprint.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>Web Development</category><category>PHP</category><category>PHP 8.6</category><category>Backend</category><category>Migration</category><category>Upgrade</category><category>Security</category><author>Marco Orta</author></item><item><title>The Best MCP Servers for SEO and GEO in 2026: Which One to Connect for Your Case</title><link>https://ortamarco.me/en/blog/best-mcp-servers-for-seo-2026/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/best-mcp-servers-for-seo-2026/</guid><description>&quot;Which is the best SEO MCP server?&quot; has a different answer depending on the data you need — and one of the options costs nothing and skips API keys entirely.</description><pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;There is no single “best MCP server for SEO”: there’s a best one for each kind of data. If you want to read your own site’s real performance, the Google Search Console MCP is free and nobody has more accurate data. If you need third-party data — keywords, competitors, SERPs — the decision sits between DataForSEO (pay per query) and Ahrefs or Semrush (subscription). And if your question is a GEO question — &lt;em&gt;can ChatGPT read and cite my site?&lt;/em&gt; — that layer is covered by audit tools like my seo-geo-mcp, which is open source and doesn’t even ask for an API key.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;By mid-2026, practically every major SEO platform either has an official MCP server or is racing to ship one. That’s good and bad at once: there’s plenty to choose from, but the “12 best” listicles mix things that don’t actually compete with each other. This guide sorts them by the only question that truly decides: &lt;strong&gt;whose data does your agent need?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you’re not yet clear on what MCP is or how an agent uses these connections, start with the &lt;a href=&quot;https://ortamarco.me/en/blog/agentic-ai-ai-agents-mcp-2026/&quot;&gt;guide to agentic AI, agents and MCP&lt;/a&gt; and come back: this piece assumes that base.&lt;/p&gt;
&lt;h2 id=&quot;the-full-table&quot;&gt;The full table&lt;/h2&gt;













































































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Server&lt;/th&gt;&lt;th&gt;Data it exposes&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;th&gt;Type&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Google Search Console&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Clicks, impressions, CTR, positions &lt;strong&gt;for your site&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;td&gt;Community only: Google has no official one&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Google Analytics 4&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Events, conversions, funnels for your site&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;td&gt;Official (experimental)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;DataForSEO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;SERPs, keywords, backlinks, on-page (Google, Bing, Baidu)&lt;/td&gt;&lt;td&gt;Pay per query&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Ahrefs&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;20 tool groups: Site Explorer, Keywords, Site Audit, Rank Tracker, Brand Radar&lt;/td&gt;&lt;td&gt;Unit-based, per plan&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Semrush&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Keywords (28B+, 142 regional databases), competitive data&lt;/td&gt;&lt;td&gt;Tied to subscription&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;SE Ranking&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;180+ tools: keywords, audits, backlinks, AI visibility&lt;/td&gt;&lt;td&gt;Tied to subscription or pay-as-you-go API&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Nightwatch&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Rankings (Google/Bing/YouTube) + AI visibility&lt;/td&gt;&lt;td&gt;Paid plans&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Advanced Web Ranking&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;48 historical rank-tracking and comparison tools&lt;/td&gt;&lt;td&gt;Agency plan and up&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Screaming Frog&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Orchestrating crawls and technical audits from the agent&lt;/td&gt;&lt;td&gt;With the license (v24+)&lt;/td&gt;&lt;td&gt;Official, local&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Peec AI&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Citations and visibility across 8 AI engines (ChatGPT, Perplexity, Gemini, Claude, Copilot…)&lt;/td&gt;&lt;td&gt;Paid plans&lt;/td&gt;&lt;td&gt;Official&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;seo-geo-mcp&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;17 on-page + GEO audit tools: robots, AI crawlers, schema, hreflang&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Free, no API keys&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Open source (npm), local or self-hosted&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Tool counts and pricing come from each platform’s documentation and from the comparative write-ups by &lt;a href=&quot;https://nightwatch.io/blog/best-seo-mcp-servers/&quot;&gt;Nightwatch&lt;/a&gt; and &lt;a href=&quot;https://mcp.directory/blog/best-seo-mcp-servers-2026&quot;&gt;MCP.Directory&lt;/a&gt;; what lands on your invoice depends on your plan.&lt;/p&gt;
&lt;h3 id=&quot;how-each-one-connects-and-what-it-costs-to-get-in&quot;&gt;How each one connects and what it costs to get in&lt;/h3&gt;
&lt;p&gt;Verified on September 13, 2026 against each platform’s documentation and pricing page (linked). “Remote” means a server the platform hosts and you reach over HTTP; “local” means a process running on your machine, usually over stdio. List prices in US dollars or euros, before tax and billed monthly unless noted.&lt;/p&gt;













































































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Server&lt;/th&gt;&lt;th&gt;Transport&lt;/th&gt;&lt;th&gt;Authentication&lt;/th&gt;&lt;th&gt;Entry price&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Google Search Console&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Local (stdio), community implementations&lt;/td&gt;&lt;td&gt;Google Cloud credentials (OAuth or service account)&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/googleanalytics/google-analytics-mcp&quot;&gt;Google Analytics 4&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Local (stdio, &lt;code&gt;pipx run analytics-mcp&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Google Cloud credentials, read-only scope&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/dataforseo/mcp-server-typescript&quot;&gt;DataForSEO&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;mcp.dataforseo.com/v3/mcp&lt;/code&gt;), local HTTP or stdio&lt;/td&gt;&lt;td&gt;OAuth over HTTP; API login and password over stdio&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://dataforseo.com/help-center/how-does-your-free-unlimited-trial-work&quot;&gt;$1 trial credit&lt;/a&gt; on sign-up; &lt;a href=&quot;https://dataforseo.com/pricing&quot;&gt;$50 minimum top-up&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://docs.ahrefs.com/docs/mcp/reference/introduction&quot;&gt;Ahrefs&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;api.ahrefs.com/mcp/mcp&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;OAuth or MCP key&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://ahrefs.com/pricing&quot;&gt;Lite plan, $129/month&lt;/a&gt;; uses the plan’s API units&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://developer.semrush.com/api/v4/introduction/semrush-mcp/&quot;&gt;Semrush&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;mcp.semrush.com/v2/mcp&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;OAuth or API key&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://www.semrush.com/prices/&quot;&gt;Semrush One Starter, $199/month&lt;/a&gt;, or another plan on their list; they include 50,000 API units&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://seranking.com/mcp.html&quot;&gt;SE Ranking&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote&lt;/td&gt;&lt;td&gt;You authorize the connection with your account&lt;/td&gt;&lt;td&gt;Trial with 100,000 credits, no card; pay-as-you-go API from $50 for 250,000 credits&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://nightwatch.io/seo-mcp/&quot;&gt;Nightwatch&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;mcp.nightwatch.io&lt;/code&gt;, SSE)&lt;/td&gt;&lt;td&gt;Bearer token&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://nightwatch.io/pricing/&quot;&gt;Starter, €79/month billed yearly&lt;/a&gt; (€948/year); MCP on every plan&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.advancedwebranking.com/help/use-awr-api-data-in-chatgpt-and-claude&quot;&gt;Advanced Web Ranking&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;api.advancedwebranking.com/mcp&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;OAuth or API key&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://www.advancedwebranking.com/pricing&quot;&gt;Agency plan, $279/month&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.screamingfrog.co.uk/seo-spider/user-guide/configuration/&quot;&gt;Screaming Frog&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Local, alongside SEO Spider v24+ (stdio or Streamable HTTP)&lt;/td&gt;&lt;td&gt;The app’s license&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://www.screamingfrog.co.uk/seo-spider/pricing/&quot;&gt;$279/year license&lt;/a&gt;; the MCP doesn’t work in the free version&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://docs.peec.ai/mcp/introduction&quot;&gt;Peec AI&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Remote (&lt;code&gt;api.peec.ai/mcp&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;OAuth or personal access token&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;https://peec.ai/pricing&quot;&gt;Starter, $95/month&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://ortamarco.me/en/portfolio/seo-geo-mcp/&quot;&gt;seo-geo-mcp&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Local (stdio, &lt;code&gt;npx -y seo-geo-mcp-server&lt;/code&gt;) or self-hosted HTTP&lt;/td&gt;&lt;td&gt;None (optional Bearer token over HTTP)&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two warnings that don’t fit in the table. Ahrefs’ local repository (&lt;a href=&quot;https://github.com/ahrefs/ahrefs-mcp-server&quot;&gt;&lt;code&gt;ahrefs/ahrefs-mcp-server&lt;/code&gt;&lt;/a&gt;) is archived and its own README tells you not to use it: the official server is the remote one. And neither Google nor Microsoft ships an MCP for their webmaster consoles: &lt;a href=&quot;https://github.com/google/mcp&quot;&gt;Google’s official MCP server list&lt;/a&gt; includes Analytics but not Search Console, and the &lt;a href=&quot;https://github.com/google/mcp/issues/17&quot;&gt;request for an official one&lt;/a&gt; is still open; for Bing Webmaster Tools I couldn’t find one from Microsoft either, only community implementations.&lt;/p&gt;
&lt;h2 id=&quot;case-1-you-want-to-read-your-own-sites-data&quot;&gt;Case 1: you want to read YOUR own site’s data&lt;/h2&gt;
&lt;p&gt;Start here, because it’s free and it’s the most accurate data in existence. The &lt;strong&gt;Google Search Console MCP&lt;/strong&gt; gives your agent direct access to clicks, impressions, CTR and position by query and by page — the same first-party data you see in the console, with no third-party sampling. Mind the name, though: Google doesn’t publish a Search Console MCP. Every implementation is open source and community-built, and all of them require Google Cloud credentials (OAuth or a service account), which is the only installation toll.&lt;/p&gt;
&lt;p&gt;Its limit is structural, not the MCP’s fault: Search Console only retains &lt;strong&gt;16 months&lt;/strong&gt; of history. If you want longer series, your agent has to store snapshots — the problem stops being about connectivity and becomes about storage.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Google Analytics 4 MCP&lt;/strong&gt; (official, still experimental) is its natural partner: GSC tells you how people arrive, GA4 what they do afterwards. Together they cover the full funnel without spending a cent.&lt;/p&gt;
&lt;h2 id=&quot;case-2-you-need-third-party-data--keywords-serps-competitors&quot;&gt;Case 2: you need third-party data — keywords, SERPs, competitors&lt;/h2&gt;
&lt;p&gt;This is where the money decision lives, and the criterion is your usage pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DataForSEO&lt;/strong&gt; charges &lt;strong&gt;per query&lt;/strong&gt;, no subscription: $1 of trial credit when you sign up and top-ups from $50. For agent workflows — bursts of one-off queries rather than continuous monitoring — it usually comes out far cheaper than a full subscription. The trade-off: there’s no UI; it’s a pure data layer, and the quality of the outcome depends entirely on how your agent asks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ahrefs&lt;/strong&gt; splits its tools into 20 groups (Site Explorer, Keywords Explorer, Site Audit, Rank Tracker, Brand Radar…) over its link index, with a unit system per plan starting at Lite. There are so many that its own docs admit they exceed some MCP clients’ limits, which is why it accepts &lt;code&gt;?tools=essentials&lt;/code&gt; or a list of groups in the URL. The practical risk is new and very 2026: &lt;strong&gt;an agent in a loop can burn your monthly units in an afternoon&lt;/strong&gt;. If you connect it, give it a per-session budget (Ahrefs lets you set a monthly unit cap per key).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semrush&lt;/strong&gt; plays the coverage card (28 billion+ keywords across 142 regional databases). Its MCP works in ChatGPT, Claude, Cursor, VS Code and Perplexity, and from its Projects API it only exposes the read methods.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SE Ranking&lt;/strong&gt; has the largest surface (180+ tools) — so large that you should prune what you expose per session: agents struggle to choose among 180 options.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My practical read: for one-off automations and agents, DataForSEO; if you already pay for Ahrefs or Semrush for other reasons, their MCP is an obvious add-on, not a reason to subscribe.&lt;/p&gt;
&lt;h2 id=&quot;case-3-rank-tracking-and-technical-auditing&quot;&gt;Case 3: rank tracking and technical auditing&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nightwatch&lt;/strong&gt; and &lt;strong&gt;Advanced Web Ranking&lt;/strong&gt; are rank trackers with an MCP: historical positions, gainers/losers, multi-project comparisons. AWR requires an Agency plan, so it only makes sense for agencies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Screaming Frog&lt;/strong&gt; (from v24) ships an official MCP: your agent can &lt;strong&gt;orchestrate crawls&lt;/strong&gt; — “crawl the site and tell me which pages have duplicate titles” — on top of the tool you already used. It runs locally, bound to your license and your machine: the MCP doesn’t work in the free version of the app.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;case-4-geo--can-ai-engines-read-and-cite-you&quot;&gt;Case 4: GEO — can AI engines read and cite you?&lt;/h2&gt;
&lt;p&gt;This is the new layer, and the one most often confused with classic SEO. The question is no longer “do I rank?” but &lt;strong&gt;“can GPTBot crawl me, does it understand my schema, and am I accidentally blocking the bot that would cite me?”&lt;/strong&gt;. Two tools attack it from opposite angles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Peec AI&lt;/strong&gt; measures the outcome: which AI engines cite you, how much, and who you compete against inside each answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://ortamarco.me/en/portfolio/seo-geo-mcp/&quot;&gt;seo-geo-mcp&lt;/a&gt;&lt;/strong&gt; — my server, open source — audits the cause: 17 read-only tools that check whether your page is crawlable and citable. It separates training blocks from citation blocks (blocking &lt;code&gt;GPTBot&lt;/code&gt; prevents training; blocking &lt;code&gt;OAI-SearchBot&lt;/code&gt; erases you from ChatGPT’s answers — many people did the second wanting the first), detects pages that only render client-side (Googlebot runs JavaScript; &lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;ClaudeBot&lt;/code&gt; and &lt;code&gt;PerplexityBot&lt;/code&gt; generally don’t), and validates schema, hreflang, sitemaps and redirects. No API keys: everything resolves through public HTTP, DNS and robots.txt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What seo-geo-mcp does &lt;strong&gt;not&lt;/strong&gt; do, so you don’t connect it expecting something else: it has no keyword data, no backlinks, no rankings. It’s the auditor, not the database. The combination I run myself: GSC for performance, seo-geo-mcp for diagnosis — that pair covers 90% of the work on a site you own, for free.&lt;/p&gt;
&lt;p&gt;The reason this layer is worth the effort has numbers behind it: the &lt;a href=&quot;https://arxiv.org/abs/2311.09735&quot;&gt;Princeton GEO study&lt;/a&gt; (KDD 2024) measured that optimizing content for generative engines — citations, statistics, sources — can raise visibility in their answers by up to &lt;strong&gt;40%&lt;/strong&gt;. How to apply it is covered in the &lt;a href=&quot;https://ortamarco.me/en/blog/geo-aeo-how-to-appear-in-chatgpt-2026/&quot;&gt;GEO &amp;amp; AEO guide: showing up in ChatGPT&lt;/a&gt;.&lt;/p&gt;
&lt;aside class=&quot;not-prose my-8 flex flex-wrap items-center gap-4 rounded-2xl border border-neutral-100 bg-white p-5 transition-all duration-200 hover:shadow-[5px_5px_rgba(0,98,90,0.3),10px_10px_rgba(0,98,90,0.2),15px_15px_rgba(0,98,90,0.1)] dark:border-zinc-800 dark:bg-zinc-900/40&quot; style=&quot;border-left:4px solid #d72cef&quot;&gt; &lt;span class=&quot;grid size-12 shrink-0 place-items-center rounded-xl&quot; style=&quot;background:#d72cef1a;color:#d72cef&quot;&gt;  &lt;/span&gt; &lt;div class=&quot;flex min-w-0 flex-1 flex-col gap-0.5&quot;&gt; &lt;span class=&quot;text-xs font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-400&quot;&gt; Free tool &lt;/span&gt; &lt;span class=&quot;text-lg leading-snug font-bold text-blacktext dark:text-mint-50&quot;&gt; Domain Health Report &lt;/span&gt; &lt;span class=&quot;text-sm text-pretty text-zinc-600 dark:text-zinc-400&quot;&gt; Audit a whole domain in one query: DNS, SPF, DKIM, DMARC, DNSSEC, CAA, TLS certificate and security headers, with a prioritised list of what to fix. &lt;/span&gt; &lt;/div&gt; &lt;a href=&quot;https://ortamarco.me/en/tools/domain-health-report/&quot; data-umami-event=&quot;tool_callout_click&quot; data-umami-event-tool=&quot;informe-salud-dominio&quot; class=&quot;ml-auto shrink-0 rounded-xl bg-mint-600 px-4 py-2 text-sm! font-semibold text-white! no-underline! transition-colors hover:bg-mint-700 hover:text-white!&quot;&gt; Open tool → &lt;/a&gt; &lt;/aside&gt;
&lt;h2 id=&quot;connecting-one-in-60-seconds&quot;&gt;Connecting one, in 60 seconds&lt;/h2&gt;
&lt;p&gt;With Claude Code, a remote MCP is one command away (each platform’s docs give you the exact URL):&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;claude mcp &lt;span class=&quot;token function&quot;&gt;add&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--transport&lt;/span&gt; http ahrefs https://api.ahrefs.com/mcp/mcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a local one (like seo-geo-mcp), which Claude Code launches as a process and talks to over stdio. Since it’s published on npm, there’s nothing to clone:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; data-language=&quot;bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;claude mcp &lt;span class=&quot;token function&quot;&gt;add&lt;/span&gt; seo-geo -- npx &lt;span class=&quot;token parameter variable&quot;&gt;-y&lt;/span&gt; seo-geo-mcp-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two pieces of advice that prevent regret: &lt;strong&gt;connect only the servers you’ll use in that session&lt;/strong&gt; — each one adds tools to the agent’s context, and too many options make it clumsy — and on paid ones, &lt;strong&gt;check consumption after your first sessions&lt;/strong&gt;: an agent’s query pattern looks nothing like a human’s.&lt;/p&gt;
&lt;h2 id=&quot;the-honest-summary&quot;&gt;The honest summary&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Your own site, zero budget:&lt;/strong&gt; Search Console MCP + seo-geo-mcp. Both free; they cover performance and diagnosis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyword and competitor research:&lt;/strong&gt; DataForSEO if you pay per use; Ahrefs/Semrush if you already have the subscription.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agency with reporting:&lt;/strong&gt; SE Ranking or Nightwatch, which bundle rank tracking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deep technical audits:&lt;/strong&gt; Screaming Frog v24+ with its local MCP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measuring AI citations:&lt;/strong&gt; Peec AI, once you’ve done the homework in the layers above.&lt;/li&gt;
&lt;/ul&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is an SEO MCP server? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It is a Model Context Protocol server that exposes SEO data or tools — rankings, keywords, backlinks, audits — to an AI agent like Claude or ChatGPT. Instead of copy-pasting reports, the agent queries the source directly: it asks Search Console for positions, launches a crawl in Screaming Frog or audits a page&amp;#39;s robots.txt, and reasons over the result. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is the best MCP server for SEO in 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; It depends on the data you need. For your own site&amp;#39;s performance, the Google Search Console MCP (free, and the most accurate data available). For third-party data like keywords and SERPs, DataForSEO if you prefer paying per query, or Ahrefs/Semrush if you already hold their subscription. For GEO auditing — whether AI engines can read and cite your site — seo-geo-mcp, which is open source and requires no API keys. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Are there free SEO MCP servers? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes. The Google Search Console MCPs (all community-built: Google does not publish an official one) and the Google Analytics 4 MCP are free (they only need Google Cloud credentials), and seo-geo-mcp is open source and works without any API key because it resolves everything through public HTTP, DNS and robots.txt. Those three cover performance and diagnosis for a site you own at zero cost. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What is the difference between an SEO MCP and a GEO MCP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The SEO one answers classic search questions: rankings, keywords, backlinks. The GEO one answers whether generative engines — ChatGPT, Perplexity, Gemini — can crawl, read and cite your page: whether robots.txt blocks the wrong bot, whether the page depends on JavaScript those crawlers do not execute, or whether the schema is well formed. They are complementary layers: you can rank well on Google and be invisible to AI assistants. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Can an AI agent spend a lot of money through a paid MCP? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Yes, and it is the newest practical risk in this category: an agent in a loop fires bursts of queries that look nothing like human usage, and on unit-based servers like Ahrefs it can consume the monthly quota in one long session. The defense is a per-session budget, connecting only the servers you need, and checking consumption after the first few sessions. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>SEO</category><category>GEO</category><category>MCP</category><category>AI</category><category>Agents</category><author>Marco Orta</author></item><item><title>How Much Does a Website Cost in Mexico in 2026? (And When You Shouldn&apos;t Pay for One)</title><link>https://ortamarco.me/en/blog/how-much-website-cost-mexico-2026/</link><guid isPermaLink="true">https://ortamarco.me/en/blog/how-much-website-cost-mexico-2026/</guid><description>From a developer, not an agency: the real price ranges by site type, the recurring costs no quote itemizes, the three things that actually make a project expensive — and the cases where my advice is to hire nobody at all.</description><pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;A professional website in Mexico costs, in 2026, between $8,000 and $25,000 MXN (roughly $450–1,400 USD) for a landing page or informational site, between $25,000 and $80,000 MXN ($1,400–4,500 USD) for a custom corporate site, and from $50,000 MXN upward for e-commerce or a web application. On top of that comes what almost no quote itemizes: the domain ($200–800 MXN a year), hosting ($40–300 MXN a month) and maintenance ($1,500–4,000 MXN a month if you delegate it).&lt;/strong&gt; And there’s a fourth range agencies won’t mention: &lt;strong&gt;$0 of development&lt;/strong&gt;, because some businesses today are better served by a site builder or a Google Business profile than by paying for a site.&lt;/p&gt;
&lt;p&gt;I build websites and web systems for a living — meaning I have every incentive to sell you one. That’s why this guide runs backwards from the agency playbook: first the market numbers with their sources, then what actually moves them, and at the end the cases where you should &lt;strong&gt;not&lt;/strong&gt; hire me or anyone else.&lt;/p&gt;
&lt;p&gt;If you just came for your number: my &lt;a href=&quot;https://ortamarco.me/en/quote-calculator/&quot;&gt;quote calculator&lt;/a&gt; gives you a range instantly — project type, complexity, features. No email required.&lt;/p&gt;
&lt;h2 id=&quot;the-market-ranges-in-2026&quot;&gt;The market ranges in 2026&lt;/h2&gt;
&lt;p&gt;I crossed this year’s published price studies — &lt;a href=&quot;https://simplixy.com.mx/cuanto-cuesta-una-pagina-web-en-mexico-2026-guia-completa-de-precios/&quot;&gt;Simplixy&lt;/a&gt;, &lt;a href=&quot;https://gedx.com.mx/blog/cuanto-cuesta-pagina-web-mexico-2026/&quot;&gt;GEDX&lt;/a&gt; and &lt;a href=&quot;https://newemage.com.mx/cuanto-cuesta-una-pagina-web-en-mexico/&quot;&gt;New Emage&lt;/a&gt; among others — with what I quote myself. They agree more than you’d expect:&lt;/p&gt;








































&lt;div class=&quot;table-responsive&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Site type&lt;/th&gt;&lt;th&gt;2026 range (MXN)&lt;/th&gt;&lt;th&gt;What it typically includes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;DIY (site builder)&lt;/td&gt;&lt;td&gt;$0 – $4,000 / year&lt;/td&gt;&lt;td&gt;Template, visual editor, hosting included&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Landing page&lt;/td&gt;&lt;td&gt;$8,000 – $25,000&lt;/td&gt;&lt;td&gt;1 page focused on capturing leads or validating an idea&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Informational / professional site&lt;/td&gt;&lt;td&gt;$14,000 – $30,000&lt;/td&gt;&lt;td&gt;5–8 pages, custom design, basic SEO&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Custom corporate site&lt;/td&gt;&lt;td&gt;$25,000 – $80,000&lt;/td&gt;&lt;td&gt;Own design, optimized content, integrations&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;E-commerce&lt;/td&gt;&lt;td&gt;$27,000 – $126,000&lt;/td&gt;&lt;td&gt;Catalog, payments, shipping, invoicing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Web application&lt;/td&gt;&lt;td&gt;$36,000 – $250,000+&lt;/td&gt;&lt;td&gt;Custom business logic, users, dashboards&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two warnings about this table, because tables lie easily:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The range within each row is wider than the range between rows.&lt;/strong&gt; A complex landing page can cost more than a simple corporate site. The site type is the starting point, not the price.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Freelancer vs agency moves the number as much as the project does.&lt;/strong&gt; The same informational site goes from $5,000–25,000 MXN with a freelancer to $15,900 MXN and up with an agency. Neither is right or wrong: you’re paying for different things — with the agency, management and continuity; with the freelancer, direct contact and fewer layers.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-the-quote-usually-doesnt-itemize&quot;&gt;What the quote usually doesn’t itemize&lt;/h2&gt;
&lt;p&gt;The development price is the big number, but it’s the only one you pay once. These are the ones you pay forever:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Domain:&lt;/strong&gt; $200–800 MXN a year. It renews annually and &lt;strong&gt;must be registered in your name&lt;/strong&gt; — if your provider registers it under theirs, the day you want to switch you’ll discover your domain isn’t yours. It’s the most expensive fine print in this industry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hosting:&lt;/strong&gt; $40–300 MXN a month on basic plans; more as the site grows. Same rule: your own access, always.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maintenance:&lt;/strong&gt; $1,500–4,000 MXN a month if you contract it. Here there’s a real decision: a static informational site can live for years without maintenance; a WordPress with plugins cannot — without updates, getting hacked is a matter of time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Invisible renewals:&lt;/strong&gt; the SSL certificate (which should be free with Let’s Encrypt — be suspicious of anyone charging for it separately), plugin or template licenses, and corporate email.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The question I’d ask any quote: &lt;strong&gt;“which of this do I pay once and which do I pay monthly — and in whose name does each thing stay?”&lt;/strong&gt;. The answer tells you more about the provider than their portfolio does.&lt;/p&gt;
&lt;h2 id=&quot;the-three-things-that-actually-make-a-project-expensive&quot;&gt;The three things that actually make a project expensive&lt;/h2&gt;
&lt;p&gt;After quoting many projects, the factors that move the price are almost always the same three — and none of them is “the design”:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Functionality, not pages.&lt;/strong&gt; Doubling the number of pages barely moves the price; adding online payments, a client area with login, CFDI invoicing or an integration with your internal system can double it. Each of those features is software that has to be built and tested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content and decision complexity.&lt;/strong&gt; A project where the client brings copy, photos and a clear structure costs hours; one where everything gets decided in meetings costs weeks. You’re billed for time, and time goes into deciding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Urgency.&lt;/strong&gt; “I need it in two weeks” is a multiplier, not a detail. Compressing a project means shelving others, and that gets charged.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s why I distrust fixed catalog prices (“website: $9,999”) as much as quotes with no breakdown: both hide which row of the table you’ll land in &lt;em&gt;after&lt;/em&gt; signing.&lt;/p&gt;
&lt;h2 id=&quot;when-you-should-not-pay-for-a-website&quot;&gt;When you should NOT pay for a website&lt;/h2&gt;
&lt;p&gt;This section doesn’t appear in agency guides, for obvious reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;If your business lives within a five-block radius&lt;/strong&gt; — a diner, a salon, a repair shop — your highest-return digital investment is a &lt;strong&gt;well-maintained Google Business profile&lt;/strong&gt; (free) plus WhatsApp Business (free). A $15,000 MXN website won’t bring you more customers than that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you’re still validating the idea&lt;/strong&gt;, a builder like Wix or a one-page template site ($0–4,000 MXN/year) is enough to test. Paying for custom development before knowing whether the business works is building the house before the foundation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you sell through marketplaces and it’s going well&lt;/strong&gt;, your own site is diversification, not urgency. It comes eventually, but it doesn’t have to be today.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When is it worth it? When the site has a concrete job to do — capture leads, sell, automate something you currently do by hand — and you can put a number on that job. At that point the site stops being an image expense and becomes an investment with measurable return. I wrote about measuring it in the &lt;a href=&quot;https://ortamarco.me/en/blog/geo-aeo-how-to-appear-in-chatgpt-2026/&quot;&gt;GEO &amp;amp; AEO guide&lt;/a&gt;: an $80,000 site that neither Google nor ChatGPT can read serves nobody.&lt;/p&gt;
&lt;h2 id=&quot;how-i-quote-so-you-can-compare-against-what-youre-offered&quot;&gt;How I quote (so you can compare against what you’re offered)&lt;/h2&gt;
&lt;p&gt;My &lt;a href=&quot;https://ortamarco.me/en/quote-calculator/&quot;&gt;quote calculator&lt;/a&gt; is public and needs no signup: you pick a project type (landing, corporate, e-commerce, web app, API, mobile app), a complexity level and concrete features, and it returns the range in pesos or dollars on the spot. For typical projects it goes from &lt;strong&gt;$3,600 MXN&lt;/strong&gt; (a minimal landing) to the hundreds of thousands of a full application — the same orders of magnitude as the table above, because Mexico’s serious development market converges on those numbers.&lt;/p&gt;
&lt;p&gt;What actually varies between providers — and where the real competition is — is the usual: a clear breakdown, whose name the domain and hosting stay under, and whether the person quoting will still be around in a year. Ask those three things before asking the price.&lt;/p&gt;
&lt;p&gt;If you’d rather discuss the project directly, &lt;a href=&quot;https://ortamarco.me/en/web-development-service/&quot;&gt;my web development service&lt;/a&gt; explains how I work.&lt;/p&gt;
 &lt;section class=&quot;not-prose my-10&quot;&gt; &lt;h2 class=&quot;mb-6 font-fraunces text-3xl font-bold text-blacktext dark:text-white&quot;&gt; Frequently asked questions &lt;/h2&gt; &lt;div class=&quot;flex flex-col gap-3&quot;&gt; &lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much does a professional website cost in Mexico in 2026? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Market ranges in 2026: a landing page between $8,000 and $25,000 MXN (about $450–1,400 USD); a professional informational site of 5–8 pages between $14,000 and $30,000 MXN; a custom corporate site between $25,000 and $80,000 MXN; and e-commerce from around $27,000 MXN. Add the recurring costs: domain ($200–800 MXN/year), hosting ($40–300 MXN/month) and maintenance ($1,500–4,000 MXN/month if contracted). &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Which is cheaper: a freelancer or an agency? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The freelancer almost always quotes lower: an informational site runs $5,000–25,000 MXN with a freelancer, while agencies start around $15,900 MXN for similar projects. But they charge for different things: the agency bills management, a team and continuity; the freelancer offers direct contact and fewer layers. The useful criterion is not the price but the breakdown, domain ownership, and who will maintain the site a year from now. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; How much does it cost to maintain a website per month in Mexico? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; The minimum recurring costs are hosting ($40–300 MXN a month on basic plans) and the domain ($200–800 MXN a year). Contracted maintenance — updates, security, minor changes — runs between $1,500 and $4,000 MXN monthly in the market. A well-built static site can live on the minimum; a WordPress with plugins needs active maintenance or ends up hacked. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; When should you NOT pay for a website? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; When the business is purely local with a short radius (a diner, a salon), where a free Google Business profile and WhatsApp Business yield more; when the business idea is not yet validated, where a $0–4,000 MXN/year site builder suffices; and when sales already work through marketplaces, making your own site diversification rather than urgency. A custom site pays off when it has a measurable job: capturing clients, selling, or automating. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; What makes a website more expensive: design or features? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Features. Doubling the page count barely moves the price, but adding online payments, a client area, CFDI invoicing or integrations with internal systems can double it, because each feature is software that must be built and tested. The other two heaviest factors are how clear the client-provided content is, and deadline urgency. &lt;/p&gt; &lt;/details&gt;&lt;details class=&quot;group rounded-2xl border border-zinc-200 bg-white/60 px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900/40&quot;&gt; &lt;summary class=&quot;flex cursor-pointer list-none items-start justify-between gap-4&quot;&gt; &lt;h3 class=&quot;text-lg font-semibold text-blacktext dark:text-white&quot;&gt; Should the domain and hosting be in my name? &lt;/h3&gt;  &lt;/summary&gt; &lt;p class=&quot;mt-3 text-lg leading-relaxed text-zinc-700 max-md:text-base dark:text-zinc-300&quot;&gt; Always. If the provider registers the domain under their own name, the business does not own its own address on the internet, and switching providers becomes a negotiation. Before signing, confirm the domain stays in the client&amp;#39;s name and that hosting credentials are handed over. It is the question that prevents the most problems in the whole engagement. &lt;/p&gt; &lt;/details&gt; &lt;/div&gt; &lt;/section&gt;</content:encoded><category>Web Development</category><category>Web Development</category><category>Pricing</category><category>Mexico</category><category>SMB</category><category>Freelance</category><author>Marco Orta</author></item></channel></rss>