<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/vendor/feed/atom.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
                        <id>https://freek.dev/feed</id>
                                <link href="https://freek.dev/feed" rel="self"></link>
                                <title><![CDATA[freek.dev - all blogposts]]></title>
                    
                                <subtitle>All blogposts on freek.dev</subtitle>
                                                    <updated>2026-04-24T14:30:28+02:00</updated>
                        <entry>
            <title><![CDATA[Enhancing our API for better agentic consumption]]></title>
            <link rel="alternate" href="https://freek.dev/3062-enhancing-our-api-for-better-agentic-consumption" />
            <id>https://freek.dev/3062</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We've shipped several improvements to the Oh Dear API to make it work better with AI coding agents. The updates include historical check runs, dashboard links in every response, and markdown-friendly documentation.</p>


<a href='https://ohdear.app/news-and-updates/enhancing-our-api-for-better-agentic-consumption'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-24T14:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Generate Apple and Google Wallet passes from Laravel]]></title>
            <link rel="alternate" href="https://freek.dev/3108-generate-apple-and-google-wallet-passes-from-laravel" />
            <id>https://freek.dev/3108</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A mobile pass is that thing in your iPhone's Wallet app. A boarding pass, a concert ticket, a coffee loyalty card, a gym membership. Apple calls them passes. Google calls them objects. Both Wallet apps let you generate them, hand them out, and push live updates to the copy that's already on someone's device.</p>
<p>We just released <a href="https://github.com/spatie/laravel-mobile-pass">Laravel Mobile Pass</a>, a package that lets you generate those Apple and Google passes from a Laravel app and send updates to already issues passes.</p>
<p>Together with the package, we also published <a href="https://mobile-pass-demo.spatie.be">a demo site</a> where you can create Apple Wallet passes and push an update so you can see it all working on your own iOS device.</p>
<p><img src="https://freek.dev/admin-uploads/igrNGyZs64cOAtJMrdGV6zN0M6lxzFo2Gl1vJHB6.jpg" alt="" /></p>
<p><a href="https://github.com/danjohnson95">Dan Johnson</a> and I have been working on it for a while. Let me walk you through what it can do.</p>
<!--more-->
<h2 id="a-simple-example">A simple example</h2>
<p>Here's the shortest useful thing you can build with it. An Apple Wallet boarding pass for a flight:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\LaravelMobilePass\Builders\Apple\AirlinePassBuilder</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\LaravelMobilePass\Enums\BarcodeType</span>;

<span class="hl-variable">$mobilePass</span> = <span class="hl-type">AirlinePassBuilder</span>::<span class="hl-property">make</span>()
    -&gt;<span class="hl-property">setOrganizationName</span>(<span class="hl-value">'Artisan Airways'</span>)
    -&gt;<span class="hl-property">setSerialNumber</span>(<span class="hl-value">'ART-103'</span>)
    -&gt;<span class="hl-property">setDescription</span>(<span class="hl-value">'Boarding Pass'</span>)
    -&gt;<span class="hl-property">addHeaderField</span>(<span class="hl-value">'flight-no'</span>, <span class="hl-value">'ART103'</span>, <span class="hl-property">label</span>: <span class="hl-value">'Flight'</span>)
    -&gt;<span class="hl-property">addHeaderField</span>(<span class="hl-value">'seat'</span>, <span class="hl-value">'66F'</span>)
    -&gt;<span class="hl-property">addField</span>(<span class="hl-value">'departure'</span>, <span class="hl-value">'ABU'</span>, <span class="hl-property">label</span>: <span class="hl-value">'Abu Dhabi International'</span>)
    -&gt;<span class="hl-property">addField</span>(<span class="hl-value">'destination'</span>, <span class="hl-value">'LHR'</span>, <span class="hl-property">label</span>: <span class="hl-value">'London Heathrow'</span>)
    -&gt;<span class="hl-property">addSecondaryField</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'Mary Smith'</span>)
    -&gt;<span class="hl-property">setBarcode</span>(<span class="hl-type">BarcodeType</span>::<span class="hl-property">Pdf417</span>, <span class="hl-value">'ART-103-66F'</span>)
    -&gt;<span class="hl-property">save</span>();
</pre>
<p>Calling <code>save()</code> gives you back a <code>MobilePass</code> model. Nothing is written to disk. The whole pass (fields, images, barcode, the lot) lives as a row in the <code>mobile_passes</code> table that ships with the package.</p>
<h2 id="handing-the-pass-to-a-user">Handing the pass to a user</h2>
<p>So in the example above, the <code>$mobilePass</code> variable contains <code>MobilePass</code> Eloquent model. It plays nice with the parts of Laravel you already know. Here are a few ways you can use it to send a mobile pass to a user</p>
<p>Return it straight from a controller. The model implements <code>Responsable</code>, so the package takes care of signing and serving the <code>.pkpass</code> file.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// in a controller</span>
<span class="hl-keyword">return</span> <span class="hl-variable">$mobilePass</span>;
</pre>
<p>Attach it to an outgoing mail. The model also implements <code>Attachable</code>, which means you can drop it into a Mailable's <code>attachments()</code> method and Laravel figures out the rest.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">FlightBooked</span> <span class="hl-keyword">extends</span> <span class="hl-type">Mailable</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection"><span class="hl-keyword">public</span> <span class="hl-type">MobilePass</span> <span class="hl-property">$mobilePass</span></span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">attachments</span>(): <span class="hl-type">array</span>
    {
        <span class="hl-keyword">return</span> [<span class="hl-variable">$this</span>-&gt;<span class="hl-property">mobilePass</span>];
    }
}
</pre>
<p>The user taps through, sees the pass preview in Apple Wallet, and taps Add.</p>
<p><img src="https://freek.dev/admin-uploads/Ohr1KpMoDzxonwUDTHQJ0XHrEf9rrxm7b6O4mjYu.png" alt="" /></p>
<p>Apple then calls back to your app to register the device against the pass. The package handles that endpoint and stores the registration. That link between pass and device is what lets you push updates later.</p>
<h2 id="pushing-a-live-update">Pushing a live update</h2>
<p>Here's the part I like most. If a seat gets reassigned or a gate changes, you can update the pass from your app and the version on the user's phone updates within a minute, without them doing anything.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$mobilePass</span>-&gt;<span class="hl-property">updateField</span>(<span class="hl-value">'seat'</span>, <span class="hl-value">'13A'</span>);
</pre>
<p>Under the hood, the package dispatches a job that notifies Apple through APNs. Apple pings the device. The device fetches the new version from your server. Wallet swaps the old pass for the new one.</p>
<p>Now this all happens silently, without warning the user.  If you want the user to see a notification when the value changes, pass a <code>changeMessage</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$mobilePass</span>-&gt;<span class="hl-property">updateField</span>(
    <span class="hl-value">'seat'</span>,
    <span class="hl-value">'13A'</span>,
    <span class="hl-property">changeMessage</span>: <span class="hl-value">'Your seat was changed to :value'</span>,
);
</pre>
<p><img src="https://freek.dev/admin-uploads/NX0iBKbdWswZbKHeQkc5dZPTUyc2xe4jJEO2xxkB.jpg" alt="" /></p>
<p>Very cool! If you tap that notification, you,'ll notice that Passbook highlights what was changed.</p>
<p><img src="https://freek.dev/admin-uploads/xJWfuSJ1qgBA7EkGPCfTdAqzMgdjLN4lwpPhYYra.jpg" alt="" /></p>
<h2 id="google-wallet-is-supported-too">Google Wallet is supported too</h2>
<p>Google Wallet works a little differently. Google wants you to declare a Class once (a shared template for a batch of passes) and then issue individual Objects against that class. The package covers both.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\LaravelMobilePass\Builders\Google\EventTicketPassBuilder</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\LaravelMobilePass\Enums\BarcodeType</span>;

<span class="hl-variable">$mobilePass</span> = <span class="hl-type">EventTicketPassBuilder</span>::<span class="hl-property">make</span>()
    -&gt;<span class="hl-property">setClass</span>(<span class="hl-value">'spring-tour-2026'</span>)
    -&gt;<span class="hl-property">setAttendeeName</span>(<span class="hl-value">'Mary Smith'</span>)
    -&gt;<span class="hl-property">setSection</span>(<span class="hl-value">'Floor A'</span>)
    -&gt;<span class="hl-property">setRow</span>(<span class="hl-value">'12'</span>)
    -&gt;<span class="hl-property">setSeat</span>(<span class="hl-value">'24'</span>)
    -&gt;<span class="hl-property">setBarcode</span>(<span class="hl-type">BarcodeType</span>::<span class="hl-property">Qr</span>, <span class="hl-value">'TICKET-12345'</span>)
    -&gt;<span class="hl-property">save</span>();
</pre>
<p>Returning <code>$mobilePass</code> from a controller does the right thing on either platform. Android users get redirected to the Google Wallet save URL. iPhone users get the <code>.pkpass</code> download. The <code>Responsable</code> implementation picks the right response for the platform the pass was built for.</p>
<h2 id="a-few-more-things-worth-knowing">A few more things worth knowing</h2>
<p>Picture the boarding pass from earlier. You hand it to the user three days before the flight. They install it and promptly forget it exists. An hour before departure, or the moment they walk into the airport, the pass drifts onto their lock screen without them doing anything. By the time they reach the gate, their thumb is already on it.</p>
<p>That trick is called pass relevance, and wiring it up is two calls on the builder:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$builder</span>
    -&gt;<span class="hl-property">addLocation</span>(<span class="hl-property">latitude</span>: 25.2528, <span class="hl-property">longitude</span>: 55.3644)
    -&gt;<span class="hl-property">setRelevantDate</span>(<span class="hl-variable">$flight</span>-&gt;<span class="hl-property">departs_at</span>);
</pre>
<p>The location turns on the geofence. The date turns on the clock. Combine both and Wallet shows the pass at the right place and the right moment. It's the small thing that makes a Wallet pass feel like it belongs on the device instead of inside an email.</p>
<p>Passes don't need to stick around once they've served their purpose. When a coupon has been redeemed or a ticket is from last night, call <code>expire()</code> on the model and Wallet greys it out and drops it from the lock screen:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$mobilePass</span>-&gt;<span class="hl-property">expire</span>();
</pre>
<p>If you issue Google Wallet passes, you probably want to know when someone actually saves one to their Wallet, or later removes it. Google pings your app on both events. The package listens for those callbacks and fires regular Laravel events you can subscribe to:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Event</span>::<span class="hl-property">listen</span>(<span class="hl-type">GoogleMobilePassSaved</span>::<span class="hl-keyword">class</span>, <span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">GoogleMobilePassSaved</span> $event</span>) {
    <span class="hl-comment">// $event-&gt;mobilePass is now on the user's Wallet</span>
});
</pre>
<p>Drop in a listener if you want to record activations, send a thank-you email, or kick off any other flow. It's the kind of integration that would otherwise involve reading Google's callback spec and signing JWTs by hand. You shouldn't have to.</p>
<h2 id="try-it-live">Try it live</h2>
<p>We put up a demo at <a href="https://mobile-pass-demo.spatie.be">mobile-pass-demo.spatie.be</a>. Pick a pass type on the landing page, scan the QR code with your iPhone, install the pass, and hit the simulate-change button. Your Wallet pulls the new version a moment later. The full source of the demo app is on <a href="https://github.com/spatie/laravel-mobile-pass-demo">GitHub</a> if you want to see how everything ties together.</p>
<p><img src="https://freek.dev/admin-uploads/igrNGyZs64cOAtJMrdGV6zN0M6lxzFo2Gl1vJHB6.jpg" alt="" /></p>
<h2 id="in-closing">In closing</h2>
<p>At Laracon India 2025, <a href="https://github.com/danjohnson95">Dan Johnson</a> showed me a prototype he had been hacking on for generating Apple Wallet passes. I loved the idea, we decided to team up, and this package is what came out the other end. It took a while to get here, but we finally gave it the polish we wanted.</p>
<p>Mobile passes aren't new. Apple shipped Wallet (back then called Passbook) with iOS 6 in 2012, and Google followed with their own Wallet passes a few years later.</p>
<p>Given the two have been around for that long, I was surprised nobody had built a solid Laravel package around either platform yet. Luckily there's one now. Hope you like it!</p>
<p>The code is on <a href="https://github.com/spatie/laravel-mobile-pass">GitHub</a>. The full documentation is at <a href="https://spatie.be/docs/laravel-mobile-pass">spatie.be</a>.</p>
<p>This is one of the many packages we've created at Spatie. If you want to support our open source work, consider picking up one of our <a href="https://spatie.be/open-source/support-us">paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-04-23T16:00:19+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[In Praise of --dry-run]]></title>
            <link rel="alternate" href="https://freek.dev/3077-in-praise-of-dry-run" />
            <id>https://freek.dev/3077</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Henrik Warne makes a good case for adding a --dry-run mode to commands that change state. It gives you a fast, safe way to verify configuration, inspect behavior, and test workflows without side effects.</p>


<a href='https://henrikwarne.com/2026/01/31/in-praise-of-dry-run/'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-23T14:30:27+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How Will LLMs Transform Us? AI as a Tool in the Future of Development]]></title>
            <link rel="alternate" href="https://freek.dev/3076-how-will-llms-transform-us-ai-as-a-tool-in-the-future-of-development" />
            <id>https://freek.dev/3076</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>This article frames AI as a tool to support, not replace, developers, emphasizing the importance of staying in control of how and when it’s used. It encourages a thoughtful approach where developers leverage AI for efficiency while maintaining ownership of decisions and outcomes.</p>


<a href='https://tighten.com/insights/pragmatic-ai-ai-as-a-tool/'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-22T14:04:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What Paddle doesn't tell you about implementing metered billing]]></title>
            <link rel="alternate" href="https://freek.dev/3075-what-paddle-doesnt-tell-you-about-implementing-metered-billing" />
            <id>https://freek.dev/3075</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>How to implement calendar-month metered billing on Paddle using zero-value subscriptions, one-time charges, and a homemade invoice grace period.</p>


<a href='https://phare.io/blog/what-paddle-doesnt-tell-you-about-implementing-metered-billing/'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-21T14:11:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing TypeScript Transformer 3]]></title>
            <link rel="alternate" href="https://freek.dev/3094-introducing-typescript-transformer-3" />
            <id>https://freek.dev/3094</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Ruben explains the full rewrite behind TypeScript Transformer 3 and why the new architecture makes the package much more flexible. The post covers the new transformer pipeline, AST-based output, improved type parsing via PHPStan, and a watch mode for faster development.</p>


<a href='https://rubenvanassche.com/p/c07a798b-01fe-41d3-a5c5-08b24789f4ac/'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-20T14:30:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Talking about Laravel, Oh Dear, and AI]]></title>
            <link rel="alternate" href="https://freek.dev/3073-talking-about-laravel-oh-dear-and-ai" />
            <id>https://freek.dev/3073</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>In this interview, I talk about Laravel, application monitoring, and how AI is changing the way developers work.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/kNmnNoz7AWU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
]]>
            </summary>
                                    <updated>2026-04-19T14:30:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Scotty vs Laravel Envoy: Spatie's New Deploy Tool Is Worth the Switch]]></title>
            <link rel="alternate" href="https://freek.dev/3072-scotty-vs-laravel-envoy-spaties-new-deploy-tool-is-worth-the-switch" />
            <id>https://freek.dev/3072</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Hafiz compares Scotty with Laravel Envoy and explains why Spatie's new deploy tool is a nicer fit for SSH-based deployments. He walks through the plain bash format, improved terminal output, migration path, and zero-downtime deployment workflow.</p>


<a href='https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-18T14:30:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Spatie Guidelines as AI Skills]]></title>
            <link rel="alternate" href="https://freek.dev/3098-spatie-guidelines-as-ai-skills" />
            <id>https://freek.dev/3098</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We turned our internal coding guidelines into reusable AI skills, so coding assistants can follow the same conventions their team uses. The package works with Laravel Boost and the broader skills.sh ecosystem, and ships with skills for Laravel PHP, JavaScript, version control, and security.</p>


<a href='https://spatie.be/blog/spatie-guidelines-as-ai-skills'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-17T14:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Validating Array Inputs in Laravel Without the N+1]]></title>
            <link rel="alternate" href="https://freek.dev/3070-validating-array-inputs-in-laravel-without-the-n1" />
            <id>https://freek.dev/3070</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Validate nested array inputs in Laravel form requests without the N+1. Prefetch lookup data in prepareForValidation and check items in memory.</p>


<a href='https://daryllegion.com/validating-array-inputs-in-laravel-without-the-n-plus-1'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-16T14:22:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How 10x Developers Actually Use AI]]></title>
            <link rel="alternate" href="https://freek.dev/3067-how-10x-developers-actually-use-ai" />
            <id>https://freek.dev/3067</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[

<a href='https://youtu.be/_78-xwTyQeE?si=6SvmgiF7-HHig0gs'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-15T14:03:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The Great CSS Expansion]]></title>
            <link rel="alternate" href="https://freek.dev/3068-the-great-css-expansion" />
            <id>https://freek.dev/3068</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A comprehensive look at how new CSS features are replacing JavaScript libraries. Anchor positioning, the Popover API, scroll-driven animations, view transitions, customizable selects, and more. The article estimates around 322 kB of JavaScript that can potentially be replaced by native CSS.</p>


<a href='https://blog.gitbutler.com/the-great-css-expansion'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-15T12:30:29+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why use static closures?]]></title>
            <link rel="alternate" href="https://freek.dev/3066-why-use-static-closures" />
            <id>https://freek.dev/3066</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A clear walkthrough of how PHP closures implicitly capture $this, even when they don't use it, and how that can prevent objects from being garbage collected. Also covers what PHP 8.6 will change with automatic static inference.</p>


<a href='https://f2r.github.io/en/static-closures'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-14T12:30:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Instant view switches with Inertia v3 prefetching]]></title>
            <link rel="alternate" href="https://freek.dev/3087-instant-view-switches-with-inertia-v3-prefetching" />
            <id>https://freek.dev/3087</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Over the past few months we've been building <a href="https://there-there.app">There There</a> at Spatie, a support tool shaped by the two decades we've spent running our own customer support. The goal is simple: the helpdesk we always wished we had.</p>
<p>We care about using AI in a particular way. It should help support agents write better replies, not substitute for them. The human stays in charge of the conversation, and the model does the unglamorous work of drafting, rephrasing, and suggesting links. There There is in private beta right now, and you can apply for early access at <a href="https://there-there.app">there-there.app</a>.</p>
<p>We're building There There with Laravel and Inertia, and we lean heavily on the latest features Inertia v3 brings. This post is about another one: prefetching on hover.</p>
<!--more-->
<h2 id="using-prefetch-on-hover">Using prefetch on hover</h2>
<p>In a helpdesk, a support agent flips between views constantly. My open tickets, unassigned, waiting, spam, team views, custom filters. Every switch triggers a fresh page load against a different filter. If each switch blocks on a round-trip, the sidebar starts to feel like dead weight.</p>
<p><a href="https://inertiajs.com/docs/v3/data-props/prefetching">Inertia v3 has a built-in prefetching API</a> that covers this neatly. Instead of waiting for the click, we fetch the view's data as soon as the cursor hovers over the link, and keep the result in a short-lived cache so the actual navigation feels instant.</p>
<p>Here's the core of our sidebar view link.</p>
<pre data-lang="js" class="notranslate">&lt;Link
    href={<span class="hl-property">buildViewUrl</span>(view)}
    prefetch=<span class="hl-value">&quot;hover&quot;</span>
    cacheFor=<span class="hl-value">&quot;30s&quot;</span>
    preserveState={isOnTickets}
    preserveScroll={isOnTickets}
&gt;
    &lt;Icon className=<span class="hl-value">&quot;size-4&quot;</span> /&gt;
    &lt;span&gt;{view.<span class="hl-property">label</span>}&lt;/span&gt;
&lt;/Link&gt;
</pre>
<p><code>prefetch=&quot;hover&quot;</code> is the whole mechanism. After the cursor sits on the link for 75ms, Inertia fires the underlying request in the background. When the agent actually clicks, the response is already in memory, and the page swaps without a network wait.</p>
<p><code>cacheFor</code> is where the real tuning happens. The default is 30 seconds. For our ticket views, that's a sweet spot: long enough that flipping back and forth feels free, short enough that the counts don't go visibly stale. You can push it higher for more static lists, or drop it to <code>&quot;0&quot;</code> for views where freshness matters more than speed.</p>
<p>Here it is in action. The cursor hovers, Inertia warms the cache in the background, and the click lands on data that's already there.</p>
<p><video src="https://freek.dev/admin-uploads/prefetch.mp4" autoplay loop muted playsinline style="max-width: 125%; margin-left: -12.5%;"></video></p>
<p>One small gotcha is worth flagging. When an agent navigates away from a view, you often want to cancel any in-flight requests so you don't paint stale data. The obvious call is <code>router.cancelAll()</code>, but that also cancels prefetches that are happily warming the cache for views the agent is about to visit.</p>
<p>Inertia v3 lets you scope that. Here's how we cancel the active visit without touching prefetches.</p>
<pre data-lang="js" class="notranslate">router.<span class="hl-property">cancelAll</span>({ <span class="hl-keyword">async</span>: <span class="hl-keyword">false</span>, <span class="hl-property">prefetch</span>: <span class="hl-keyword">false</span> });
</pre>
<p>The flags tell Inertia to keep background prefetches and async visits (like an infinite scroll) alive, while cancelling the normal page visit. The agent gets an immediate navigation and the sidebar stays primed for the next move.</p>
<h2 id="in-closing">In closing</h2>
<p>Prefetching on hover is one of those features that costs almost nothing to turn on and changes how the app feels. One prop, a cache duration, and you're done. The cancellation scoping is the one detail worth knowing about, because without it you'll undo your own optimisation the first time you navigate away from a view.</p>
<p>You can read more in the <a href="https://inertiajs.com/docs/v3/data-props/prefetching">Inertia v3 prefetching guide</a>. And if you'd like to try There There yourself, we're in private beta right now and you can apply for early access at <a href="https://there-there.app">there-there.app</a>.</p>
]]>
            </summary>
                                    <updated>2026-04-13T11:57:13+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ How we use Inertia v3 optimistic updates in There There]]></title>
            <link rel="alternate" href="https://freek.dev/3085-how-we-use-inertia-v3-optimistic-updates-in-there-there" />
            <id>https://freek.dev/3085</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A few months ago we started building <a href="https://there-there.app">There There</a>, a helpdesk we're making at Spatie. The premise is simple. After two decades of running customer support for our open source work and our SaaS apps, we wanted the tool we always wished existed.</p>
<p>One thing we care about in particular is using AI to help humans craft better responses, not to replace them. The agent stays in charge of the conversation. The model just helps them reply faster and a little sharper. There There is in private beta right now, and you can apply for early access at <a href="https://there-there.app">there-there.app</a>.</p>
<p>We're building There There with Laravel and Inertia, and we lean heavily on the latest features Inertia v3 brings. In this post I'd like to give one example: optimistic updates.</p>
<!--more-->
<h2 id="using-optimistic-updates">Using optimistic updates</h2>
<p>A support agent toggles a lot of small things in the course of a day. Adding a tag to a ticket, granting a teammate access to a channel, flipping a workflow on or off. Each of those is a single click that triggers a server round-trip.</p>
<p>If the UI waits for that round-trip before showing the change, the app feels sluggish. Not broken, just slow. And in a tool you live in all day, that compounds.</p>
<p><a href="https://inertiajs.com/docs/v3/the-basics/optimistic-updates">Inertia v3 ships with a first-class optimistic updates API</a> that handles this nicely. Instead of waiting on a response, we immediately update the UI to reflect the change and let Inertia roll it back if the request fails.</p>
<p>Here's how it looks on our member detail page. An admin can toggle whether a teammate belongs to a team. The list of teams comes in as a prop, and we render a switch next to each one.</p>
<pre data-lang="js" class="notranslate">{teams.<span class="hl-property">map</span>((team) =&gt; (
    &lt;div key={team.<span class="hl-property">id</span>}&gt;
        &lt;span&gt;{team.<span class="hl-property">name</span>}&lt;/span&gt;
        &lt;Switch
            checked={team.<span class="hl-property">is_member</span>}
            onCheckedChange={() =&gt; <span class="hl-property">handleToggleTeam</span>(team)}
        /&gt;
    &lt;/div&gt;
))}
</pre>
<p>When the switch is flipped, <code>handleToggleTeam</code> runs.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">function</span> <span class="hl-property">handleToggleTeam</span>(team) {
    <span class="hl-keyword">const</span> optimistic = router.<span class="hl-property">optimistic</span>((props) =&gt; ({
        <span class="hl-property">teams</span>: props.<span class="hl-property">teams</span>.<span class="hl-property">map</span>((t) =&gt;
            t.<span class="hl-property">id</span> === team.<span class="hl-property">id</span> ? { ...<span class="hl-property">t</span>, <span class="hl-property">is_member</span>: !t.<span class="hl-property">is_member</span> } : t,
        ),
    }));

    <span class="hl-keyword">if</span> (team.<span class="hl-property">is_member</span>) {
        optimistic.<span class="hl-property">delete</span>(removeTeamMember.<span class="hl-property">url</span>({
            <span class="hl-property">team</span>: team.<span class="hl-property">ulid</span>,
            <span class="hl-property">user</span>: member.<span class="hl-property">ulid</span>,
        }), { <span class="hl-property">preserveScroll</span>: <span class="hl-keyword">true</span> });

        <span class="hl-keyword">return</span>;
    }

    optimistic.<span class="hl-property">post</span>(addTeamMember.<span class="hl-property">url</span>({
        <span class="hl-property">team</span>: team.<span class="hl-property">ulid</span>,
        <span class="hl-property">user</span>: member.<span class="hl-property">ulid</span>,
    }), {}, { <span class="hl-property">preserveScroll</span>: <span class="hl-keyword">true</span> });
}
</pre>
<p>The function we pass to <code>router.optimistic</code> receives the current props and returns the keys we want to patch. Inertia applies that patch immediately, so the toggle in the UI flips before the request leaves the browser. When the response comes back, Inertia merges the real server props on top.</p>
<p>I like the chained style here because the same optimistic patch can lead to either a <code>post</code> or a <code>delete</code>. We capture the optimistic builder once and pick the verb after.</p>
<p>Here it is in action. The toggle flips immediately, and the request happens in the background.</p>
<p><video src="https://freek.dev/admin-uploads/optimistic.mp4" autoplay loop muted playsinline style="max-width: 125%; margin-left: -12.5%;"></video></p>
<p>For simpler cases there's a second style. You pass <code>optimistic</code> straight as an option to the verb. Here's our workflow toggle:</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">function</span> <span class="hl-property">handleToggle</span>(workflow) {
    router.<span class="hl-property">patch</span>(workflowToggle.<span class="hl-property">url</span>(workflow.<span class="hl-property">ulid</span>), {}, {
        <span class="hl-property">optimistic</span>: (props) =&gt; ({
            <span class="hl-property">workflows</span>: props.<span class="hl-property">workflows</span>.<span class="hl-property">map</span>((w) =&gt;
                w.<span class="hl-property">id</span> === workflow.<span class="hl-property">id</span> ? { ...<span class="hl-property">w</span>, <span class="hl-property">is_enabled</span>: !w.<span class="hl-property">is_enabled</span> } : w,
            ),
        }),
    });
}
</pre>
<p>Same idea. You describe the patched shape, hand it to Inertia, and let it deal with the rest.</p>
<p>If the server returns a non-2xx response, Inertia reverts the optimistic change automatically. There's no manual restore code to write. Validation errors land where you'd expect, and only the keys you touched in the callback are snapshotted, so unrelated state stays untouched. Concurrent requests each carry their own snapshot, which means a slow request won't undo a faster one that already returned.</p>
<h2 id="in-closing">In closing</h2>
<p>Optimistic updates used to mean keeping a parallel copy of state, reverting it on rejection, and reasoning carefully about race conditions. With Inertia v3, you describe the next state and that's it. The whole interaction is about ten lines.</p>
<p>You can read the official documentation in the <a href="https://inertiajs.com/docs/v3/the-basics/optimistic-updates">Inertia v3 optimistic updates guide</a>. And if you'd like to try There There yourself, we're in private beta right now and you can apply for early access at <a href="https://there-there.app">there-there.app</a>.</p>
]]>
            </summary>
                                    <updated>2026-04-13T11:24:11+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Two Soups, Two Cookies]]></title>
            <link rel="alternate" href="https://freek.dev/3065-two-soups-two-cookies" />
            <id>https://freek.dev/3065</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A lovely analogy about software craft. Same ingredients, same features, but the invisible process behind the decisions is what separates &quot;this works&quot; from &quot;this feels right.&quot;</p>


<a href='https://liamhammett.com/two-soups-two-cookies'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-11T12:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Overcoming AI anxiety]]></title>
            <link rel="alternate" href="https://freek.dev/3063-overcoming-ai-anxiety" />
            <id>https://freek.dev/3063</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Ryan Chandler shares his honest journey from unease to acceptance with AI coding tools. A thoughtful reflection on how your value as a software engineer is not in writing every line, but knowing which lines should exist at all.</p>


<a href='https://ryangjchandler.co.uk/posts/overcoming-ai-anxiety'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-10T12:30:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Prove that you're human]]></title>
            <link rel="alternate" href="https://freek.dev/3078-prove-that-youre-human" />
            <id>https://freek.dev/3078</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A good reminder that trust matters more than ever when every week brings another AI-made product. The post argues that real faces, founder stories, and a visible reputation help both people and LLMs trust what you build.</p>


<a href='https://marketingfordevelopers.com/lessons/prove-that-youre-human'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-09T12:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Quantization from the ground up]]></title>
            <link rel="alternate" href="https://freek.dev/3061-quantization-from-the-ground-up" />
            <id>https://freek.dev/3061</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A thorough explainer on how quantization makes LLMs 4x smaller and 2x faster while losing only 5-10% accuracy. Covers floating point precision, compression techniques, and how to measure quality loss, with interactive examples throughout.</p>


<a href='https://ngrok.com/blog/quantization'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-08T12:41:25+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Designing with Claude Code]]></title>
            <link rel="alternate" href="https://freek.dev/3060-designing-with-claude-code" />
            <id>https://freek.dev/3060</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Steve Schoger shows how he uses Claude Code to design and build UIs, turning natural language prompts into polished interfaces.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/lkKGQVHrXzE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
]]>
            </summary>
                                    <updated>2026-04-07T12:17:28+02:00</updated>
        </entry>
    </feed>
