<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriele Pieretti</title>
    <description>The latest articles on DEV Community by Gabriele Pieretti (@gabbrowick).</description>
    <link>https://dev.to/gabbrowick</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081509%2F97ad5a9c-8094-4644-b924-bfeb72c87649.jpg</url>
      <title>DEV Community: Gabriele Pieretti</title>
      <link>https://dev.to/gabbrowick</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabbrowick"/>
    <language>en</language>
    <item>
      <title>Hair Colour Consultation: the Client Sees the Result on the Tablet Before You Start</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Wed, 04 Nov 2026 20:31:15 +0000</pubDate>
      <link>https://dev.to/gabbrowick/hair-colour-consultation-the-client-sees-the-result-on-the-tablet-before-you-start-243c</link>
      <guid>https://dev.to/gabbrowick/hair-colour-consultation-the-client-sees-the-result-on-the-tablet-before-you-start-243c</guid>
      <description>&lt;p&gt;"I'd like it a bit warmer, but not too much." Anyone who works in a salon knows what that sentence is worth: nothing. The client has one picture in her head, the stylist has another, and the gap only shows up afterwards, in the mirror. With &lt;a href="https://miraviso.it/" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt; I have been working to make that gap visible before you start, on a tablet, with the client still in the chair.&lt;/p&gt;

&lt;p&gt;In an earlier article I described the &lt;a href="https://gabrielepieretti.dev/blog-en/live-hair-color-try-on-on-device/" rel="noopener noreferrer"&gt;live hair colour try-on&lt;/a&gt;, which colours the hair in the camera view and runs entirely on the device. This is something else: a proper consultation, step by step, that starts from a photo of the client and builds the look using the words of the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  You talk like in the salon, not like to an app
&lt;/h2&gt;

&lt;p&gt;The tablet does not ask anyone to type. The stylist taps buttons grouped as &lt;strong&gt;Cut, Styling, Technique, Tone, Touch-ups&lt;/strong&gt; and, for those who use it, &lt;strong&gt;Colour analysis&lt;/strong&gt;. Inside are everyday words: balayage, shatush, babylights, airtouch for techniques; shades for colour; and touch-ups like "Warmer", "Cooler" or "Darker roots".&lt;/p&gt;

&lt;p&gt;The starting catalogue is already rich (techniques and tones alone add up to more than thirty entries), but every salon adapts it: it can switch off what it does not offer, rename entries to match what it calls them at the chair, and add its own. If your salon calls "honey blonde" something else, the tablet shows it under that name.&lt;/p&gt;

&lt;p&gt;There is also a button people really like: &lt;strong&gt;"Like the swatch"&lt;/strong&gt;. The stylist points the camera at the swatch from the colour chart that the client picked, and that colour goes into the preview. No trade names to interpret: it is the swatch the client actually touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  All over or only the strands
&lt;/h2&gt;

&lt;p&gt;This part came from a real test. At the end of September a salon owner used the tablet with real clients and pointed out something I had missed as a developer: the same colour changes everything depending on where it goes. A warm brown all over and a warm brown only on the strands of a balayage are two different results.&lt;/p&gt;

&lt;p&gt;So now there is a simple switch, &lt;strong&gt;"The colour goes on"&lt;/strong&gt;, with two options: &lt;em&gt;all over&lt;/em&gt; or &lt;em&gt;strands only&lt;/em&gt;. When the stylist picks a technique that works on strands, the tablet switches to "strands only" by itself, because that is almost always what is needed. But if the stylist changes it by hand, the tablet respects that choice and does not quietly undo it. A small label on screen always shows what is being tried: "Base" or "Strands", with the colour name.&lt;/p&gt;

&lt;h2&gt;
  
  
  A history of previews, like at the chair
&lt;/h2&gt;

&lt;p&gt;Each preview takes about thirty seconds, and the tablet says so clearly while it works. The result is shown full screen; holding a finger down brings back the original, and letting go shows the preview again. It is the most natural way to compare.&lt;/p&gt;

&lt;p&gt;Previews line up in a &lt;strong&gt;history&lt;/strong&gt; that always starts with "Her now": the starting photo. From there you can move on in two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;start over&lt;/strong&gt;, applying the whole recipe again to the original photo (useful when several things change at once);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;adjust this photo&lt;/strong&gt;, starting from the latest preview and changing a single detail, for example "a bit cooler".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the client says "the one before was better", you tap the one before and continue from there: the history opens a new branch without losing the other previews. That is how people actually reason at the chair, trying and stepping back.&lt;/p&gt;

&lt;p&gt;For undecided clients there is the &lt;strong&gt;warm and cool&lt;/strong&gt; comparison: both versions start together and arrive side by side, again in about thirty seconds. The choice is often made right there, in a few moments, looking at the two images next to each other. The Miraviso site has a practical guide on exactly this situation, in Italian: &lt;a href="https://miraviso.it/guide/cliente-indecisa-colore" rel="noopener noreferrer"&gt;the client who can't decide on a colour&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At the end, the before and after can be replayed with a wipe animation, built directly on the tablet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I made no compromises
&lt;/h2&gt;

&lt;p&gt;This is a person's face, and it has to be treated accordingly. To generate the preview the photo goes through a Google model (Gemini) running on servers in Europe, not on the generic worldwide ones. I say it openly because it is different from the live try-on, which never leaves the device.&lt;/p&gt;

&lt;p&gt;The rules are these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;without the client's consent the consultation does not start&lt;/strong&gt;: the server refuses the request, it is not just a notice on screen;&lt;/li&gt;
&lt;li&gt;on the server the photo &lt;strong&gt;stays only in memory&lt;/strong&gt; while the preview is generated, it is not written to disk;&lt;/li&gt;
&lt;li&gt;the preview history &lt;strong&gt;lives on the tablet&lt;/strong&gt;, not on the server; the look is saved to the client record only if the client has also agreed to photos being kept;&lt;/li&gt;
&lt;li&gt;every image carries the label &lt;strong&gt;"AI-generated preview"&lt;/strong&gt;, as required by the EU rules on artificial intelligence: the client must know it is a simulation, not a promise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters for the salon too. A preview helps people understand each other, it does not guarantee the result: the starting base, the condition of the hair and the stylist's hand still decide, and the label says so without having to explain it every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes for the salon
&lt;/h2&gt;

&lt;p&gt;The value is not in the technology, it is in the conversation. A consultation where the client sees three or four variations and picks the one she likes is one where you understand each other earlier, and where suggesting an extra service gets easier, because she sees it on herself instead of imagining it.&lt;/p&gt;

&lt;p&gt;If you work in a salon and want to handle this moment better, even without a tablet, the Miraviso site has &lt;a href="https://miraviso.it/guide" rel="noopener noreferrer"&gt;practical guides&lt;/a&gt; (in Italian) written for the chair, starting with the &lt;a href="https://miraviso.it/guide/consulenza-colore-domande" rel="noopener noreferrer"&gt;questions to ask in a colour consultation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your app or business software handles client photos or personal data and you want to check where that data goes and which consents you need, we can look at it together on a &lt;a href="https://gabrielepieretti.dev/consulting/?da=hair-colour-consultation-tablet-preview&amp;amp;utm_source=devto&amp;amp;utm_medium=referral#call" rel="noopener noreferrer"&gt;free 30-minute call&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/hair-colour-consultation-tablet-preview/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about software for small businesses, Laravel, FastAPI and privacy-by-design. &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;More here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>privacy</category>
      <category>flutter</category>
      <category>smallbusiness</category>
    </item>
    <item>
      <title>Does ChatGPT Recommend Your Business? How I Check Every Week</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Wed, 04 Nov 2026 20:31:15 +0000</pubDate>
      <link>https://dev.to/gabbrowick/does-chatgpt-recommend-your-business-how-i-check-every-week-38c1</link>
      <guid>https://dev.to/gabbrowick/does-chatgpt-recommend-your-business-how-i-check-every-week-38c1</guid>
      <description>&lt;p&gt;Someone arrives in a new town and, instead of opening Google Maps, asks ChatGPT: "Which body shop can I trust in Montecatini?". They get three or four names, each with a couple of lines of explanation. If your business is not on that list, for that customer it does not exist. And you never find out, because nobody sends you a notification.&lt;/p&gt;

&lt;p&gt;On Google you can at least search for yourself and see where you rank. With AI assistants it is harder: the answer changes from one day to the next, from one assistant to another, and with the way the question is phrased. That is why in &lt;a href="https://localproai.it/" rel="noopener noreferrer"&gt;LocalProAI&lt;/a&gt; I built a check that runs on its own every Monday morning. Here is how it works, because the choices behind it are useful even if you never use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right questions for each kind of business
&lt;/h2&gt;

&lt;p&gt;The first mistake would be asking everyone the same question. People looking for a dentist do not ask what people looking for a pizzeria ask. So the questions are written by hand, per type of business, in the words a customer would use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pizzeria&lt;/strong&gt;: "What is the best pizzeria in Pistoia?", but also "Pizzeria open on Sunday evening in Pistoia?" and "Best pizzeria with home delivery in Pistoia?";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hair salon&lt;/strong&gt;: "How much does a blow-dry cost in Lucca?", "Where can I get a good hair colour in Lucca?";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;garage and body shop&lt;/strong&gt;: "Who do I call for roadside assistance in Prato?", "Where can I get hail damage on my car repaired in Prato?";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dentist&lt;/strong&gt;: "Emergency dentist in Livorno: who do I call?".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Today there are seven categories: pizzeria, restaurant, hair salon, gym, dentist, medical practice and garage. Each one has between four and eight questions. The questions include the town, the neighbourhood if the business has set one, the dish or service it is known for and, for medical practices, the specialty. If a piece of information is missing, the question that needs it is skipped instead of being asked halfway.&lt;/p&gt;

&lt;p&gt;For doctors and dentists there is a note I also wrote into the code: these are questions patients ask, not advertising for the practice. The rules on healthcare advertising apply to what the practice publishes, and in fact the texts the system prepares later (I will get there) follow different rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three assistants, not one
&lt;/h2&gt;

&lt;p&gt;Every question goes to three assistants with web search turned on: Perplexity, OpenAI (the company behind ChatGPT) and Google's Gemini. One alone would give a partial picture: it is common for a business to show up on one and not on the others.&lt;/p&gt;

&lt;p&gt;One thing needs saying clearly: this is not the ChatGPT app on your phone. It is the same providers queried automatically, with web search on. The answers can differ a little from what a customer sees, since they may have a chat history or phrase the question differently. The check is there to show the trend week after week, not to capture the exact answer every single person will get.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets recorded in each answer
&lt;/h2&gt;

&lt;p&gt;For every answer the system notes four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;whether the business is mentioned&lt;/strong&gt;, looking for the name and its variants: "Pizzeria Da Mario" must be recognised even when the answer just says "Da Mario";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;in which position&lt;/strong&gt;: first on the list or fourth makes a real difference;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;in what tone&lt;/strong&gt;: "renowned", "recommended" or "avoid", "overrated";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;who else is named&lt;/strong&gt;, meaning the competitors the assistant suggests instead of you, and the sources it says it read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I do not use another AI model to read the answers. These assistants almost always reply with numbered lists and names in bold, and a few handwritten rules are enough to pick them out. Having a second AI re-read every answer would triple the cost of the check with no real benefit. It is the same reasoning I described in &lt;a href="https://gabrielepieretti.dev/blog-en/when-you-dont-need-an-llm/" rel="noopener noreferrer"&gt;when you don't need an LLM&lt;/a&gt;: the big model goes where it is needed, not everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  From numbers to action
&lt;/h2&gt;

&lt;p&gt;A chart going down without telling you what to do is not much use. So two and a half hours after the check, a second step looks at the last two weeks and searches for three situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a question where the business &lt;strong&gt;never appears&lt;/strong&gt;, on any assistant;&lt;/li&gt;
&lt;li&gt;a question where it appears, but &lt;strong&gt;on average fourth or lower&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;same competitor&lt;/strong&gt; named in its place on at least three different questions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the most serious cases the system drafts an article for the business's website and a few FAQs, written to answer exactly that question, with the business name and a concrete fact at the start of each section. That is the kind of sentence assistants like to pick up.&lt;/p&gt;

&lt;p&gt;There are three brakes, on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;at most two actions a week&lt;/strong&gt; per business: a few good pieces beat a website full of similar texts;&lt;/li&gt;
&lt;li&gt;if a question was already worked on in the &lt;strong&gt;last 60 days&lt;/strong&gt;, the same article is not rewritten every Monday;&lt;/li&gt;
&lt;li&gt;the article stays &lt;strong&gt;in draft&lt;/strong&gt; until the owner reads it and approves it from the dashboard. It only goes out on its own if the owner chose automatic publishing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The owner gets a Telegram message explaining, in plain words, which question they were not being mentioned for and what has been prepared.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the owner sees
&lt;/h2&gt;

&lt;p&gt;The dashboard shows a summary of the last twelve weeks: how often the business was mentioned out of all questions, the average position, the week by week trend, the questions where it does best and worst, and the ten competitors named most often. For the most recent answers you can also see the sources the assistant used.&lt;/p&gt;

&lt;p&gt;The sources are often the most useful part. If assistants keep citing a local guide, a review site or a category directory where you are missing, you know where to work. And that work needs no technology: an up-to-date listing, consistent information everywhere, real reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it by hand
&lt;/h2&gt;

&lt;p&gt;Even without tools you can run a smaller version of this check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;write five questions the way a customer would, with your town and your main service;&lt;/li&gt;
&lt;li&gt;ask them to ChatGPT, Perplexity and Gemini, in a private window or logged out;&lt;/li&gt;
&lt;li&gt;note whether you appear, in which position and who comes before you;&lt;/li&gt;
&lt;li&gt;look at the sources the assistant cites: those are the places where it pays to be present and accurate;&lt;/li&gt;
&lt;li&gt;repeat in a month with the same questions, worded the same way.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last step is the one that matters. A single answer says little; the same question repeated over time tells you whether you are heading in the right direction.&lt;/p&gt;

&lt;p&gt;If you run a local business and want to know what ChatGPT, Perplexity and Gemini say when your customers ask about your kind of service, we can look at it together on a &lt;a href="https://gabrielepieretti.dev/consulting/?da=ai-assistants-recommend-local-business&amp;amp;utm_source=devto&amp;amp;utm_medium=referral#call" rel="noopener noreferrer"&gt;free 30-minute call&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/ai-assistants-recommend-local-business/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about software for small businesses, Laravel, FastAPI and privacy-by-design. &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;More here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>smallbusiness</category>
      <category>localbusiness</category>
    </item>
    <item>
      <title>The Routine Update That Quietly Changes Your Software: Why You Need Tests That Notice</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:47:48 +0000</pubDate>
      <link>https://dev.to/gabbrowick/the-routine-update-that-quietly-changes-your-software-why-you-need-tests-that-notice-1dlc</link>
      <guid>https://dev.to/gabbrowick/the-routine-update-that-quietly-changes-your-software-why-you-need-tests-that-notice-1dlc</guid>
      <description>&lt;p&gt;Every piece of software your business runs on is built on dozens of parts written by other people. Now and then those parts need an update, usually to close a security hole. The program restarts, the pages open, nobody sees an error. And sometimes a rule that used to work has stopped working. Without telling anyone.&lt;/p&gt;

&lt;p&gt;It has happened to me more than once, on my own projects. Better that an automated test notices first, before an angry customer does. Here is what that means in plain words, and what to ask whoever builds your software.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't stop updating
&lt;/h2&gt;

&lt;p&gt;A management system, a website with a customer area or a custom app is not only your supplier's code. It uses ready-made components: the one that reads uploaded files, the one that sends emails, the one that talks to the database. Think of them as spare parts in a car. The mechanic doesn't build the brakes, he fits them.&lt;/p&gt;

&lt;p&gt;When a hole is found in one of those parts, a fixed version comes out. Staying on the old one means leaving open a door everybody now knows about. So you update, and that's fine. The question is what happens next.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an update changes the rules without an error
&lt;/h2&gt;

&lt;p&gt;Take a made-up but realistic example. An online shop gives a 10% discount only to newsletter subscribers. The rule lives in a setting of an external component, the one that handles the cart.&lt;/p&gt;

&lt;p&gt;A security update comes out and the supplier installs it. In the new version that setting has a different name. The shop's code still uses the old name, and the component simply ignores it. No error message, no blank page. The shop keeps selling.&lt;/p&gt;

&lt;p&gt;Except now everyone gets the discount. Or nobody does, and subscribers start writing to support. In the first case you find out when you look at the margins at the end of the month. In the second, when someone has already complained.&lt;/p&gt;

&lt;p&gt;In an app that handles photos or health data, the rule that stops working can be a data protection rule. Then it is no longer about margins. It is about what you promised your customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an automated test is, without jargon
&lt;/h2&gt;

&lt;p&gt;An automated test is a check written once and repeated by the computer every time something changes. For the discount it would read roughly like this: "a customer who isn't subscribed puts a 100 euro product in the cart, the total must be 100; a subscribed customer does the same, the total must be 90".&lt;/p&gt;

&lt;p&gt;It takes a few seconds to run. The supplier runs it before every update. If the total is wrong, the test turns red and the update doesn't go live until someone understands why.&lt;/p&gt;

&lt;p&gt;That is where the value is. The test checks behaviour, meaning what the customer sees, and not the code. The code can look exactly as before and do something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that always says yes
&lt;/h2&gt;

&lt;p&gt;There is a little-known trap. A test can stop checking and still pass. Picture a check that must confirm every private page asks for a password. After an update, the check no longer finds any page to look at. Zero pages checked, zero problems found: green.&lt;/p&gt;

&lt;p&gt;That is why a good supplier also checks the opposite. They remove the rule on purpose and watch the test turn red. A test you have never seen fail is a test you can't trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to ask your supplier
&lt;/h2&gt;

&lt;p&gt;You don't need to code to ask these questions. They tell you whether the software you pay for is kept under control or whether everyone is hoping for the best.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Are there automated tests on the rules that involve money or data?&lt;/strong&gt; Prices, discounts, permissions, deadlines, privacy. Start with whatever is expensive when it breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who watches the external components, and how often?&lt;/strong&gt; There are tools that flag known holes in the parts you use. Ask whether they run and who reads the warnings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens after an update?&lt;/strong&gt; The right answer is "the tests run, and if one is red we don't release". The answer to dig into is "we click around a bit by hand".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has a test ever stopped an update?&lt;/strong&gt; If yes, that's a good sign: the tests are actually checking something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you change supplier, do the tests stay?&lt;/strong&gt; They are part of the project you pay for. They belong in your repository, next to the code.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Writing tests takes time, so money. You don't do it for everything. In an average management system the truly delicate rules are a few dozen. One test each takes a few hours, and then it works for free for years.&lt;/p&gt;

&lt;p&gt;The other side of the sum is what a wrong discount costs you for a month, a customer who can't log in, or a privacy promise that is no longer kept while nobody knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  For the technical reader
&lt;/h2&gt;

&lt;p&gt;Both traps share a shape: the line of code is still there, and it no longer does anything. Nothing below is tied to one framework; I have seen it in Python, and the same holds in PHP or JavaScript.&lt;/p&gt;

&lt;h3&gt;
  
  
  A setting under a name nobody reads
&lt;/h3&gt;

&lt;p&gt;Many libraries are configured by assigning a class attribute at startup. If a new version renames that attribute, Python does not complain about the old name: assigning an attribute that does not exist on a class simply creates it.&lt;/p&gt;

&lt;p&gt;The app starts, every test that only checks "does it run" stays green, and the library quietly falls back to its default. The defence is a test that reads the value back under the current name and fails loudly if the name moves again.&lt;/p&gt;

&lt;p&gt;It looks redundant next to a behavioural test. It is not: when it fails it tells you exactly what broke, and if the attribute is renamed once more, the &lt;code&gt;AttributeError&lt;/code&gt; shows up in CI instead of in production. Keep a behavioural test as well, because the setting is only a means.&lt;/p&gt;

&lt;h3&gt;
  
  
  A loop over an empty list
&lt;/h3&gt;

&lt;p&gt;Tests that walk every route, file or table are handy: nobody has to remember the route added next week. But a framework release can change how mounted routers appear in the route list. After that, the filter finds zero items, the loop runs zero times, and the test is green.&lt;/p&gt;

&lt;p&gt;One check that the list is not empty, placed before the loop, turns a silent no-op into a red test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Habits that catch both
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Run the dependency audit inside the test suite, so the warning lands next to the results and the upgrade starts from there.&lt;/li&gt;
&lt;li&gt;Before trusting a guard test, remove the guard and watch the test fail. A test you have never seen fail may be testing nothing.&lt;/li&gt;
&lt;li&gt;After a big version jump, read the release notes for renames, but don't rely on them: no changelog will tell you that your own test now loops over an empty list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have also written about security updates in the post on &lt;a href="https://gabrielepieretti.dev/blog-en/php-vulnerabilities-october-2026/" rel="noopener noreferrer"&gt;two critical PHP flaws in October&lt;/a&gt;, with the steps I followed on my own services. If you want to know how well the software you use is protected, or have one built with the right tests from day one, we can talk it through on a &lt;a href="https://gabrielepieretti.dev/consulting/?da=software-updates-need-tests&amp;amp;utm_source=devto&amp;amp;utm_medium=referral#call" rel="noopener noreferrer"&gt;free first call&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/software-updates-need-tests/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about software for small businesses, Laravel, FastAPI and privacy-by-design. &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;More here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Email Tracking Pixels in Italy: What to Do With Your Sending Tool After the Garante Guidelines</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Fri, 30 Oct 2026 16:19:47 +0000</pubDate>
      <link>https://dev.to/gabbrowick/email-tracking-pixels-in-italy-what-to-do-with-your-sending-tool-after-the-garante-guidelines-2bn3</link>
      <guid>https://dev.to/gabbrowick/email-tracking-pixels-in-italy-what-to-do-with-your-sending-tool-after-the-garante-guidelines-2bn3</guid>
      <description>&lt;p&gt;In late October 2026, Italian banks, phone carriers and online platforms sent emails about "tracking pixels". The reason is a decision by the Garante, Italy's data protection authority, and the deadline to comply ran out on 29 October 2026. Almost none of those emails explain what the people who send email should do: a company with a newsletter, a CRM or business software that writes to customers.&lt;/p&gt;

&lt;p&gt;Here is the practical version, without the jargon. At the end there is a checklist to go through with your own sending tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an email pixel is
&lt;/h2&gt;

&lt;p&gt;It is a tiny image, usually invisible, placed inside the email. When the recipient opens the message, their mail program downloads that image from the sender's server. At that moment the server learns that the email was opened, when, and often from which IP address and with which program or device.&lt;/p&gt;

&lt;p&gt;If every recipient gets a different pixel, the sender knows who opened, how many times and at what time. That is how open-rate statistics work in almost every newsletter tool. For many businesses it is just a number in the dashboard, and nobody ever decided to switch it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Garante decided
&lt;/h2&gt;

&lt;p&gt;On 17 April 2026 the Garante adopted its &lt;a href="https://www.garanteprivacy.it/home/docweb/-/docweb-display/docweb/10241943" rel="noopener noreferrer"&gt;guidelines on the use of tracking pixels in email communications&lt;/a&gt; (doc. web no. 10241943, in Italian), published in the Italian Official Journal, General Series no. 98, on 29 April 2026. The deadline to comply was six months from publication: 29 October 2026. The Garante also has a &lt;a href="https://www.garanteprivacy.it/temi/tracking-pixel" rel="noopener noreferrer"&gt;summary page on the topic&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The legal basis is Article 122 of the Italian Privacy Code, the same rule that covers cookies (Article 5(3) of the ePrivacy Directive). The Garante also refers to the European Data Protection Board guidelines 2/2023. The principle is simple: reading or storing information on a person's device requires their consent, with a few exceptions. Without consent and without an exception, the pixel cannot be used.&lt;/p&gt;

&lt;p&gt;The rules apply to whoever sends the emails, and also to sending platforms and email service providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is allowed without consent
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Counting opens anonymously and in total.&lt;/strong&gt; Knowing how many emails in a campaign were opened overall, to check whether they land in the inbox or in spam. The pixel must be the same for the whole campaign, and the IP address and mail client must be anonymised. In practice: one number, not a list of people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security tied to authentication.&lt;/strong&gt; Account activation emails, password changes, replies to requests about privacy rights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mandatory service communications.&lt;/strong&gt; Contract changes, data breach notices, phishing warnings, deadlines.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What needs consent
&lt;/h3&gt;

&lt;p&gt;Measuring opens for each individual recipient to improve promotional campaigns, to decide how often to write to them, or to build a profile of them. In other words, the most common use of open statistics in commercial newsletters.&lt;/p&gt;

&lt;h3&gt;
  
  
  A privacy notice is always required
&lt;/h3&gt;

&lt;p&gt;This is the point many people miss. Any email that contains a pixel, including the ones allowed without consent, needs a privacy notice. It can be layered and it can also sit in the cookie policy. For mailings already under way, it is enough to give it in the first suitable message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consent for marketing
&lt;/h3&gt;

&lt;p&gt;For promotional emails, consent to the pixel can be part of the single marketing consent, as long as it is asked in a neutral way. But it must be possible to withdraw it on its own: every email needs a link or icon in the footer that leads to the recipient's preferences. People who refuse the pixel must not be penalised, for example they cannot lose the newsletter. And the choices must be recorded, so you can prove them.&lt;/p&gt;

&lt;p&gt;There is also a technical requirement: the pixel address must contain an identifier that is not readable and not sequential, never the recipient's email address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grey area: tracked links
&lt;/h2&gt;

&lt;p&gt;Besides the pixel, many tools rewrite every link in the email so it goes through their server first, which tells them who clicked what. The guidelines do not deal with click tracking explicitly. The mechanism is similar, though: an identifier per recipient that tells the sender what that person did. My own cautious view is to treat it with the same criteria as the pixel until there is more guidance. That is an opinion, not something the text says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;You do not need to know how to code. You need the dashboard of your sending tool, or half an hour with whoever runs it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find out whether open tracking is on.&lt;/strong&gt; In many tools it is a dashboard setting, sometimes in the options of each campaign, sometimes on by default. Check your CRM and business software too: if they send emails with open statistics, they use a pixel. If you cannot find the setting, ask the supplier in writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you do not need it, switch it off.&lt;/strong&gt; If you only look at the total number of opens, ask whether the tool can give it to you in anonymous, aggregate form, or do without it. It is the simplest route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you need it for marketing, sort out consent.&lt;/strong&gt; Consent asked in a neutral way, a notice that explains the pixel, a footer link in every email to change one's mind, no penalty for saying no, recorded choices. And check that the tool can exclude people who did not consent from tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional and service emails: privacy notice.&lt;/strong&gt; Order confirmations, reminders, invoices, password recovery. Even when the pixel is allowed without consent, the notice is required. Also ask yourself whether you really need the pixel in those emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check tracked links.&lt;/strong&gt; Look at a link in one of your emails: if it points to the sending tool's domain instead of your website, clicks are being tracked. Decide whether to keep it and on what basis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update your privacy notice and your record of processing.&lt;/strong&gt; The notice must say that you use the pixel, for what and on which basis. The record must list the processing, along with the sending platform provider. If a supplier adds the pixel, also check what your contract with them says.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your tool builds the pixel address with the recipient's email inside it, pass that on to the supplier straight away: the guidelines ask for an identifier that means nothing to whoever reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is worth doing
&lt;/h2&gt;

&lt;p&gt;It is not only about the rules. Per-recipient open statistics are already unreliable: several mail programs download images in advance or block them. Quite often you find out you were collecting personal data for a number nobody used to decide anything.&lt;/p&gt;

&lt;p&gt;I wrote about something similar, checking what the software you use actually does, in the post on &lt;a href="https://gabrielepieretti.dev/blog-en/software-updates-need-tests/" rel="noopener noreferrer"&gt;updates that quietly change your software&lt;/a&gt;. If you send newsletters or customer emails from your business software and want to understand what it tracks and what is worth changing, we can look at it together on a &lt;a href="https://gabrielepieretti.dev/consulting/?da=email-tracking-pixels-italy-garante&amp;amp;utm_source=devto&amp;amp;utm_medium=referral#call" rel="noopener noreferrer"&gt;free 30-minute call&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post is a practical reading of the guidelines, not legal advice. For unclear cases, talk to your privacy advisor or DPO.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/email-tracking-pixels-italy-garante/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about software for small businesses, Laravel, FastAPI and privacy-by-design. &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;More here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>gdpr</category>
      <category>email</category>
      <category>smallbusiness</category>
    </item>
    <item>
      <title>A Free-Trial Rule Written Wrong in the Software: What to Check Before It Costs You Customers</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Thu, 29 Oct 2026 18:47:49 +0000</pubDate>
      <link>https://dev.to/gabbrowick/a-free-trial-rule-written-wrong-in-the-software-what-to-check-before-it-costs-you-customers-ac3</link>
      <guid>https://dev.to/gabbrowick/a-free-trial-rule-written-wrong-in-the-software-what-to-check-before-it-costs-you-customers-ac3</guid>
      <description>&lt;p&gt;If you sell a subscription with a free trial, somewhere in the software there is a rule that decides how much a trial user can do. It looks like a trivial line. In my case that line was written with paying customers in mind, and a trial that spanned two months was worth double.&lt;/p&gt;

&lt;p&gt;I'm telling the story because the same mistake, turned the other way, blocks a customer halfway through the trial. And a blocked trial user doesn't call support. They close the tab and try your competitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;Miraviso, the app I built for hair salons, has a 14-day free trial. During the trial there is a cap on haircut previews, because every preview has a cost that I pay.&lt;/p&gt;

&lt;p&gt;For subscribed salons the counter goes back to zero on the first of every month. That makes sense: they pay every month, every month they get their allowance. The problem was that the trial used the same rule. A salon that started its trial on 25 October finished it on 8 November. On 1 November the counter reset, and that salon got two full allowances instead of one.&lt;/p&gt;

&lt;p&gt;No customer was ever going to report it, of course. And no check was looking for it, because for subscribers the rule was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same mistake, the other way round
&lt;/h2&gt;

&lt;p&gt;For me it cost a bit of margin. But a badly written trial rule can do worse damage, and I came close to doing some myself while fixing it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The customer blocked on day five.&lt;/strong&gt; The cap is counted on the wrong basis, for example it adds usage from an earlier period. The customer is trying the product seriously, which makes them the best lead you have, and the software tells them they're done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo account blocked forever.&lt;/strong&gt; I had a few trial salons with no end date, the ones I use for demos. With a single cap for the whole trial, sooner or later they would have stopped and never started again. I would have found out during a demo, in front of a salon owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix that slams the door.&lt;/strong&gt; On the day you correct the rule, there are people halfway through their trial. If the count changes overnight, someone finds themselves blocked without doing anything different from the day before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The right number with the wrong label.&lt;/strong&gt; The salon dashboard said "previews this month" even for trial users. A customer who reads "month" and gets stopped after ten days thinks the software is broken.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why nobody notices
&lt;/h2&gt;

&lt;p&gt;Business rules in software are written once, at the start, when there is only one kind of customer. Then come the trial, the launch discount, the demo account, the yearly plan. Each needs a slightly different rule, and often each inherits the one already there.&lt;/p&gt;

&lt;p&gt;The whole problem sits in one word that seems obvious. For me it was "the period". For a subscriber it meant the billing month. For a trial user it should have meant the 14 days of the trial. The software used the same word for two different things.&lt;/p&gt;

&lt;p&gt;These mistakes only show on specific days: at the end of the month, in February, when a customer moves from trial to subscription. If nobody tests exactly those days, they sit there for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your software
&lt;/h2&gt;

&lt;p&gt;You don't need to read the code. Sit down for half an hour with whoever wrote it and run these checks together.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the rule in one sentence.&lt;/strong&gt; "A trial user can do at most so many things in 14 days, counted from the day they signed up." If your supplier tells it differently, you have already found something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try the awkward dates.&lt;/strong&gt; A trial that starts on the 25th, on 28 February, on 31 December. Ask your supplier to simulate them: with an automated test it takes minutes, with no need to wait for the end of the month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List your kinds of customer.&lt;/strong&gt; On trial, subscribed, demo account, moving from trial to paid mid-month, cancelling and coming back. For each: which rule applies?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at what the customer reads.&lt;/strong&gt; The number on their dashboard and the words next to it. They must say the same thing the rule says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask what happens when you fix it.&lt;/strong&gt; Do people already on trial keep the usage they have? Are they blocked overnight, or do they reach the end normally?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two people at the same moment.&lt;/strong&gt; Two employees of the same customer using the service in the same second must not be able to go over the limit together.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I fixed it
&lt;/h2&gt;

&lt;p&gt;Before touching the rule I wrote an automated test that reproduced the problem: a trial started at the end of October, a few previews, then the calendar moved to early November. The counter had to stay where it was. Right after, I wrote another one for subscribers saying the opposite: on the first of the month you start again. That way I couldn't fix the trial by breaking the subscription.&lt;/p&gt;

&lt;p&gt;Then I changed the rule: for trial users the count runs from the first day to the last, with no reset. Usage already recorded by salons halfway through the trial stayed in the count, so nobody saw their number change overnight. Demo accounts kept the monthly count, on purpose. And the dashboard now says "in your trial" instead of "this month" for trial users.&lt;/p&gt;

&lt;p&gt;An internal statistic that only I use still counts by month. It decides nothing for customers, and I noted it in the project notes. I preferred to leave it that way rather than widen the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  For the technical reader
&lt;/h2&gt;

&lt;p&gt;The fix had three constraints that are worth more than the bug itself. No real numbers below; the pattern is what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The period is a function of the customer state.&lt;/strong&gt; The old helper returned "the billing period if there is a subscription, otherwise the first of the current month". That definition was written for one kind of customer and then used for two. Now the helper takes the customer: trials with an end date count on a single bucket for the whole trial; subscribers and open-ended demo accounts keep the monthly bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage already recorded must keep counting.&lt;/strong&gt; On release day some trials already had a row for October and one for November. Changing only the key would have dropped November's usage and widened the cap for someone halfway through. So the trial counts on the bucket of the month it started in (the row the old code already wrote) plus any later rows that existed before the release.&lt;/p&gt;

&lt;p&gt;For subscribers this changes nothing, because there are no rows in the future.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reservation must stay atomic.&lt;/strong&gt; Two devices of the same customer can ask at the same instant. If both read "one left", both pass. The check and the increment have to live in one statement, so the sum of later rows goes inside it, not in a read before it.&lt;/p&gt;

&lt;p&gt;The database locks the row, checks and increments in one step; no row back means the cap is reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests first.&lt;/strong&gt; The first commit was only a failing test: a trial started late in October, some usage, the clock moved to early November, the counter must not reset. Next to it, the opposite test for subscribers, so the fix could not break billing. The "today" function is injected, so nobody waits for the end of the month. If you run a SaaS with trials, start a fake one on the 25th and move the clock a week: about ten lines of test.&lt;/p&gt;

&lt;p&gt;If you want to see how Miraviso is built as a whole, there is the &lt;a href="https://gabrielepieretti.dev/consulting/cases/miraviso/" rel="noopener noreferrer"&gt;project page&lt;/a&gt;. If you sell subscriptions or services with a trial and want to check that the rules in your software say what you decided, that is work we can do together, starting with a &lt;a href="https://gabrielepieretti.dev/consulting/?da=free-trial-rule-written-wrong&amp;amp;utm_source=devto&amp;amp;utm_medium=referral#call" rel="noopener noreferrer"&gt;free 30-minute call&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/free-trial-rule-written-wrong/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about software for small businesses, Laravel, FastAPI and privacy-by-design. &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;More here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>postgres</category>
      <category>testing</category>
      <category>startup</category>
    </item>
    <item>
      <title>Claude Duet Asks Before It Acts: What to Check Before You Hand Work to an Agent</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:21:59 +0000</pubDate>
      <link>https://dev.to/gabbrowick/claude-duet-asks-before-it-acts-what-to-check-before-you-hand-work-to-an-agent-4iih</link>
      <guid>https://dev.to/gabbrowick/claude-duet-asks-before-it-acts-what-to-check-before-you-hand-work-to-an-agent-4iih</guid>
      <description>&lt;p&gt;On October 21 Anthropic opened a research preview of &lt;a href="https://claude.com/resources/articles/claude-duet" rel="noopener noreferrer"&gt;Claude Duet&lt;/a&gt;, an app where Claude works as a proactive agent: it brings you what needs your attention, completes the tasks you give it, keeps working when you step away and checks with you before it acts on your behalf. It is aimed at professionals and small business owners. The useful question for a business owner is not whether Duet is nice. It is what to check before you let an agent, this one or any other, do things on your behalf. Read carefully, the announcement answers a good part of that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the announcement says
&lt;/h2&gt;

&lt;p&gt;Only what is written on the &lt;a href="https://claude.com/resources/articles/claude-duet" rel="noopener noreferrer"&gt;official page&lt;/a&gt; of October 21, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it is an app for iOS and Android, and it is also on claude.ai; for now access is through a waitlist, for Pro and Max subscribers in the US only;&lt;/li&gt;
&lt;li&gt;every morning it sends a short brief on the important items in your connected apps, then checks your accounts every few hours and messages you only when something needs you;&lt;/li&gt;
&lt;li&gt;it has a virtual machine with its own browser, so it works with your phone and laptop off;&lt;/li&gt;
&lt;li&gt;it reads, drafts and prepares work on its own, but before it sends a message, makes a payment, signs a document or deletes anything it shows you what it is about to do and waits for your approval;&lt;/li&gt;
&lt;li&gt;some things are off-limits even with your approval, such as trading securities or moving money between accounts;&lt;/li&gt;
&lt;li&gt;credentials follow the design of Claude Managed Agents: it never directly sees your passwords or sign-in tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anthropic itself says this is an early version that may misread a thread or miscalibrate what is urgent, and that it cannot yet show you its browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right line: what can be undone
&lt;/h2&gt;

&lt;p&gt;Look at the list of actions where Duet stops: send, pay, sign, delete. They are not the "important" actions. They are the ones you cannot undo. A bad draft goes in the bin, a sent email does not; a sloppy quote can be fixed, a wire transfer cannot be called back.&lt;/p&gt;

&lt;p&gt;That is the right criterion, and it applies to any agent, bought or built. If a vendor pitches you an AI automation, the first question to ask is: which actions does it take on its own, and which ones stop and wait for me? If the answer is vague, the model is not the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Duet choices to demand everywhere
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Credentials never reach the agent.&lt;/strong&gt; In Duet the agent never sees passwords or tokens. A rule like "do not reveal the password" is something the model has to follow; an architecture where the password never reaches the model does not depend on the model at all. The second is sturdier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some things are simply forbidden.&lt;/strong&gt; Duet has actions it will not take even with your yes. It sounds excessive until you think about how approvals happen in real life: quickly, at the tenth notification of the day, maybe away from the office. For actions where a mistake costs too much, it is better if the button does not exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A check on sensitive data going out.&lt;/strong&gt; Duet ships with a dedicated classifier that checks each message: it refuses to share card numbers and government ID, and lets health or financial information through only when you have explicitly told it to. An agent that reads your mail and writes on your behalf is constantly deciding where information goes, and that is exactly where this check belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things the announcement does not make clear
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Who writes the summary you approve.&lt;/strong&gt; An agent that has read an email with hidden instructions could describe a harmful action in harmless words. So it matters whether the confirmation screen is written by the agent in its own words or by the system from the real data of the action. The announcement says Duet shows you exactly what it is about to do, but not how; Anthropic promises more details on safety in the coming days. Before connecting my accounting to it, I would wait for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the data goes.&lt;/strong&gt; The agent runs on an Anthropic virtual machine, on the apps you connect. For a business in the EU, the question of where mail and invoices pass through, and in what data protection role for the vendor, is the first one to ask, not the last. The announcement does not answer it, and I am not going to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proactivity has a cost
&lt;/h2&gt;

&lt;p&gt;An honest detail in the announcement: Duet checks your accounts every few hours instead of nonstop, because checking nonstop would use up the plan's limits much faster. Background checks count toward those limits, and larger tasks, like a financial model or a deck, use more. An agent that keeps an eye on things costs something even when it finds nothing: pick a few things to watch, and watch them well.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I set up mine.&lt;/strong&gt; I use an assistant built on Claude Code every day, and its principles are very close to Duet's. It runs in an isolated sandbox. Actions that cannot be undone (merging to the main branch, deploys, messages to third parties, payments) go through a separate approver, which sends me a summary on Telegram with two buttons, Approve and Reject. It keeps a git-based memory that is consolidated every night and that I approve. Credentials never pass through the chat, and it treats emails and web pages as data, never as instructions. On its own it runs scheduled jobs: audits of my sites and a morning check. It is not infallible: now and then it misreads a request or sticks to the wrong approach and needs correcting. That is exactly why the confirmation button sits where it does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When you need it and when you do not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Duet makes sense&lt;/strong&gt; if your day is mail, calendar, invoices and widely used SaaS tools, and you are fine with the agent running on a managed service. Two practical limits, though. The examples in the announcement are mostly US tools (QuickBooks, Gusto, Docusign, HubSpot): whether there are connectors for your own management software or e-invoicing has to be checked one by one in the marketplace. And for now you cannot try it outside the US.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom agent makes sense&lt;/strong&gt; when it has to work on your servers, your code or your business software, when the approval rules have to be yours, or when you need to know exactly where the data goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need neither&lt;/strong&gt; if the process is not defined yet. An agent that automates a mess produces the mess faster, and asks you to confirm it every time.&lt;/p&gt;

&lt;p&gt;I also wrote about the line between approving an action and protecting data in the context of the &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-ai-sdk-tool-approvals/" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt;. If you want to figure out which steps in your business can be handed to an agent, and where the confirmation button belongs, that is what I do in my &lt;a href="https://gabrielepieretti.dev/consulting/" rel="noopener noreferrer"&gt;AI automation consulting&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gabrielepieretti.dev/blog-en/claude-duet-proactive-agent/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>automation</category>
      <category>security</category>
    </item>
    <item>
      <title>When You Don't Need an LLM: Splitting Work Between Device, Classifier and Big Model</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:26:15 +0000</pubDate>
      <link>https://dev.to/gabbrowick/when-you-dont-need-an-llm-splitting-work-between-device-classifier-and-big-model-cmn</link>
      <guid>https://dev.to/gabbrowick/when-you-dont-need-an-llm-splitting-work-between-device-classifier-and-big-model-cmn</guid>
      <description>&lt;p&gt;In version 1.0 of the &lt;a href="https://laravel.com/blog/introducing-laravel-ai-sdk-v1" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt;, classification becomes a capability of its own, alongside text, images, audio and embeddings. That is not a catalogue detail: it is the framework saying out loud something anyone running an AI product has already paid to learn — that "answer a closed question" and "generate something" are two different jobs, with different costs and different risk profiles. In Miraviso that split was not an optimisation that arrived later. It is the reason the product can claim that hair colour never leaves the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in the SDK
&lt;/h2&gt;

&lt;p&gt;The API is deliberately narrow. You ask a set of typed questions and get typed answers back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Classification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Classification\Boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Classification\Choice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Classification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ticket&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'is_urgent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Does this message convey urgency?'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'department'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Which team should handle this?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'billing'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Payments, invoicing, refunds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'technical'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bugs, outages, integrations'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'sales'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Pricing, plans, upgrades'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a &lt;code&gt;Score&lt;/code&gt; type returning 0.0 to 1.0, and a &lt;code&gt;Str::decide&lt;/code&gt; macro for a single yes/no question with a certainty threshold. It runs on TypeSafe's Jev models and on OpenRouter; the announcement promises answers "in milliseconds at a fraction of the price" of a traditional LLM. I take that for what it is — a vendor claim, to be measured against your own workload — but the shape of the API interests me regardless of the benchmark.&lt;/p&gt;

&lt;p&gt;The point is that the return type is closed. &lt;code&gt;$response['department']-&amp;gt;choice&lt;/code&gt; is one of the three values you declared, or it is nothing. No JSON to repair, no prompt politely begging for a one-word answer, no retry for when the model felt like explaining itself. Anyone who has shipped an LLM as a router knows how much defensive code disappears when the contract looks like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three tiers I sort work into
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt; — the virtual mirror for hair salons — every piece of work lands in one of three tiers, and the tier is decided before any code is written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the device.&lt;/strong&gt; Everything that is geometry and segmentation. The colour try-on runs entirely on the tablet with MediaPipe on-device: the model finds the hair in the frame, the colour is composited locally, no frame leaves the hardware. This is not a cost decision, it is a product decision: it is a sentence I can say to a salon owner without an asterisk, and it is verifiable by watching the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closed decisions.&lt;/strong&gt; Questions with a finite set of answers — the ones that now have a dedicated capability in Laravel's SDK. In my case almost all of them sit on the back-office side and never touch an image: routing an incoming request, deciding whether a salon's note contains something to be treated as sensitive, deciding whether a piece of text should be shown to an operator. A generative model answers these perfectly well, and is the most expensive, slowest and hardest-to-test instrument I could possibly pick for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actual generation.&lt;/strong&gt; Exactly one thing: the haircut preview, which requires producing new, plausible pixels. It runs on Gemini via Vertex AI in an EU region, requires explicit consent, and is never written to disk. It is the only part of the pipeline that needs a large model — and also the only one that carries a privacy story I have to explain in full to the end user. As I wrote about &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-ai-sdk-tool-approvals/" rel="noopener noreferrer"&gt;tool approvals in the SDK&lt;/a&gt;, the boundary that matters sits before the model, not inside the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tier has to be decided up front
&lt;/h2&gt;

&lt;p&gt;The temptation, once you have a working generative integration, is to use it for everything: the client is already there, the error handling is already there, one more question costs five minutes. That is precisely how a product accumulates generative calls nobody designed.&lt;/p&gt;

&lt;p&gt;Three consequences I have watched happen in my own code, in order of how much they annoyed me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The data perimeter widens quietly.&lt;/strong&gt; Every question handed to a remote model is one more piece of data leaving. If a classifier or a plain rule could have answered it, you widened the perimeter for nothing — and the perimeter is the thing you eventually have to describe in a privacy notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests stop being tests.&lt;/strong&gt; A closed question has a set of acceptable answers and you assert on it. A natural-language answer gets checked by another model, or by eye, and at that point the suite no longer tells you whether you broke something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deprecations hurt more.&lt;/strong&gt; I &lt;a href="https://gabrielepieretti.dev/blog-en/ai-model-deprecations-saas/" rel="noopener noreferrer"&gt;wrote about this already&lt;/a&gt;: when an endpoint is retired, the migration hurts in proportion to how many decisions you leaned on that model for. Closed decisions can be re-measured in half a day. Generation cannot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The craft has not changed
&lt;/h2&gt;

&lt;p&gt;Said without reverence for the historical moment: this is the same question we have been asking for twenty years in front of a slow query. Do I really need to call the service, or can I compute the answer here? The difference is that this time the call does not only cost latency — it costs money per request and it moves data across a boundary you promised to hold.&lt;/p&gt;

&lt;p&gt;It is also why I keep hand-writing &lt;a href="https://gabrielepieretti.dev/games/" rel="noopener noreferrer"&gt;the logic games on this site&lt;/a&gt; with no libraries: when the minesweeper solver has to guarantee that a grid never forces a guess, that guarantee comes from a proof, not an estimate. Not every hard problem is a model problem — and telling which ones are not has become, over the last two years, one of the few skills that actually moves the cost of a product.&lt;/p&gt;

&lt;p&gt;Which is the good news here: having classification as a separate capability makes that choice explicit in the code, instead of leaving it implicit in a prompt. A framework that forces you to declare what kind of work you are asking for is a framework that helps you not ask for the wrong one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/when-you-dont-need-an-llm/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about Laravel, privacy-by-design and building a vertical B2B SaaS solo — &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;more here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>laravel</category>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>Slow Jobs, Killer Containers: Laravel Queue Timeouts and Crashes in Docker</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:14:06 +0000</pubDate>
      <link>https://dev.to/gabbrowick/slow-jobs-killer-containers-laravel-queue-timeouts-and-crashes-in-docker-204g</link>
      <guid>https://dev.to/gabbrowick/slow-jobs-killer-containers-laravel-queue-timeouts-and-crashes-in-docker-204g</guid>
      <description>&lt;p&gt;A job that sends an email takes half a second. A job that calls a generative model takes as long as the provider feels like, and sometimes a lot longer. Run that job in a Docker container and it has three ways to die: the worker timeout fires, the kernel kills it for memory, or a deploy pulls the container out from under it. Until this month Laravel treated those three deaths differently and didn't tell you. Laravel 13.33 and 13.34 change two of them. Here's which two, and what's still on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  One job, three ways to die
&lt;/h2&gt;

&lt;p&gt;Picture a job that sends an image to a model and waits for the answer, running under &lt;code&gt;php artisan queue:work&lt;/code&gt; inside a container. In production it can end like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worker timeout.&lt;/strong&gt; The job exceeds &lt;code&gt;--timeout&lt;/code&gt; (or the job's &lt;code&gt;$timeout&lt;/code&gt;). Laravel uses &lt;code&gt;pcntl&lt;/code&gt; and &lt;code&gt;SIGALRM&lt;/code&gt;; when the alarm fires, the worker has always killed itself with &lt;code&gt;SIGKILL&lt;/code&gt;. The timeout is recorded against the job (which fails immediately if it has &lt;code&gt;$failOnTimeout&lt;/code&gt;) and the process is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container OOM.&lt;/strong&gt; The process goes over the container's memory limit (&lt;code&gt;mem_limit&lt;/code&gt; or &lt;code&gt;deploy.resources.limits.memory&lt;/code&gt; in Compose). The kernel sends &lt;code&gt;SIGKILL&lt;/code&gt;: no exception, no &lt;code&gt;failed()&lt;/code&gt; callback, no application log. If &lt;code&gt;php&lt;/code&gt; is PID 1, the container exits with 137.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy.&lt;/strong&gt; &lt;code&gt;docker compose up&lt;/code&gt; recreates the container. It sends &lt;code&gt;SIGTERM&lt;/code&gt;, the worker tries to finish its current job, and after &lt;code&gt;stop_grace_period&lt;/code&gt; — ten seconds by default — comes &lt;code&gt;SIGKILL&lt;/code&gt;. For a job waiting on a model, ten seconds isn't much.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the last two cases the job stays reserved until &lt;code&gt;retry_after&lt;/code&gt; expires, then becomes available again and another worker picks it up. As far as the framework is concerned nothing happened: no exception was thrown, so no exception was counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap: &lt;code&gt;maxExceptions&lt;/code&gt; can't see crashes
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;$tries&lt;/code&gt; the damage is bounded: every time the job is popped the attempt counter goes up, and eventually it fails. But if you call rate-limited external APIs you often can't use &lt;code&gt;$tries&lt;/code&gt;, because the &lt;code&gt;RateLimited&lt;/code&gt; middleware releases the job and burns an attempt even when the job never actually ran. So the usual setup becomes &lt;code&gt;$tries = 0&lt;/code&gt;, a &lt;code&gt;$maxExceptions&lt;/code&gt; cap, and &lt;code&gt;retryUntil()&lt;/code&gt; as a safety net.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;maxExceptions&lt;/code&gt; only counts real exceptions. A job that pushes the worker into OOM, gets killed, returns to the queue and pushes the next worker into OOM increments nothing. It loops until &lt;code&gt;retryUntil()&lt;/code&gt; runs out, burning CPU and — if it calls a paid model — billed requests. The author of &lt;a href="https://github.com/laravel/framework/pull/61737" rel="noopener noreferrer"&gt;PR #61737&lt;/a&gt; describes hitting exactly this with a worker being OOM-killed on Laravel Cloud.&lt;/p&gt;

&lt;p&gt;The fix is opt-in, per job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[MaxExceptions(3), CountCrashesAsExceptions]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeneratePreview&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;retryUntil&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DateTime&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is simple and honest. When the worker picks up the job it writes a marker to the cache, and deletes it when the attempt ends. If the marker is still there on the next attempt, the previous one died badly, and that death counts as one exception. It costs two cache calls per attempt, and it needs a cache shared between workers: with the &lt;code&gt;array&lt;/code&gt; driver, or &lt;code&gt;file&lt;/code&gt; inside separate containers, the marker dies with the container. It shipped in &lt;a href="https://github.com/laravel/framework/releases/tag/v13.34.0" rel="noopener noreferrer"&gt;13.34.0&lt;/a&gt;; instead of the attribute you can also set &lt;code&gt;public $countCrashesAsExceptions = true;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout that doesn't kill the worker
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/pull/61591" rel="noopener noreferrer"&gt;PR #61591&lt;/a&gt;, shipped in 13.33, adds a static flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$killOnTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it off, the worker doesn't kill itself when the timeout fires: it throws a &lt;code&gt;TimeoutExceededException&lt;/code&gt; inside the job. The job gets a chance to clean up, close what it opened, even catch the exception and decide what to do. The worker survives and moves on to the next job without re-bootstrapping the framework and reopening every connection.&lt;/p&gt;

&lt;p&gt;I'd be careful with it, for two reasons spelled out in the discussion on &lt;a href="https://github.com/laravel/framework/pull/61622" rel="noopener noreferrer"&gt;PR #61622&lt;/a&gt;. First, an exception thrown from a signal handler only propagates once PHP gets back to executing instructions; a job stuck inside a blocking system call never gets there. Second, if the exception does unwind, the worker carries on with whatever state the interrupted job left behind. &lt;code&gt;SIGKILL&lt;/code&gt; is brutal, but it guarantees that state dies with the process. And a broad &lt;code&gt;try/catch (\Throwable)&lt;/code&gt; inside &lt;code&gt;handle()&lt;/code&gt; will swallow the exception and make the timeout disappear altogether — which is why the default stays &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Line up your timeouts, inside out
&lt;/h2&gt;

&lt;p&gt;Neither flag replaces the thing that actually matters: a strict ordering of timeouts, from the innermost to the outermost. In years of running Laravel queues this is the part I've seen get wrong most often, and rarely out of ignorance — the four numbers just live in four different files and nobody reads them side by side.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The HTTP timeout&lt;/strong&gt; on the model call comes first. &lt;code&gt;Http::timeout(60)&lt;/code&gt; or your SDK's equivalent. It's the only one that turns waiting into a normal, catchable, counted exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The job's &lt;code&gt;$timeout&lt;/code&gt;&lt;/strong&gt;, a few seconds higher. It only fires if the first one wasn't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retry_after&lt;/code&gt;&lt;/strong&gt; on the queue connection, above the job timeout. If it's lower, a second worker picks the job up while the first is still running it. The docs say so; with a paid model it means paying twice for the same answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stop_grace_period&lt;/code&gt;&lt;/strong&gt; on the Compose worker service, above the job timeout, so a deploy waits for the running job instead of killing it.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app:latest&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan queue:work --timeout=90 --memory=256&lt;/span&gt;
    &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on &lt;code&gt;--memory&lt;/code&gt;: it stops the worker cleanly once memory goes over the threshold, but it checks &lt;em&gt;between&lt;/em&gt; jobs. It won't save you from a single job that balloons past the container limit while decoding an image. For that you need a container limit with headroom, and — now — &lt;code&gt;CountCrashesAsExceptions&lt;/code&gt; so you don't end up in a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your payload is a file, even if you don't call it one
&lt;/h2&gt;

&lt;p&gt;One last point, from &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, the SaaS I'm building for hair salons. There, the haircut preview is generated by Gemini on an EU server, with consent, and is never written to disk. That stack is FastAPI, not Laravel, but the rule translates word for word: put an image in a job payload and you've written it to your queue backend — Redis with persistence, a &lt;code&gt;jobs&lt;/code&gt; table, and on failure &lt;code&gt;failed_jobs&lt;/code&gt;. Retries read it back from there. I wrote about these undeclared copies in &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-sensitive-data-in-logs/" rel="noopener noreferrer"&gt;a piece on sensitive data in logs&lt;/a&gt;; queues follow the same logic. If the promise is "never on disk", an async job is the wrong home for that data, and automatic retries need rethinking accordingly.&lt;/p&gt;

&lt;p&gt;The rest is hygiene: a PR that counts crashes, a flag that makes timeouts manageable, and four numbers in the right order. Of those, the four numbers are the only thing no upgrade will ever do for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-queue-timeouts-crashes-docker/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about Laravel, privacy-by-design and building a vertical B2B SaaS solo — &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;more here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>docker</category>
      <category>ai</category>
    </item>
    <item>
      <title>Approving a Tool Is Not Approving Data: What Laravel AI SDK 1.0 Changes, and What It Doesn't</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Fri, 25 Sep 2026 20:23:56 +0000</pubDate>
      <link>https://dev.to/gabbrowick/approving-a-tool-is-not-approving-data-what-laravel-ai-sdk-10-changes-and-what-it-doesnt-1gj</link>
      <guid>https://dev.to/gabbrowick/approving-a-tool-is-not-approving-data-what-laravel-ai-sdk-10-changes-and-what-it-doesnt-1gj</guid>
      <description>&lt;p&gt;&lt;a href="https://laravel.com/blog/introducing-laravel-ai-sdk-v1" rel="noopener noreferrer"&gt;Laravel AI SDK 1.0&lt;/a&gt; shipped on September 23, and one of the headline features is the &lt;code&gt;Approvable&lt;/code&gt; contract: a tool that implements it pauses the agent until a human approves the call, rejects it with a reason, or rewrites the arguments. It is the right thing to have in the framework. But after a year of building a product that sends customer images to a model, my reaction was colder than I expected: tool approval is a control over an &lt;em&gt;action&lt;/em&gt;, and the thing that keeps me up at night is a control over &lt;em&gt;data&lt;/em&gt;. Those are not the same boundary, and putting them in the same place is an elegant way to get it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the contract actually does
&lt;/h2&gt;

&lt;p&gt;The mechanism is clean. A tool declares &lt;code&gt;Approvable&lt;/code&gt;, uses the &lt;code&gt;InteractsWithApprovals&lt;/code&gt; trait, and when the model decides to call it the conversation stops and hands back the pending calls with the arguments the model picked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Concerns\InteractsWithApprovals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Approvable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeleteFile&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Approvable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithApprovals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You resume by passing a decision per call: approve, reject with a reason the model gets to read, or edit the arguments before the tool runs. It works with &lt;code&gt;prompt&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt; and the broadcast methods, so it survives the case where the agent runs on a queue and the approval arrives twenty minutes later from a different HTTP session. That part — persisting the state of a suspended agent — is the boring work nobody wants to rewrite, and it is the real reason to reach for the package.&lt;/p&gt;

&lt;p&gt;The mental model it suggests, though, is: the agent proposes, the human confirms, the action runs. That is perfect for &lt;code&gt;DeleteFile&lt;/code&gt;, for a refund, for an email going out. It is much weaker when the risky part is not what the tool &lt;em&gt;does&lt;/em&gt;, but what the prompt &lt;em&gt;carries with it&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  In Miraviso the boundary sits lower down
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, the virtual mirror for hair salons, the pipeline has two declared privacy treatments, because they genuinely are two different mechanisms.&lt;/p&gt;

&lt;p&gt;The colour try-on runs entirely on the tablet: MediaPipe segmentation on-device, colour composited locally, no frame ever leaving the hardware. The haircut preview does not: that one needs a generative model, it runs on Gemini via Vertex AI in an EU region, it requires explicit consent from the person sitting in the chair, and the result is never written to disk.&lt;/p&gt;

&lt;p&gt;Take that second path and try modelling it as an approvable tool. The approval would read something like: "the model wants to call &lt;code&gt;generate_haircut_preview&lt;/code&gt; with this image — confirm?" But who confirms? The salon operator — the only person holding the tablet, and the only one the agent loop can question. And the consent I need is not theirs. It belongs to the client, who never touches the interface and who has to be able to say no before the camera does anything useful at all.&lt;/p&gt;

&lt;p&gt;Hence the rule I ended up with: &lt;strong&gt;human approval inside the agent protects whoever is driving the agent. Consent to processing protects whoever is inside the data.&lt;/strong&gt; If the only gate lives in the agent loop, I have built a confirmation dialog for the wrong person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that sits before the model
&lt;/h2&gt;

&lt;p&gt;In my pipeline consent is not a turn in a conversation: it is session state, and the client cannot even compose a generative request until that state exists. The FastAPI endpoint that talks to Vertex refuses upstream, not after the fact, and not by trusting a flag the tablet sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/preview/cut&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;preview_cut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CutRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_session&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generative_preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing consent for generative preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# from here on the frame only ever exists in memory
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three practical consequences, all more boring than a PHP contract and all more important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;consent state lives server-side and expires with the chair session, not with the salon account: the next client starts from zero, because the previous client's consent is not a property of the tablet;&lt;/li&gt;
&lt;li&gt;revocation has to work mid-flight, which means the gate is rechecked after every &lt;code&gt;await&lt;/code&gt; that can take real time, not just at the entrance;&lt;/li&gt;
&lt;li&gt;"never written to disk" means never: no temp files, no HTTP client cache, no logs echoing the request body. That is not a property you defend with an architecture decision — you defend it by auditing every library that touches those bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote about that last point separately, because it is a far more common failure than it looks: &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-sensitive-data-in-logs/" rel="noopener noreferrer"&gt;sensitive data ends up in logs&lt;/a&gt; almost always through a failed query or an APM trace, not through a line somebody wrote on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would use it tomorrow
&lt;/h2&gt;

&lt;p&gt;None of this is a criticism of the package; it is a division of labour. On the back-office side — the Laravel side, where customer records, appointments and invoicing live — an agent that proposes actions and waits for confirmation is exactly the right tool. Merging two duplicate customer records, moving a run of appointments, issuing a credit note: these are operations where the argument the model picked is precisely the thing I want to be able to &lt;em&gt;correct&lt;/em&gt;, and being able to rewrite arguments before execution is worth more than being able to say no.&lt;/p&gt;

&lt;p&gt;Worth flagging too: per-step middleware, which in 1.0 runs on every generation step instead of once per prompt. You get a &lt;code&gt;PendingStep&lt;/code&gt; and can swap the model, strip tools or lower the token budget as the conversation goes on. For a solo founder paying for tokens out of pocket that is a concrete cost lever — dropping the expensive tool after its first use is now one line, and used to be an architecture problem.&lt;/p&gt;

&lt;p&gt;The rule I am taking away, and it travels well beyond this SDK: when a framework hands you a gate, ask who is standing on the handle side. If the answer is not the person whose data is passing through, the gate is useful but it is not yours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-ai-sdk-tool-approvals/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>privacy</category>
      <category>php</category>
    </item>
    <item>
      <title>Storage::path() Could Walk Out of the Disk: The Path Traversal Laravel 13.30 Closes</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:48:32 +0000</pubDate>
      <link>https://dev.to/gabbrowick/storagepath-could-walk-out-of-the-disk-the-path-traversal-laravel-1330-closes-24i4</link>
      <guid>https://dev.to/gabbrowick/storagepath-could-walk-out-of-the-disk-the-path-traversal-laravel-1330-closes-24i4</guid>
      <description>&lt;p&gt;Up to Laravel 13.29, &lt;code&gt;Storage::get('../../../.env')&lt;/code&gt; threw an exception while &lt;code&gt;Storage::path('../../../.env')&lt;/code&gt; happily returned the absolute path of your &lt;code&gt;.env&lt;/code&gt;. Same facade, same disk, same argument, opposite answers. Laravel 13.30, released on September 1, brings the two calls in line. It is worth understanding how the gap existed, where the pattern actually bites, and why the right security fix here is a design habit rather than a flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two methods on the same facade disagreed
&lt;/h2&gt;

&lt;p&gt;Almost everything you ask of &lt;code&gt;Storage&lt;/code&gt; goes through Flysystem: &lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt;, &lt;code&gt;readStream()&lt;/code&gt;. Flysystem normalizes the path before using it and, if the normalized result escapes the disk root, throws &lt;code&gt;PathTraversalDetected&lt;/code&gt;. That is why, in years of Laravel applications, nobody worried much about a &lt;code&gt;..&lt;/code&gt; passed to &lt;code&gt;Storage::get()&lt;/code&gt;: the framework blocked it for you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Storage::path()&lt;/code&gt; was the exception. It does not read the file; it only returns the native path string, and to do so it went straight to &lt;code&gt;PathPrefixer::prefixPath()&lt;/code&gt;, which concatenates the disk prefix with whatever you hand it. No normalization, no check. On the default &lt;code&gt;local&lt;/code&gt; disk, &lt;code&gt;path('../../../.env')&lt;/code&gt; resolved out of &lt;code&gt;storage/app&lt;/code&gt; all the way to the project root. Scoped disks had the same hole: &lt;code&gt;path('../file.txt')&lt;/code&gt; walked straight out of the configured prefix as if it were not there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/laravel/framework/pull/61343" rel="noopener noreferrer"&gt;KIKOmanasijev's PR&lt;/a&gt;, merged on August 27 and shipped in 13.30.0, runs &lt;code&gt;path()&lt;/code&gt; through the same &lt;code&gt;WhitespacePathNormalizer&lt;/code&gt; that Flysystem builds for every other call, with the same defaults. The result is that &lt;code&gt;path()&lt;/code&gt; now returns exactly the string the driver computes internally, so the two can no longer disagree about what a path means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the pattern bites: attachment downloads
&lt;/h2&gt;

&lt;p&gt;The code that makes this asymmetry dangerous is trivial, which is exactly why the story interests me. Every line-of-business app has a spot where the user downloads a document: an invoice, an attachment, a report export. The lazy version of that route looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/download'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;?path=../../../.env&lt;/code&gt;, until two weeks ago, that route served your database credentials to anyone with an account. And &lt;code&gt;Storage::path()&lt;/code&gt; is precisely the method you reach for when you &lt;em&gt;don't&lt;/em&gt; want Flysystem to read the file: when you hand it to &lt;code&gt;response()-&amp;gt;download()&lt;/code&gt;, to an external process like ImageMagick or LibreOffice for a conversion, to a PDF library that wants a native path. In other words, exactly the cases where the file travels from the disk to the outside world, often after a detour through a queue where nobody checks anymore who asked for what.&lt;/p&gt;

&lt;p&gt;After eleven years of building Laravel business applications I have seen enough download routes to say that the "path comes from the query string" variant is not rare. It is almost always born in good faith: the front end already has the path because it received it in a JSON response, and passing it back looks like the simplest thing. It is also the wrong thing, and 13.30 does not change that verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework's fix is not your fix
&lt;/h2&gt;

&lt;p&gt;Upgrade to 13.30: it is free and it closes the &lt;code&gt;path()&lt;/code&gt; hole. But normalization stops the &lt;code&gt;..&lt;/code&gt;, not access to a file &lt;em&gt;inside&lt;/em&gt; the disk that does not belong to the person asking. With &lt;code&gt;?path=invoices/another-customer/2026-03.pdf&lt;/code&gt; the path is perfectly legitimate for Flysystem and perfectly illegitimate for you. No normalizer can know that.&lt;/p&gt;

&lt;p&gt;The rule I have applied for years is that &lt;strong&gt;the path on disk is never an input&lt;/strong&gt;. It is a column in a table, and the user passes you the row's identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/attachments/{attachment}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Attachment&lt;/span&gt; &lt;span class="nv"&gt;$attachment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attachment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attachments'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attachment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attachment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;original_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen in three lines. Route model binding turns the input into an existing row or a 404. The policy answers the question the filesystem cannot ask: is this person allowed to see this file? And &lt;code&gt;download()&lt;/code&gt; goes through Flysystem, so even if someone one day wrote a row with a strange path, the traversal would still be rejected. The 13.30 fix becomes a third layer of defense, not the only one.&lt;/p&gt;

&lt;p&gt;If you truly need &lt;code&gt;path()&lt;/code&gt; — for an external process, say — the same discipline applies: the path comes from the model, never from the request, and it is still worth checking that &lt;code&gt;realpath()&lt;/code&gt; of the result starts with &lt;code&gt;realpath()&lt;/code&gt; of the disk root. It is one extra line that, on Laravel &amp;lt; 13.30, is the only thing between you and your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer places a byte can live, fewer paths to defend
&lt;/h2&gt;

&lt;p&gt;This bug reminded me of a choice I made in &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, my SaaS for hair salons. The haircut preview is generated on a server in the EU, with consent, and is never written to disk: it is born in memory, returned to the salon's tablet, and gone. That decision was not made with path traversal in mind; it came from privacy — a client's face should not outlive the request that generated it. But it has a side effect I appreciate more today: a file that does not exist has no path, and a path that does not exist can be neither traversed nor guessed. The same goes for the sensitive notes, which the server stores as &lt;a href="https://gabrielepieretti.dev/blog-en/sealed-envelopes-sensitive-data-saas/" rel="noopener noreferrer"&gt;sealed envelopes it cannot open&lt;/a&gt;: even an improper download would return something unreadable.&lt;/p&gt;

&lt;p&gt;That model does not apply to everything. A business app has to keep invoices, and invoices have to be downloadable. But the question to ask is the one I asked for Miraviso: &lt;em&gt;does this file need to exist on disk, and for how long?&lt;/em&gt; Every file you decide not to write is a download route you do not have to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do today
&lt;/h2&gt;

&lt;p&gt;Upgrade to 13.30 and grep your code for &lt;code&gt;Storage::path(&lt;/code&gt; and &lt;code&gt;-&amp;gt;path(&lt;/code&gt;: for every hit, trace the argument back to its origin. If at any point you reach &lt;code&gt;$request&lt;/code&gt;, that is the route to rewrite with a binding and a policy, whatever framework version you run. It is a less entertaining audit than &lt;a href="https://gabrielepieretti.dev/games/minesweeper/" rel="noopener noreferrer"&gt;a minesweeper that never makes you guess&lt;/a&gt;, but the rule there is the same: a cell only opens once you can prove it is safe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-storage-path-traversal/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;. I write about Laravel, privacy-by-design and building a vertical B2B SaaS solo — &lt;a href="https://gabrielepieretti.dev/blog-en/" rel="noopener noreferrer"&gt;more here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Laravel's Native Vector Search Now Speaks MariaDB — Plain MySQL Is Still Left Out</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 08 Sep 2026 16:13:32 +0000</pubDate>
      <link>https://dev.to/gabbrowick/laravels-native-vector-search-now-speaks-mariadb-plain-mysql-is-still-left-out-i2p</link>
      <guid>https://dev.to/gabbrowick/laravels-native-vector-search-now-speaks-mariadb-plain-mysql-is-still-left-out-i2p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-vector-search-mariadb-mysql/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On August 20, PR &lt;a href="https://github.com/laravel/framework/pull/61250" rel="noopener noreferrer"&gt;#61250&lt;/a&gt; was merged into Laravel's 13.x branch, bringing the query builder's native vector search — &lt;code&gt;whereVectorSimilarTo&lt;/code&gt; and friends — to MariaDB. Until then it was PostgreSQL-with-pgvector only. I care about this for two reasons: I spent eleven years building business software on Laravel and MySQL, and the &lt;em&gt;way&lt;/em&gt; the PR is written is a small lesson in driver design worth more than the feature itself. There's a third, less cheerful reason too: if your projects run on plain MySQL, as most of my older ones do, you're still left out — and not because anyone was lazy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was there before: a hard-coded check
&lt;/h2&gt;

&lt;p&gt;Laravel 13 ships four methods for working with embeddings straight from the query builder: &lt;code&gt;whereVectorSimilarTo&lt;/code&gt;, &lt;code&gt;whereVectorDistanceLessThan&lt;/code&gt;, &lt;code&gt;orderByVectorDistance&lt;/code&gt; and &lt;code&gt;selectVectorDistance&lt;/code&gt;. The idea is the usual one: you write the query in fluent PHP, the framework compiles it into the right SQL for your database.&lt;/p&gt;

&lt;p&gt;Except that until mid-August, "your database" meant exactly one. Inside &lt;code&gt;Query\Builder&lt;/code&gt; sat a hand-written &lt;code&gt;instanceof PostgresConnection&lt;/code&gt; check, and the distance SQL itself — pgvector's &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator — was inlined right there in the builder rather than living in the driver's grammar. It worked, but it was the classic kind of debt you don't notice until somebody tries to add a second database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the PR does: move the question where it belongs
&lt;/h2&gt;

&lt;p&gt;The refactoring is small and clean. The question "does this driver know how to compute vector distances?" moves from the connection class to the grammar, via a pair of methods — &lt;code&gt;supportsVectorDistance()&lt;/code&gt; and &lt;code&gt;compileVectorDistanceExpression($column)&lt;/code&gt; — that each driver can override. It's the same pattern Laravel has used for years for &lt;code&gt;compileRandom()&lt;/code&gt; or savepoint support: a base implementation on the grammar, per-driver overrides.&lt;/p&gt;

&lt;p&gt;With the question in the right place, adding MariaDB becomes almost trivial: its grammar compiles the distance into &lt;code&gt;vec_distance_cosine()&lt;/code&gt;, the native function MariaDB has shipped since 11.7 Community (11.4.5-3 on Enterprise), alongside a real &lt;code&gt;VECTOR&lt;/code&gt; column type. The schema side — &lt;code&gt;typeVector()&lt;/code&gt; and vector indexes — already existed from an earlier PR; only the query side was missing. Five days later a &lt;a href="https://github.com/laravel/framework/pull/61337" rel="noopener noreferrer"&gt;follow-up, #61337&lt;/a&gt;, landed with an SQL fix and an &lt;code&gt;AsVector&lt;/code&gt; Eloquent cast.&lt;/p&gt;

&lt;p&gt;This is what I take home as someone who designs APIs, even before wearing the user hat: the moment you catch yourself writing &lt;code&gt;instanceof SomethingConnection&lt;/code&gt; outside the driver, the feature is living in the wrong place. You'll ship the first database either way; it's the second one that hands you the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part: plain MySQL stays out
&lt;/h2&gt;

&lt;p&gt;And my MySQL-based systems? Nothing. &lt;code&gt;whereVectorSimilarTo()&lt;/code&gt; on a standard MySQL connection still throws a &lt;code&gt;RuntimeException&lt;/code&gt; — the PR merely updated the message to mention MariaDB. The reason isn't Laravel: MySQL Community and Enterprise, in the standard binaries, have no native vector distance function. &lt;code&gt;DISTANCE()&lt;/code&gt; and &lt;code&gt;VECTOR_DISTANCE()&lt;/code&gt; exist only on HeatWave (so, on OCI) and on MySQL AI. If you're not on Oracle's cloud, there is no SQL to compile.&lt;/p&gt;

&lt;p&gt;Just as interesting is what the PR &lt;em&gt;refused&lt;/em&gt; to do: a PHP-side fallback that fetches rows and computes similarity in memory. It would have been convenient to announce and disastrous to use — it would silently break the semantics of &lt;code&gt;limit()&lt;/code&gt; and pagination, and change the performance contract of the query without telling anyone. Letting the exception fly is the honest choice: a clear error today beats a mysterious slowdown in production six months from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do
&lt;/h2&gt;

&lt;p&gt;For an existing MySQL application that wants semantic search — matching "frizz treatment" when the user types "puffy hair", say — I see three options today, in my order of preference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate moving to MariaDB&lt;/strong&gt;, if your app uses MySQL in a standard enough way that the migration is boring rather than heroic. With this PR, MariaDB has become the cheapest way to get native vectors while staying in the MySQL family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate service just for search&lt;/strong&gt; — a small Postgres with pgvector next to the main MySQL, kept in sync from application events. More moving parts, but it doesn't touch the database everything else lives on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait&lt;/strong&gt;, which is a legitimate strategy: semantic search in a line-of-business app is almost always a nice-to-have, and wrong infrastructure is paid for over years.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For new projects the question settles itself: when I picked Postgres for &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, vectors weren't even on my radar, but this is exactly the kind of dividend a conservative database choice keeps paying. And the flip side is worth stating too: none of these queries help you with data you encrypt client-side. Miraviso's sensitive notes — the ones the server &lt;a href="https://gabrielepieretti.dev/blog-en/sealed-envelopes-sensitive-data-saas/" rel="noopener noreferrer"&gt;stores as envelopes it cannot open&lt;/a&gt; — can never end up in a server-side vector index: you can't embed text you can't read. It's a useful reminder that semantic search is a data processing operation like any other, and deserves the same care in deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is heading
&lt;/h2&gt;

&lt;p&gt;The refactoring opens a wider door than the single feature: now that distance compilation lives in the grammar, adding a driver or a different metric is a small PR, not open-heart surgery on the builder. There's already a proposal to make the distance metric configurable (cosine, euclidean) instead of assumed. That direction looks right to me: a query builder that treats vectors as a driver capability, declared by the grammar, exactly like the rest of its SQL. Meanwhile, those of us on plain MySQL at least get an error message that tells the truth.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>ai</category>
      <category>database</category>
    </item>
    <item>
      <title>Thirty Days to Migrate: When Your SaaS Depends on a Model That's About to Disappear</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 08 Sep 2026 16:13:19 +0000</pubDate>
      <link>https://dev.to/gabbrowick/thirty-days-to-migrate-when-your-saas-depends-on-a-model-thats-about-to-disappear-703</link>
      <guid>https://dev.to/gabbrowick/thirty-days-to-migrate-when-your-saas-depends-on-a-model-thats-about-to-disappear-703</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gabrielepieretti.dev/blog-en/ai-model-deprecations-saas/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On August 27, Google moved Gemini Omni 1.1 Flash, its video generation model, to general availability under the stable ID &lt;code&gt;gemini-omni-1.1-flash&lt;/code&gt;. In the same move, the old &lt;code&gt;gemini-omni-flash-preview&lt;/code&gt; endpoint was scheduled for deprecation on September 30. Do the math: roughly thirty days to migrate, retest, and ship. I don't use that model — the haircut preview in &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt; works on images, not video — but the pattern is exactly the risk anyone lives with once a hosted model sits inside their product. And in my case there's an extra twist worth writing down: a privacy-first architecture takes away precisely the tool you'd want for managing the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: GA is good news with a deadline inside
&lt;/h2&gt;

&lt;p&gt;The sequence is familiar by now: a model ships as a preview, you integrate it because it's the only way to get that capability, the product grows to depend on it, and one day GA arrives — good news — bundled with a shutdown date for the preview, which is a deadline on your calendar set by somebody else. The Omni transition has all the typical details, too: a separate channel ID on Vertex AI (&lt;code&gt;gemini-omni-1.1-flash-preview&lt;/code&gt;, which is &lt;em&gt;not&lt;/em&gt; the stable Gemini API ID despite the nearly identical name), new controls the preview never had, and — according to third-party reports I've read, worth verifying against the official pricing pages — no clearly published GA price at announcement time. Migrating is not swapping a string: it's retesting quality, latency, and cost with &lt;em&gt;your&lt;/em&gt; inputs, because the release notes won't do that for you.&lt;/p&gt;

&lt;p&gt;Thirty days is tight if you learn about the migration from the deprecation email. It's comfortable if, the day the email lands, all you have to do is run a procedure that already exists. That's the entire difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  My case: half the product is immune, half is not
&lt;/h2&gt;

&lt;p&gt;Miraviso has two deliberately different technical paths, and this story lights both of them up. The color try-on runs entirely on the salon's tablet with on-device MediaPipe: the video never leaves the device. That path is &lt;em&gt;structurally immune&lt;/em&gt; to endpoint deprecations — the model ships with the app, and nobody can switch it off remotely. It's an under-discussed benefit of going on-device: not just privacy and latency, but independence from someone else's release calendar.&lt;/p&gt;

&lt;p&gt;The haircut preview, on the other hand, is generated server-side in the EU, with Gemini on Vertex AI, after the client's consent. There the dependency is real, and it's the price of a capability you can't get on-device today. That path lives on Google's calendar, and has to be designed with that in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: you can't regression-test data you don't keep
&lt;/h2&gt;

&lt;p&gt;The standard way to handle a model swap is a regression corpus: sample real production inputs, replay them against the new model, compare outputs. Well — I can't. By design, not by accident: preview images are never written to disk. They pass through, get processed, return to the tablet, gone. It's one of the promises the product stands on, and it doesn't become negotiable just because it would be convenient.&lt;/p&gt;

&lt;p&gt;The consequence is that the test corpus has to be built &lt;em&gt;outside&lt;/em&gt; production traffic, and in advance. Concretely, that means three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An explicit, consented golden set&lt;/strong&gt;: images collected specifically for testing — your own, volunteers who signed up for that exact purpose, or datasets with a suitable license — covering the cases that actually matter: hair types, skin tones, real salon lighting, crooked framing. It's not a poor substitute for production data: it's the only corpus you have, so its quality is the ceiling on the quality of your tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model behind a hard boundary&lt;/strong&gt;: one place in the code that knows which model is called and how. If the endpoint name shows up in multiple files, scattered environment variables, or stored job templates, then step one of any migration — finding every occurrence — is already an afternoon-long project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluation criteria that don't require the originals&lt;/strong&gt;: if you compare old vs. new by eye on the golden set, fine — but the criteria for what makes a preview "acceptable" must be written down beforehand, because aesthetic judgment under mid-migration pressure is the worst judgment you will ever exercise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The deadline is the product working as intended
&lt;/h2&gt;

&lt;p&gt;There's a lazy reading of news like this: "big tech keeps killing your APIs, what a disaster." I don't share it. A vendor that moves a model to GA and retires the preview is doing serious catalog maintenance; the alternative — eternal previews nobody promises to maintain — is worse. The deprecation isn't the problem. The problem is arriving at it with no golden set, no boundary around the model, and the endpoint ID copy-pasted into four places.&lt;/p&gt;

&lt;p&gt;The rule I hold myself to is trivial to state: every capability bought from a hosted model must have, from day one, an answer to the question "how do I replace this?". Not a detailed plan — an answer. If the answer is "I don't know", that's not a dependency, it's a lien on the product. And much like &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-sensitive-data-in-logs/" rel="noopener noreferrer"&gt;the data that ends up in your logs without anyone deciding it should&lt;/a&gt;, the right time to think about it is while the architecture is still fresh — not when the email with the date inside arrives.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>privacy</category>
      <category>googlecloud</category>
    </item>
  </channel>
</rss>
