<?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>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>
    <item>
      <title>Sensitive Data in Your Logs: What Laravel 13.27's Binding Masking Fixes (and What It Doesn't)</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:22:46 +0000</pubDate>
      <link>https://dev.to/gabbrowick/sensitive-data-in-your-logs-what-laravel-1327s-binding-masking-fixes-and-what-it-doesnt-5fbb</link>
      <guid>https://dev.to/gabbrowick/sensitive-data-in-your-logs-what-laravel-1327s-binding-masking-fixes-and-what-it-doesnt-5fbb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gabrielepieretti.dev/blog-en/laravel-sensitive-data-in-logs/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a query fails, Laravel builds a &lt;code&gt;QueryException&lt;/code&gt; whose message contains the SQL with every bound value interpolated into it. That's great while you're debugging. In production it means every failed insert copies customer data — an email, a name, maybe a national ID — into your logs, into the &lt;code&gt;failed_jobs&lt;/code&gt; table, and into your APM traces. Laravel 13.27, released on August 26, finally ships a switch to turn that interpolation off. It's worth being precise about what it closes, and what it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: exceptions get written down
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;QueryException&lt;/code&gt; message is designed to be read by a developer mid-debug: &lt;code&gt;insert into users (email) values (?)&lt;/code&gt; tells you nothing about which row broke, the version with the value tells you immediately. The trouble is that the message doesn't stay on that developer's screen. It's a string attached to an exception, and exceptions get recorded everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in the log file or channel you configured in &lt;code&gt;logging.php&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;in the &lt;code&gt;exception&lt;/code&gt; column of &lt;code&gt;failed_jobs&lt;/code&gt;, because the failed-job provider casts the exception to a string and inserts it as-is;&lt;/li&gt;
&lt;li&gt;in the spans of your APM or OpenTelemetry agent, if you run one;&lt;/li&gt;
&lt;li&gt;in whatever error-tracking service your exception reporting ships data to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those places becomes an undeclared copy of the data you were writing to the database — with different retention, different access rules, and almost never accounted for when someone fills in a data-processing register. Across eleven years of building Laravel back-office systems, I've watched application logs get read by far more people than ever had database access. That's normal; logs exist to be read. The question is what ends up in them without anyone having decided it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.27 does
&lt;/h2&gt;

&lt;p&gt;Version 13.27 introduces the per-connection option &lt;code&gt;mask_bindings_in_exception_messages&lt;/code&gt;. The key ships in the framework's own &lt;code&gt;config/database.php&lt;/code&gt; on all five default connections, so even an application that never published that file can enable it with a single environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_MASK_BINDINGS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the flag on, the exception message keeps its placeholders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23000&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;Integrity&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1062&lt;/span&gt;
&lt;span class="n"&gt;Duplicate&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="s1"&gt;'ada@example.com'&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="s1"&gt;'users_email_unique'&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="nv"&gt;`users`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`email`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`name`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`national_id`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the detail: the duplicate value still appears in the first line, because that part of the message comes from the database driver, not from Laravel. The flag masks the framework's interpolation — it doesn't rewrite what MySQL says. That's still a lot: the full insert with three columns of personal data is gone. But it's not a complete scrub of the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'd turn it on almost everywhere
&lt;/h2&gt;

&lt;p&gt;The cost of the flag is that when an exception fires in production you no longer see the values at a glance: you have to recover the bindings some other way — logging them separately to a channel with short retention and tight access, or reproducing the case in development. That's real friction. But the right question isn't "does this make debugging harder?", it's "who reads my logs, and how long do they stick around?". If the answer includes a third-party service, an APM, or months of retention, then binding interpolation is a category of personal-data processing you never declared to anyone — including yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model is bigger than one flag
&lt;/h2&gt;

&lt;p&gt;This release matters to me because it touches a principle I built &lt;a href="https://miraviso.it" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt; around — it's my SaaS for hair salons: &lt;strong&gt;sensitive data has to be protected not just where you think you store it, but in every place your infrastructure re-copies it without asking&lt;/strong&gt;. Logs, queues, traces, log backups, snapshots: every component that serializes state to help you debug is a potential shadow archive.&lt;/p&gt;

&lt;p&gt;In Miraviso, the notes a salon keeps about its clients can contain allergies and scalp conditions, and there I took the conclusion all the way: those notes are encrypted with a key derived on the device, and the server stores envelopes it cannot open. I wrote about that design &lt;a href="https://gabrielepieretti.dev/blog-en/sealed-envelopes-sensitive-data-saas/" rel="noopener noreferrer"&gt;in a dedicated post&lt;/a&gt;. The property that's relevant here is that the envelope design makes the logging problem &lt;em&gt;structurally&lt;/em&gt; impossible for those fields: if the plaintext never exists server-side, no exception, however verbose, can interpolate it into a message. There's no flag to remember to enable, no misconfigured APM agent that can betray you.&lt;/p&gt;

&lt;p&gt;I'm not arguing every field deserves that treatment — the same segmentation I apply there applies here. Most data can live in plaintext on the server, and for that data Laravel 13.27's flag is the right move: cheap, immediate, reversible. A small set of fields is delicate enough that leaking them should be impossible rather than unlikely. The test for telling them apart never changes: does the server have a legitimate need to read this value? If not, make sure it can't.&lt;/p&gt;

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

&lt;p&gt;If you run a Laravel application in production that handles personal data, the list is short. Upgrade to 13.27 and set &lt;code&gt;DB_MASK_BINDINGS&lt;/code&gt; at least in production. Then open &lt;code&gt;failed_jobs&lt;/code&gt; and look at the &lt;code&gt;exception&lt;/code&gt; column of your old records: the flag protects the future, it doesn't clean up the past, and those rows have whatever retention you gave them — often forever. Finally, ask which other components of your stack cast exceptions to strings and keep them around. It's a less entertaining exercise than &lt;a href="https://gabrielepieretti.dev/games/nonogram/" rel="noopener noreferrer"&gt;writing nonogram solvers&lt;/a&gt;, but it makes your next audit a great deal shorter.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>privacy</category>
      <category>php</category>
    </item>
    <item>
      <title>Vue's Vapor Mode Is Almost Here. Writing Browser Games With No Framework Taught Me Why It Matters</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:28:49 +0000</pubDate>
      <link>https://dev.to/gabbrowick/vues-vapor-mode-is-almost-here-writing-browser-games-with-no-framework-taught-me-why-it-matters-5gak</link>
      <guid>https://dev.to/gabbrowick/vues-vapor-mode-is-almost-here-writing-browser-games-with-no-framework-taught-me-why-it-matters-5gak</guid>
      <description>&lt;p&gt;Vue 3.6 entered release-candidate stage in July, and with it Vapor Mode is finally feature-complete: single-file components that compile straight to direct DOM operations, skipping the virtual DOM entirely. A stable release is expected this autumn.&lt;/p&gt;

&lt;p&gt;I use Vue daily in client work, so I care about this for the usual reasons - smaller bundles, faster updates. But I have a second, stranger reason to care: for the past while I've been hand-writing logic games for my personal site - a nonogram, a minesweeper variant, numberlink, mastermind, a 3D Rubik's cube with a built-in solver, a speedcubing timer. Each game is one page, no framework, no heavy libraries. Just state and direct DOM manipulation. And that experience is basically a preview of what Vapor Mode does - except I've been the compiler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you learn when there's no virtual DOM to hide behind&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Take the nonogram. The board is a grid of cells; a click toggles one cell and possibly updates a couple of row/column clue indicators. In a naive framework mental model, the "view" is a function of the whole board state, and something diffs its way to the minimal change. Writing it by hand, you can't afford that mental model. You think in deltas: this click changes this cell's class and these two clue elements, nothing else. You write exactly those three DOM operations and you're done.&lt;/p&gt;

&lt;p&gt;The minesweeper was the same lesson at higher stakes. Mine is a no-guess variant - every generated grid is verified by a solver so you're never forced into a 50/50. When a flood-fill reveals eighty cells at once, you feel every unnecessary DOM touch. So you batch: compute the full set of cells to reveal in pure JS, then apply the class changes in one pass. State first, DOM second, and only the DOM that actually changed.&lt;/p&gt;

&lt;p&gt;The 3D Rubik's cube pushed this furthest. Animating layer turns and running solvers (layer-by-layer, CFOP, optimal) means the interesting work is entirely in the state model - the DOM/render layer is a thin projection of it. Once you structure things that way, "rendering" stops being the hard part of the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this says about Vapor Mode&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three takeaways from doing manually what Vapor now does at compile time:&lt;/p&gt;

&lt;p&gt;First, the virtual DOM was never primarily a performance feature. It's a developer experience feature: it lets you write &lt;code&gt;view = f(state)&lt;/code&gt; and not think about deltas. The diffing is the price you pay for that mental model, not a speed trick. Vapor's bet is that a compiler can keep the declarative authoring model and emit the delta-based code I was writing by hand - the best of both.&lt;/p&gt;

&lt;p&gt;Second, the discipline that no-framework code forces on you - keep state pure, treat the DOM as a projection - is exactly the discipline that makes components fast in any framework. If your component's state is tangled with its DOM, no rendering strategy saves you.&lt;/p&gt;

&lt;p&gt;Third, there's a real ceiling on how much this matters. My games are the favorable case for direct DOM updates: high-frequency, localized mutations on large grids. A typical CRUD form re-renders so rarely that VDOM overhead is noise. If your Vue app is mostly forms and tables, Vapor will shrink your bundle more than it speeds up your interactions - still worth it, but know which benefit you're buying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would I still hand-write the games after Vapor lands?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes - but for a different reason than performance. Keeping each game dependency-free means the pages will still work untouched in ten years, and writing the update logic myself is half the fun. But for product work, Vapor is the right direction: it takes the rendering strategy that low-level code teaches you and makes it the default output of the declarative code you'd write anyway.&lt;/p&gt;

&lt;p&gt;One caveat: 3.6 is still RC as I write this. Feature-complete is not stable - I'm not shipping it to client projects until the stable release, expected this autumn.&lt;/p&gt;

&lt;p&gt;The games are all playable at &lt;a href="https://gabrielepieretti.dev/games/" rel="noopener noreferrer"&gt;gabrielepieretti.dev&lt;/a&gt; if you want to see the hand-rolled version of "vapor mode" in action.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The GPU that says yes and does nothing: debugging real-time hair segmentation on mid-range Android</title>
      <dc:creator>Gabriele Pieretti</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:14:55 +0000</pubDate>
      <link>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</link>
      <guid>https://dev.to/gabbrowick/the-gpu-that-says-yes-and-does-nothing-debugging-real-time-hair-segmentation-on-mid-range-android-2990</guid>
      <description>&lt;p&gt;We build &lt;a href="https://miraviso.it/prova-colore-live" rel="noopener noreferrer"&gt;a virtual mirror for hair salons&lt;/a&gt;: a tablet&lt;br&gt;
camera feed where the customer's hair changes colour in real time while they move their head.&lt;br&gt;
Two constraints shaped every decision.&lt;/p&gt;

&lt;p&gt;The first is privacy: no frame ever leaves the device. Everything runs locally — the model,&lt;br&gt;
the fonts, the runtime — with no CDN dependency, because a CDN request would both break&lt;br&gt;
offline use and leak the salon's IP to a third party.&lt;/p&gt;

&lt;p&gt;The second is hardware: salons don't buy flagship tablets. We had to work on whatever&lt;br&gt;
mid-range Android is on the counter.&lt;/p&gt;

&lt;p&gt;This is what we learned making it fast enough. All numbers were measured on real devices with&lt;br&gt;
diagnostics built into the engine — none of them are estimates.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that looked like slowness
&lt;/h2&gt;

&lt;p&gt;Our starting point was bad in a confusing way. A Pixel 8 Pro ran at 9 fps — poor, but working.&lt;br&gt;
A Samsung A56 (Exynos 1580, Mali GPU) showed &lt;em&gt;no colour at all&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The cause took a while to find, and it's the most useful thing in this article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe's &lt;code&gt;ImageSegmenter&lt;/code&gt; with the GPU delegate was created without throwing, ran in 5 ms,&lt;br&gt;
and returned zero hair pixels.&lt;/strong&gt; Both the confidence mask and the category mask came back&lt;br&gt;
empty. Every time.&lt;/p&gt;

&lt;p&gt;Our fallback to CPU only triggered on exceptions. There were no exceptions. So it never&lt;br&gt;
triggered, and the app simply looked slow instead of broken.&lt;/p&gt;

&lt;p&gt;A clean comparison, same scene minutes apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Delegate&lt;/th&gt;
&lt;th&gt;Mask coverage&lt;/th&gt;
&lt;th&gt;Time per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;1.5–2.1 %&lt;/td&gt;
&lt;td&gt;483 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the trap in that table. The GPU path is 96× faster precisely &lt;em&gt;because it isn't doing&lt;br&gt;
anything&lt;/em&gt;. If you benchmark by timing alone, the broken path wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson: don't assume that a delegate which constructs successfully actually works.&lt;/strong&gt;&lt;br&gt;
Check the output, not the exit code. We now measure mask coverage and surface it in the&lt;br&gt;
diagnostics.&lt;/p&gt;
&lt;h3&gt;
  
  
  Detecting it at runtime
&lt;/h3&gt;

&lt;p&gt;The fix is a fallback that rebuilds the engine on CPU. The rule matters more than it looks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After five consecutive empty masks, &lt;strong&gt;having never seen a good one in the whole session&lt;/strong&gt;,&lt;br&gt;
rebuild on CPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The condition is "never seen a mask", not "empty right now". An empty mask right now is&lt;br&gt;
completely normal — it happens whenever nobody is in front of the lens. We had to encode the&lt;br&gt;
difference between &lt;em&gt;broken&lt;/em&gt; and &lt;em&gt;nobody's there&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;On the A56 this took us from 2 fps to 14 fps with colour applied.&lt;/p&gt;
&lt;h3&gt;
  
  
  The field confirmed it, months later
&lt;/h3&gt;

&lt;p&gt;We shipped that fallback on the strength of a coverage number, which is a slightly&lt;br&gt;
uncomfortable place to be: we knew &lt;em&gt;that&lt;/em&gt; the delegate returned nothing, not &lt;em&gt;why&lt;/em&gt;. The answer&lt;br&gt;
arrived from a beta tester, as a photo of their phone screen. MediaPipe was failing a&lt;br&gt;
&lt;code&gt;RET_CHECK&lt;/code&gt; on every single &lt;code&gt;segmentForVideo&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Confidence mask count mismatch, 0 vs 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GPU graph was asking for two masks and receiving zero — at &lt;strong&gt;runtime&lt;/strong&gt;, on every frame,&lt;br&gt;
while construction succeeded cleanly. That is exactly why an exception-based fallback can&lt;br&gt;
never catch it, and it's why a plain "Retry" button wouldn't have helped either: retrying&lt;br&gt;
rebuilds the same broken graph.&lt;/p&gt;

&lt;p&gt;Two cheap safety nets came out of that, both costing nothing per frame. If nothing has been&lt;br&gt;
drawn two seconds after start, fall back from &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;requestAnimationFrame&lt;/code&gt; — on some WebViews the former exists but never fires. And do a&lt;br&gt;
&lt;strong&gt;one-pixel&lt;/strong&gt; &lt;code&gt;readPixels&lt;/code&gt;, &lt;strong&gt;once&lt;/strong&gt;, to find out whether the canvas is coming out black. We&lt;br&gt;
had shipped a version where the error line was written into an element that&lt;br&gt;
&lt;code&gt;display: none&lt;/code&gt; had already hidden, so a broken device showed a black mirror and said nothing&lt;br&gt;
at all. Errors you can't see are errors you can't fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  A diagnostic that costs more than what it measures
&lt;/h3&gt;

&lt;p&gt;While chasing this, we left &lt;code&gt;outputCategoryMask&lt;/code&gt; enabled from an earlier experiment. The&lt;br&gt;
diagnostic itself became a significant part of the frame budget. Worth remembering when your&lt;br&gt;
measurements start shaping the thing you're measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was never the model
&lt;/h2&gt;

&lt;p&gt;With the GPU path honest, we profiled the Pixel 8 Pro's 9 fps. The model inference cost 6 ms.&lt;br&gt;
The other 126 ms were ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;66 ms&lt;/strong&gt; in a JavaScript loop recolouring pixels one at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;two GPU→CPU readbacks&lt;/strong&gt; per frame, one of them costing 123 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole story of most "the ML model is too slow" complaints we've had since: the&lt;br&gt;
model was never the problem. The problem was everything we did around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1 — recolour in a fragment shader (66 ms → 0)
&lt;/h3&gt;

&lt;p&gt;The per-pixel JavaScript loop moved into a fragment shader. This is the obvious one, and it's&lt;br&gt;
free: the pixels are already on the GPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2 — decouple the mask from the draw
&lt;/h3&gt;

&lt;p&gt;Hair does not move at 60 Hz. We were segmenting every frame for no reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;segmentForVideo&lt;/code&gt; is &lt;strong&gt;synchronous&lt;/strong&gt;, and on an Exynos it costs 437 ms of blocked main thread.&lt;br&gt;
Now we segment one frame in every &lt;code&gt;PASSO_SEG&lt;/code&gt;, and draw the video on all of them using the&lt;br&gt;
most recent mask. The step adapts to the device.&lt;/p&gt;

&lt;p&gt;This is the change that fixed the A56, and it's worth stressing: &lt;strong&gt;we expected to need a Web&lt;br&gt;
Worker and we didn't.&lt;/strong&gt; Decoupling cadence was enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3 — share the WebGL context with MediaPipe (123 ms → 0)
&lt;/h3&gt;

&lt;p&gt;This was the big one. We were copying the mask out of MediaPipe and back into our own&lt;br&gt;
pipeline, round-tripping through the CPU.&lt;/p&gt;

&lt;p&gt;MediaPipe accepts a &lt;code&gt;canvas&lt;/code&gt; in its options. Pass it the same canvas you're drawing on, and&lt;br&gt;
you can take the mask with &lt;code&gt;getAsWebGLTexture()&lt;/code&gt; — it never leaves the GPU.&lt;/p&gt;

&lt;p&gt;One catch, learned the hard way: &lt;strong&gt;MediaPipe recycles that texture under your feet.&lt;/strong&gt; Binding&lt;br&gt;
it directly made the colour flicker. We copy it into our own texture with a shader, which is&lt;br&gt;
still enormously cheaper than a CPU round trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4 — drop &lt;code&gt;preserveDrawingBuffer&lt;/code&gt; (5 ms per frame)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;preserveDrawingBuffer: true&lt;/code&gt; costs real time on every single frame. We only needed it to&lt;br&gt;
capture a still of the current look, which is a rare user action.&lt;/p&gt;

&lt;p&gt;We defer the capture to the next draw call, within the same task, and read it there. Verified&lt;br&gt;
on device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe will not start inside a &lt;code&gt;type: "module"&lt;/code&gt; worker.&lt;/strong&gt; It dies with&lt;br&gt;
&lt;code&gt;ModuleFactory not set&lt;/code&gt;, because its WASM loader registers the factory via &lt;code&gt;importScripts()&lt;/code&gt;,&lt;br&gt;
which doesn't exist in module workers. If you want MediaPipe in a worker, you need a classic&lt;br&gt;
worker — or you don't get one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freeze the frame in a texture &lt;em&gt;before&lt;/em&gt; you segment.&lt;/strong&gt; If you segment first and draw after,&lt;br&gt;
the video has already advanced, and the hair lags behind the head permanently. Not&lt;br&gt;
occasionally. Always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the specialised model.&lt;/strong&gt; We were running &lt;code&gt;selfie_multiclass&lt;/code&gt;, which segments six classes&lt;br&gt;
when we needed one. Switching to the dedicated &lt;code&gt;hair_segmenter&lt;/code&gt;: 780 KB instead of 16 MB, and&lt;br&gt;
the official Pixel 6 figures are 58 ms vs 217 ms on CPU, 52 ms vs 71 ms on GPU. Our consumer&lt;br&gt;
APK dropped from 40.7 MB to 25.8 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;132 ms → 17 ms per frame. 9 fps → 15 fps&lt;/strong&gt; on the Pixel 8 Pro, and 2 → 14 on the A56.&lt;/p&gt;

&lt;p&gt;You'll notice 17 ms per frame should mean roughly 58 fps, and we get 15. That's honest and&lt;br&gt;
worth explaining: the remaining ceiling is no longer in our code. It's in how the WebView is&lt;br&gt;
composited inside Flutter, and we haven't opened that front yet.&lt;/p&gt;

&lt;p&gt;We also kept the old CPU 2D path as a fallback for devices without WebGL2. That's not&lt;br&gt;
nostalgia — &lt;strong&gt;a canvas has exactly one context&lt;/strong&gt;. If you request WebGL and it fails, you can't&lt;br&gt;
fall back to 2D on the same canvas. You have to decide before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Four of the five wins here came from removing work we had added ourselves, not from making the&lt;br&gt;
model faster. And the single largest discovery — the silently empty GPU delegate — wasn't a&lt;br&gt;
performance problem at all. It was a correctness bug wearing a performance costume, and it was&lt;br&gt;
invisible until we measured the &lt;em&gt;output&lt;/em&gt; instead of the &lt;em&gt;duration&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building anything similar: instrument mask coverage, not just milliseconds. The&lt;br&gt;
frame that takes 5 ms might be the one doing nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The engine described here ships in &lt;a href="https://miraviso.it/" rel="noopener noreferrer"&gt;Miraviso&lt;/a&gt;, a consultation tool for hair salons: the customer sees the colour and the haircut before the stylist starts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you'd rather look at it than read about it, the same engine is in a free consumer app — &lt;a href="https://miraviso.it/app-colore" rel="noopener noreferrer"&gt;Miraviso Colore&lt;/a&gt; on Google Play. No account, no sign-up, and the camera pipeline runs entirely on the device, which you can check by putting the phone in airplane mode: the mirror keeps working. Tap the "Miraviso" header inside the colour screen and the status line turns into the live diagnostics described above — frame budget broken down by stage, the delegate in use, and the mask coverage number that this whole article is about. Each tap cycles: off → automatic → force GPU → force CPU. If you want to see the empty-mask failure for yourself, force GPU on a mid-range device and watch &lt;code&gt;conf&lt;/code&gt; sit at 0.0 %.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Happy to answer questions about the WebGL side in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>android</category>
    </item>
  </channel>
</rss>
