<?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/originals</id>
                                <link href="https://freek.dev/feed/originals" rel="self"></link>
                                <title><![CDATA[freek.dev - all originally written blogposts]]></title>
                    
                                <subtitle>All originally written blogposts on freek.dev</subtitle>
                                                    <updated>2026-10-09T14:04:12+02:00</updated>
                        <entry>
            <title><![CDATA[★ Typed routes from Laravel to React with Wayfinder]]></title>
            <link rel="alternate" href="https://freek.dev/3088-typed-routes-from-laravel-to-react-with-wayfinder" />
            <id>https://freek.dev/3088</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>I've been writing about <a href="https://there-there.app">There There</a> a lot lately. It's a helpdesk we're building at Spatie, born from two decades of running our own customer support for our open source work and SaaS apps. After all that time being the customer of other helpdesks, we finally started on the one we actually wanted to use.</p>
<p>Our take on AI here is to aim it at the support agent, not the customer. The human owns the conversation. The model makes the reply a little cleaner and a little faster. There There is in private beta, 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. One package we've come to rely on is <a href="https://github.com/laravel/wayfinder">Laravel Wayfinder</a>. It generates typed TypeScript functions for every route and controller in your Laravel app, so the frontend talks to the backend without a single hardcoded URL. This post is about why that matters.</p>
<h2 id="using-wayfinder">Using Wayfinder</h2>
<p>A React codebase quickly fills up with URL strings. Every <code>fetch</code>, every form action, every link points to a route by way of a template literal. Then you rename a route in Laravel, or add a required parameter, and nothing breaks at compile time. You find out in production, or when a teammate clicks the wrong thing.</p>
<p>Wayfinder fixes this by generating a function per named route. You import it and call <code>.url()</code> to build the URL.</p>
<p>Here's a snippet from our team membership toggle.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">import</span> {
    add <span class="hl-keyword">as</span> addTeamMember,
    remove <span class="hl-keyword">as</span> removeTeamMember,
} <span class="hl-keyword">from</span> <span class="hl-value">'@/routes/app/settings/workspace/teams/members'</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> });
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> });
</pre>
<p>Two things are worth pointing out. First, the parameters are type-checked. If you rename the <code>user</code> route parameter to <code>member</code>, TypeScript catches every caller. Second, the route names map cleanly to import paths, so finding them is just autocomplete.</p>
<p>Wayfinder also generates a typed function per controller. We use that when we talk to an endpoint via plain <code>fetch</code>, outside of an Inertia visit. Here's the retry action for a failed outbound message.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">import</span> MessageRetryController <span class="hl-keyword">from</span> <span class="hl-value">'@/actions/App/Http/Controllers/Tickets/MessageRetryController'</span>;

<span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(
    <span class="hl-type">MessageRetryController</span>.<span class="hl-property">url</span>({ <span class="hl-property">ticket</span>: current.<span class="hl-property">ulid</span>, <span class="hl-property">message</span>: message.<span class="hl-property">ulid</span> }),
    { <span class="hl-property">method</span>: <span class="hl-value">'POST'</span> },
);
</pre>
<p>Same deal. The URL is type-safe, the parameters are checked, and renaming the controller in PHP breaks the TypeScript build rather than production.</p>
<p>To make all of this work, we hook Wayfinder into Vite. Here's the relevant bit of our <code>vite.config.js</code>.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">import</span> { wayfinder } <span class="hl-keyword">from</span> <span class="hl-value">&quot;@laravel/vite-plugin-wayfinder&quot;</span>;

<span class="hl-keyword">export</span> <span class="hl-keyword">default</span> <span class="hl-property">defineConfig</span>({
    <span class="hl-property">plugins</span>: [
        <span class="hl-comment">// ...</span>
        <span class="hl-property">wayfinder</span>(),
    ],
});
</pre>
<p>The plugin watches your Laravel routes and controllers during development, and regenerates the typed functions whenever something changes. Imports stay in sync with your backend without any manual step.</p>
<p><img src="https://freek.dev/admin-uploads/wayfinder-typed-route-parameters-v2.png" alt="Illustration of TypeScript flagging a missing user parameter in a Wayfinder route" /></p>
<h2 id="in-closing">In closing</h2>
<p>Wayfinder has shaved a whole category of bug off our work. No more grep-the-codebase when a route changes, no more &quot;why is this fetch returning 404&quot;, no more arguing about trailing slashes. You write the route in Laravel, import it in TypeScript, and the two stay honest with each other.</p>
<p>You can read more about it in <a href="https://github.com/laravel/wayfinder">the Wayfinder repo on GitHub</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-10-09T14:04:12+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Laravel AI SDK in practice: the agent behind There There]]></title>
            <link rel="alternate" href="https://freek.dev/3089-laravel-ai-sdk-in-practice-the-agent-behind-there-there" />
            <id>https://freek.dev/3089</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 weeks I've posted a couple of times about <a href="https://there-there.app">There There</a>, the helpdesk we're building at Spatie. After two decades of running our own customer support, we decided to stop compromising on tools and build the one we actually want to use.</p>
<p>A lot of the fun is on the AI side. We're not using it to replace support agents, we're using it to make them faster. The human reads the ticket, thinks, and clicks a button. The model pulls up relevant history, drafts a reply, tags the ticket, closes a duplicate. 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>The whole AI layer is built on the new <a href="https://github.com/laravel/ai">Laravel AI SDK</a>. It's been a joy to work with, and I'd like to walk through how we use it.</p>
<h2 id="using-the-laravel-ai-sdk">Using the Laravel AI SDK</h2>
<p>Everything starts with an agent class. Ours is called <code>ThereThereAgent</code>. It declares which model to use, what the system prompt looks like, which tools the model can call, and what conversation history to replay.</p>
<p>Here's a trimmed version.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Attributes\MaxSteps</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Attributes\Temperature</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Contracts\Agent</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Contracts\Conversational</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Contracts\HasTools</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Promptable</span>;

<span class="hl-injection"><span class="hl-attribute">#[<span class="hl-type">Temperature</span>(0.3)]</span></span>
<span class="hl-injection"><span class="hl-attribute">#[<span class="hl-type">MaxSteps</span>(10)]</span></span>
<span class="hl-keyword">class</span> <span class="hl-type">ThereThereAgent</span> <span class="hl-keyword">implements</span><span class="hl-type"> Agent, Conversational, HasTools
</span>{
    <span class="hl-keyword">use</span> <span class="hl-type">Promptable</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">protected</span> <span class="hl-type">Workspace</span> <span class="hl-property">$workspace</span>,
        <span class="hl-keyword">protected</span> <span class="hl-type">User</span> <span class="hl-property">$user</span>,
        <span class="hl-keyword">protected</span> <span class="hl-type">?Ticket</span> <span class="hl-property">$contextTicket</span> = <span class="hl-keyword">null</span>,
        <span class="hl-keyword">protected</span> <span class="hl-type">array</span> <span class="hl-property">$conversationHistory</span> = [],
    </span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">model</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-property">config</span>(<span class="hl-value">'ai-pricing.default.name'</span>);
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">instructions</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-property">view</span>(<span class="hl-value">'prompts.ai.there-there-agent-system'</span>, [
            <span class="hl-value">'workspace'</span> =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">workspace</span>,
            <span class="hl-value">'user'</span> =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">user</span>,
            <span class="hl-value">'contextTicket'</span> =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">contextTicket</span>,
        ])-&gt;<span class="hl-property">render</span>();
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">tools</span>(): <span class="hl-type">iterable</span>
    {
        <span class="hl-keyword">return</span> [
            <span class="hl-keyword">new</span> <span class="hl-type">ChangeTicketStatusTool</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">workspace</span>),
            <span class="hl-keyword">new</span> <span class="hl-type">AssignTicketTool</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">workspace</span>),
            <span class="hl-keyword">new</span> <span class="hl-type">AddNoteToTicketTool</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">workspace</span>),
            <span class="hl-keyword">new</span> <span class="hl-type">ManageTicketTagsTool</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">workspace</span>),
        ];
    }
}
</pre>
<p>A few things I like here. The <code>#[Temperature]</code> and <code>#[MaxSteps]</code> attributes are declarative. No wiring through constructor parameters, no config file. The <code>instructions()</code> method renders a Blade view, which means our system prompt is a regular template with includes and partials. And <code>tools()</code> returns an iterable, so we can turn tools on and off depending on context.</p>
<p>Tools are where it gets fun. Each one is a class the model can decide to call, with a typed JSON schema so arguments are validated before they hit our code. Here's the tool that changes a ticket's status.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">ChangeTicketStatusTool</span> <span class="hl-keyword">implements</span><span class="hl-type"> Tool
</span>{
    <span class="hl-keyword">use</span> <span class="hl-type">FindsTicketsInWorkspace</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">protected</span> <span class="hl-type">Workspace</span> <span class="hl-property">$workspace</span></span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">description</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-value">'Change the status of a ticket. Use this when the agent asks to close, reopen, or mark a ticket as spam.'</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">schema</span>(<span class="hl-injection"><span class="hl-type">JsonSchema</span> $schema</span>): <span class="hl-type">array</span>
    {
        <span class="hl-keyword">return</span> [
            <span class="hl-value">'ticket_subject'</span> =&gt; <span class="hl-variable">$schema</span>-&gt;<span class="hl-property">string</span>()-&gt;<span class="hl-property">description</span>(<span class="hl-value">'The subject, title, or ULID of the ticket.'</span>)-&gt;<span class="hl-property">required</span>(),
            <span class="hl-value">'contact_email'</span> =&gt; <span class="hl-variable">$schema</span>-&gt;<span class="hl-property">string</span>()-&gt;<span class="hl-property">description</span>(<span class="hl-value">'The contact email on the ticket.'</span>)-&gt;<span class="hl-property">required</span>(),
            <span class="hl-value">'new_status'</span> =&gt; <span class="hl-variable">$schema</span>-&gt;<span class="hl-property">string</span>()-&gt;<span class="hl-property">description</span>(<span class="hl-value">'The new status: open, closed, or spam.'</span>)-&gt;<span class="hl-property">required</span>(),
        ];
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">handle</span>(<span class="hl-injection"><span class="hl-type">Request</span> $request</span>): <span class="hl-type">string</span>
    {
        <span class="hl-variable">$status</span> = <span class="hl-type">TicketStatus</span>::<span class="hl-property">tryFrom</span>(<span class="hl-variable">$request</span>[<span class="hl-value">'new_status'</span>] ?? <span class="hl-value">''</span>);

        <span class="hl-keyword">if</span> (! <span class="hl-variable">$status</span>) {
            <span class="hl-keyword">return</span> <span class="hl-value">'Invalid status. Must be one of: open, closed, spam.'</span>;
        }

        <span class="hl-variable">$ticket</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">findTicket</span>(<span class="hl-variable">$request</span>[<span class="hl-value">'ticket_subject'</span>], <span class="hl-variable">$request</span>[<span class="hl-value">'contact_email'</span>]);

        <span class="hl-keyword">if</span> (! <span class="hl-variable">$ticket</span>) {
            <span class="hl-keyword">return</span> <span class="hl-value">'Could not find a matching ticket.'</span>;
        }

        <span class="hl-property">app</span>(<span class="hl-type">ChangeTicketStatusAction</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">execute</span>(<span class="hl-variable">$ticket</span>, <span class="hl-variable">$status</span>);

        <span class="hl-keyword">return</span> <span class="hl-property">json_encode</span>([
            <span class="hl-value">'result'</span> =&gt; <span class="hl-value">'success'</span>,
            <span class="hl-value">'ticket'</span> =&gt; <span class="hl-variable">$ticket</span>-&gt;<span class="hl-property">displayTitle</span>(),
            <span class="hl-value">'new_status'</span> =&gt; <span class="hl-variable">$status</span>-&gt;<span class="hl-property">label</span>(),
        ]);
    }
}
</pre>
<p><code>description()</code> is what the model reads when deciding whether to call this tool. <code>schema()</code> returns the typed JSON schema the model has to match. <code>handle()</code> runs the tool and returns a string that goes back into the model's context. We return JSON on success so the model has structured data to refer to when it writes its final reply.</p>
<p>Notice we don't do heavy lifting inside the tool. The actual work is delegated to <code>ChangeTicketStatusAction</code>, a regular domain action. The tool is just an AI-facing adapter over code we already had.</p>
<p>Calling the agent from an action is a couple of lines.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$agent</span> = <span class="hl-keyword">new</span> <span class="hl-type">ThereThereAgent</span>(
    <span class="hl-property">workspace</span>: <span class="hl-variable">$workspace</span>,
    <span class="hl-property">user</span>: <span class="hl-variable">$user</span>,
    <span class="hl-property">contextTicket</span>: <span class="hl-variable">$agentChat</span>-&gt;<span class="hl-property">contextTicket</span>,
    <span class="hl-property">conversationHistory</span>: <span class="hl-variable">$agentChat</span>-&gt;<span class="hl-property">getConversationHistoryExcludingLatest</span>(),
);

<span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">streamingService</span>-&gt;<span class="hl-property">stream</span>(<span class="hl-variable">$agent</span>, <span class="hl-variable">$messageForAi</span>, <span class="hl-variable">$agentChat</span>);
</pre>
<p>We hand the agent off to our streaming service, which turns the SDK's response stream into events the frontend can consume. That's another post.</p>
<p>TODO: short video of the Ask There There agent picking up a message, calling a tool like changing a ticket status, and replying.</p>
<h2 id="in-closing">In closing</h2>
<p>The Laravel AI SDK has hit a sweet spot for our use case. The agent and tool abstractions are simple, the Blade-backed prompt is a joy to edit, and the typed schema means the model rarely mangles its arguments. We've barely scratched the surface of what it can do.</p>
<p>You can read more in <a href="https://github.com/laravel/ai">the Laravel AI SDK repo</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-10-08T12:30:24+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Keeping Laravel and TypeScript in sync with data objects]]></title>
            <link rel="alternate" href="https://freek.dev/3090-keeping-laravel-and-typescript-in-sync-with-data-objects" />
            <id>https://freek.dev/3090</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>At Spatie we build SaaS apps with Laravel on the backend and Inertia with React on the frontend. That split means two type systems have to agree about every payload that crosses between them. Laravel data going out, TypeScript picking it up.</p>
<p><a href="https://there-there.app">There There</a>, the helpdesk we're building, is built exactly like that. Tickets, messages, members, custom sidebar sections, all of it crosses the bridge. We'd go insane maintaining those types by hand. So we don't. 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>Two Spatie packages make this painless. <code>spatie/laravel-data</code> shapes outgoing payloads into typed PHP classes, and <code>spatie/laravel-typescript-transformer</code> turns those classes into TypeScript definitions at build time. Let me show what that looks like in practice.</p>
<h2 id="keeping-types-in-sync">Keeping types in sync</h2>
<p>A payload starts life as a <code>Data</code> class. Here's our <code>MemberData</code>, which we send down every time we render the member detail page.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\LaravelData\Data</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\TypeScriptTransformer\Attributes\TypeScript</span>;

<span class="hl-attribute">#[<span class="hl-type">TypeScript</span>]</span>
<span class="hl-keyword">class</span> <span class="hl-type">MemberData</span> <span class="hl-keyword">extends</span> <span class="hl-type">Data</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">int</span> <span class="hl-property">$id</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">string</span> <span class="hl-property">$ulid</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">string</span> <span class="hl-property">$name</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">string</span> <span class="hl-property">$email</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">?string</span> <span class="hl-property">$avatar_url</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">WorkspaceMemberRole</span> <span class="hl-property">$role</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">string</span> <span class="hl-property">$joined_at</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">bool</span> <span class="hl-property">$restricted_channel_access</span> = <span class="hl-keyword">false</span>,
    </span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">fromPivotUser</span>(<span class="hl-injection"><span class="hl-type">User</span> $user</span>): <span class="hl-type">self</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">new</span> <span class="hl-type">self</span>(
            <span class="hl-property">id</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">id</span>,
            <span class="hl-property">ulid</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">ulid</span>,
            <span class="hl-property">name</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">name</span>,
            <span class="hl-property">email</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">email</span>,
            <span class="hl-property">avatar_url</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">getAvatarUrlWithFallback</span>(),
            <span class="hl-property">role</span>: <span class="hl-type">WorkspaceMemberRole</span>::<span class="hl-property">from</span>(<span class="hl-variable">$user</span>-&gt;<span class="hl-property">pivot</span>-&gt;<span class="hl-property">role</span>),
            <span class="hl-property">joined_at</span>: <span class="hl-variable">$user</span>-&gt;<span class="hl-property">pivot</span>-&gt;<span class="hl-property">created_at</span>-&gt;<span class="hl-property">toIso8601String</span>(),
            <span class="hl-property">restricted_channel_access</span>: (<span class="hl-type">bool</span>) <span class="hl-variable">$user</span>-&gt;<span class="hl-property">pivot</span>-&gt;<span class="hl-property">restricted_channel_access</span>,
        );
    }
}
</pre>
<p>The <code>#[TypeScript]</code> attribute is the opt-in. Any PHP class that carries it is picked up by the transformer. The constructor properties become the TypeScript type, including the enum <code>WorkspaceMemberRole</code>, the nullable string, and the boolean with a default.</p>
<p>The transformer itself is a regular Laravel package with a config file. Here's the relevant part of ours.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">return</span> [
    <span class="hl-value">'auto_discover_types'</span> =&gt; [
        <span class="hl-property">app_path</span>(),
    ],

    <span class="hl-value">'transformers'</span> =&gt; [
        <span class="hl-type">SpatieStateTransformer</span>::<span class="hl-keyword">class</span>,
        <span class="hl-type">EnumTransformer</span>::<span class="hl-keyword">class</span>,
        <span class="hl-type">DataTypeScriptTransformer</span>::<span class="hl-keyword">class</span>,
        <span class="hl-type">DtoTransformer</span>::<span class="hl-keyword">class</span>,
    ],

    <span class="hl-value">'default_type_replacements'</span> =&gt; [
        <span class="hl-type">Carbon\Carbon</span>::<span class="hl-keyword">class</span> =&gt; <span class="hl-value">'string'</span>,
    ],

    <span class="hl-value">'output_file'</span> =&gt; <span class="hl-property">resource_path</span>(<span class="hl-value">'js/types/generated.d.ts'</span>),
];
</pre>
<p>Three things worth knowing. <code>auto_discover_types</code> scans <code>app/</code> for any annotated class. <code>transformers</code> handles data classes, DTOs, enums, and Spatie state machines. <code>default_type_replacements</code> lets you swap PHP types for their TypeScript equivalents, which is how every <code>Carbon</code> date becomes a plain <code>string</code> on the frontend.</p>
<p>When the transformer runs, it writes <code>resources/js/types/generated.d.ts</code>. For our <code>MemberData</code>, the output looks like this.</p>
<pre data-lang="txt" class="notranslate">declare namespace App.Domain.Settings.Data {
    export type MemberData = {
        id: number;
        ulid: string;
        name: string;
        email: string;
        avatar_url: string | null;
        role: App.Domain.Workspace.Enums.WorkspaceMemberRole;
        joined_at: string;
        restricted_channel_access: boolean;
    };
}
</pre>
<p>The namespace mirrors the PHP namespace. The property shapes line up with the constructor. Enums are cross-referenced to their own generated types, so renaming an enum case in PHP propagates to every TypeScript caller.</p>
<p>You could regenerate this file manually with <code>php artisan typescript:transform</code>, but we wire it into Vite so it happens whenever a data class or enum changes.</p>
<pre data-lang="js" class="notranslate"><span class="hl-property">handleHotUpdate</span>({ file }) {
    <span class="hl-keyword">if</span> (isServe &amp;&amp; file.<span class="hl-property">endsWith</span>(<span class="hl-value">'.php'</span>) &amp;&amp; (file.<span class="hl-property">includes</span>(<span class="hl-value">'/Data/'</span>) || file.<span class="hl-property">includes</span>(<span class="hl-value">'/Enums/'</span>))) {
        <span class="hl-property">generate</span>();
    }
},
</pre>
<p>That snippet sits inside a small custom Vite plugin. When the dev server is running and a PHP file under <code>Data/</code> or <code>Enums/</code> changes, we shell out to <code>php artisan typescript:transform</code> and the frontend types update in place. No manual step, no stale definitions.</p>
<p>There's a simpler option in v3 of <code>spatie/laravel-typescript-transformer</code>: the package includes a <a href="https://spatie.be/docs/typescript-transformer/v3/watch-mode/how-it-works">built-in watch mode</a>. After installing <code>chokidar</code>, run <code>php artisan typescript:transform --watch</code>. We're on v2.6, whose <code>typescript:transform</code> command has no <code>--watch</code> option, so the Vite plugin above handles changes for us.</p>
<p>Here's what that looks like in the editor. The fields from <code>MemberData</code> show up as TypeScript suggestions:</p>
<p><img src="https://freek.dev/admin-uploads/member-data-autocomplete-6114fc3a.png" alt="Autocomplete suggestions for the fields of MemberData in a TypeScript editor" /></p>
<h2 id="in-closing">In closing</h2>
<p>This setup has erased a whole category of papercut. We rename a PHP property, the TypeScript compiler lights up on every frontend caller. We add a field, it's available to autocomplete in seconds. The two sides stay honest.</p>
<p>You can find the packages on GitHub: <a href="https://github.com/spatie/laravel-data">laravel-data</a> and <a href="https://github.com/spatie/laravel-typescript-transformer">laravel-typescript-transformer</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-10-07T12:30:50+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Exceptions that explain themselves]]></title>
            <link rel="alternate" href="https://freek.dev/3091-exceptions-that-explain-themselves" />
            <id>https://freek.dev/3091</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p><a href="https://there-there.app">There There</a>, the helpdesk we're building at Spatie, is a project of learned habits. Twenty-some years of writing Laravel apps has taught us which patterns pay off and which ones quietly rot. One that keeps earning its keep is a custom exception class for every domain error.</p>
<p>This is a small pattern, but it's one of those things that makes a codebase nicer to live in. 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>
<h2 id="exceptions-that-explain-themselves">Exceptions that explain themselves</h2>
<p>The conventional answer for a domain error is something like <code>throw new InvalidArgumentException(&quot;Only notes can be deleted&quot;)</code>. It works, the test passes, the user sees a 500 page. But then three months later you try to catch just this specific error and you can't, because <code>InvalidArgumentException</code> is thrown from a dozen other places.</p>
<p>Our pattern is one class per domain error, with a static factory method per reason. Here's the one that fires when someone tries to delete a message that isn't a note.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Domain\Tickets\Exceptions</span>;

<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Tickets\Models\Message</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Exception</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Contracts\Debug\ShouldntReport</span>;

<span class="hl-keyword">class</span> <span class="hl-type">CouldNotDeleteMessage</span> <span class="hl-keyword">extends</span> <span class="hl-type">Exception</span> <span class="hl-keyword">implements</span><span class="hl-type"> ShouldntReport
</span>{
    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">onlyNotesCanBeDeleted</span>(<span class="hl-injection"><span class="hl-type">Message</span> $message</span>): <span class="hl-type">self</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">new</span> <span class="hl-type">self</span>(<span class="hl-value">&quot;Could not delete message id {$message-&gt;id} of type {$message-&gt;type-&gt;value}. Only messages of type `note` can be deleted.&quot;</span>);
    }
}
</pre>
<p>Two things worth calling out. The static method name spells out the failure reason in English, which makes the throw site read like a sentence: <code>throw CouldNotDeleteMessage::onlyNotesCanBeDeleted($message)</code>. And the method takes the typed context as a parameter, which makes the message impossible to build without the relevant data.</p>
<p>Here's the throw site, inside our <code>DeleteNoteAction</code>.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">DeleteNoteAction</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">execute</span>(<span class="hl-injection"><span class="hl-type">Message</span> $message</span>): <span class="hl-type">void</span>
    {
        <span class="hl-keyword">if</span> (! <span class="hl-variable">$message</span>-&gt;<span class="hl-property">isNote</span>()) {
            <span class="hl-keyword">throw</span> <span class="hl-type">CouldNotDeleteMessage</span>::<span class="hl-property">onlyNotesCanBeDeleted</span>(<span class="hl-variable">$message</span>);
        }

        <span class="hl-comment">// ... delete the note</span>
    }
}
</pre>
<p>When the throw site is this readable, you don't need a comment explaining what went wrong. The exception name, the method name, and the message all agree with each other.</p>
<p>The <code>ShouldntReport</code> marker is a small Laravel convention. We implement it so these exceptions don't end up in our error tracker. They're expected domain errors, not bugs, and they surface through user action. Keeping them out of the error log makes the signal there actually signal.</p>
<p>If a domain has several failure modes, we add methods. Our <code>CouldNotInviteMember</code> class has half a dozen static factories, one per reason the invitation couldn't be sent. Every throw site is a one-liner that tells you exactly what went wrong.</p>
<h2 id="in-closing">In closing</h2>
<p>Custom exceptions look like over-engineering until the first time you need to catch one. Then they look like the obvious move. And because every exception class is tiny, they don't add much weight to the codebase.</p>
<p>If you'd like to see how the rest of There There is put together, 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-10-06T12:30:47+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Streaming AI replies from Laravel to React]]></title>
            <link rel="alternate" href="https://freek.dev/3092-streaming-ai-replies-from-laravel-to-react" />
            <id>https://freek.dev/3092</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A couple of posts back I walked through <a href="https://freek.dev/3089-laravel-ai-sdk-in-practice-the-agent-behind-there-there">the agent and tools</a> we built on top of the Laravel AI SDK for <a href="https://there-there.app">There There</a>, the helpdesk we're putting together at Spatie. I ended that post on a small cliffhanger. We hand the agent off to a streaming service that turns the SDK's response stream into events the frontend can consume. That's this post.</p>
<p>A quick reminder on our angle. After two decades of running our own customer support, we wanted a helpdesk where AI makes support agents faster, not one that tries to replace them. The human reads, thinks, and directs. The model drafts and retrieves in real time while they watch. 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>
<h2 id="streaming-from-laravel-to-react">Streaming from Laravel to React</h2>
<p>The obvious way to hand an LLM response to the browser is to wait for it to finish, then render the whole thing. That's easy, but it makes the agent feel sluggish. What we actually want is for the reply to appear word by word, and for the agent's own reasoning (like which tool it's calling) to be narrated along the way.</p>
<p>The Laravel AI SDK exposes each step of a conversation as a typed event. We iterate over that stream and emit newline-delimited JSON for the frontend, one line per event.</p>
<p>Here's the core of our <code>AgentStreamingService</code>.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">stream</span>(<span class="hl-injection"><span class="hl-type">Agent&amp;HasTools</span> $agent, <span class="hl-type">string</span> $message, <span class="hl-type">AgentChat</span> $chat</span>): <span class="hl-type">StreamedResponse</span>
{
    <span class="hl-keyword">return</span> <span class="hl-property">response</span>()-&gt;<span class="hl-property">stream</span>(<span class="hl-keyword">function</span> (<span class="hl-injection">) </span><span class="hl-keyword">use</span> (<span class="hl-variable">$agent</span>, <span class="hl-variable">$message</span>, <span class="hl-variable">$chat</span>): <span class="hl-type">Generator</span> {
        <span class="hl-keyword">yield from</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">streamAgent</span>(<span class="hl-variable">$agent</span>, <span class="hl-variable">$message</span>, <span class="hl-variable">$chat</span>);
    }, 200, [
        <span class="hl-value">'Content-Type'</span> =&gt; <span class="hl-value">'application/x-ndjson'</span>,
        <span class="hl-value">'Cache-Control'</span> =&gt; <span class="hl-value">'no-cache'</span>,
    ]);
}

<span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">streamAgent</span>(<span class="hl-injection"><span class="hl-type">Agent&amp;HasTools</span> $agent, <span class="hl-type">string</span> $message, <span class="hl-type">AgentChat</span> $chat</span>): <span class="hl-type">Generator</span>
{
    <span class="hl-property">set_time_limit</span>(120);

    <span class="hl-variable">$fullContent</span> = <span class="hl-value">''</span>;
    <span class="hl-variable">$toolCalls</span> = [];

    <span class="hl-keyword">foreach</span> (<span class="hl-variable">$agent</span>-&gt;<span class="hl-property">stream</span>(<span class="hl-variable">$message</span>) <span class="hl-keyword">as</span> <span class="hl-variable">$event</span>) {
        <span class="hl-keyword">if</span> (<span class="hl-variable">$event</span> <span class="hl-keyword">instanceof</span> <span class="hl-type">ToolCall</span>) {
            <span class="hl-keyword">yield</span> <span class="hl-property">json_encode</span>([<span class="hl-value">'type'</span> =&gt; <span class="hl-value">'tool_call'</span>, <span class="hl-value">'tool_name'</span> =&gt; <span class="hl-variable">$event</span>-&gt;<span class="hl-property">toolCall</span>-&gt;<span class="hl-property">name</span>]).<span class="hl-value">&quot;\n&quot;</span>;
        }

        <span class="hl-keyword">if</span> (<span class="hl-variable">$event</span> <span class="hl-keyword">instanceof</span> <span class="hl-type">TextDelta</span>) {
            <span class="hl-variable">$fullContent</span> .= <span class="hl-variable">$event</span>-&gt;<span class="hl-property">delta</span>;
            <span class="hl-keyword">yield</span> <span class="hl-property">json_encode</span>([<span class="hl-value">'type'</span> =&gt; <span class="hl-value">'delta'</span>, <span class="hl-value">'content'</span> =&gt; <span class="hl-variable">$event</span>-&gt;<span class="hl-property">delta</span>]).<span class="hl-value">&quot;\n&quot;</span>;
        }

        <span class="hl-keyword">if</span> (<span class="hl-variable">$event</span> <span class="hl-keyword">instanceof</span> <span class="hl-type">ToolResult</span>) {
            <span class="hl-variable">$toolCalls</span>[] = [
                <span class="hl-value">'name'</span> =&gt; <span class="hl-variable">$event</span>-&gt;<span class="hl-property">toolResult</span>-&gt;<span class="hl-property">name</span>,
                <span class="hl-value">'arguments'</span> =&gt; <span class="hl-variable">$event</span>-&gt;<span class="hl-property">toolResult</span>-&gt;<span class="hl-property">arguments</span>,
                <span class="hl-value">'result'</span> =&gt; <span class="hl-variable">$event</span>-&gt;<span class="hl-property">toolResult</span>-&gt;<span class="hl-property">result</span>,
            ];
        }
    }

    <span class="hl-variable">$chat</span>-&gt;<span class="hl-property">messages</span>()-&gt;<span class="hl-property">create</span>([
        <span class="hl-value">'role'</span> =&gt; <span class="hl-type">MessageRole</span>::<span class="hl-property">Assistant</span>,
        <span class="hl-value">'content'</span> =&gt; <span class="hl-variable">$fullContent</span>,
        <span class="hl-value">'tool_calls'</span> =&gt; <span class="hl-variable">$toolCalls</span> ?: <span class="hl-keyword">null</span>,
    ]);

    <span class="hl-keyword">yield</span> <span class="hl-property">json_encode</span>([
        <span class="hl-value">'type'</span> =&gt; <span class="hl-value">'done'</span>,
        <span class="hl-value">'html'</span> =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">parser</span>-&gt;<span class="hl-property">buildFinalHtml</span>(<span class="hl-variable">$fullContent</span>),
    ]).<span class="hl-value">&quot;\n&quot;</span>;
}
</pre>
<p>A few things are worth calling out. <code>response()-&gt;stream()</code> accepts a generator and flushes each <code>yield</code> to the client immediately, which is the whole trick for server-pushed progress. We type the events (<code>delta</code>, <code>tool_call</code>, <code>done</code>) so the frontend can tell a text chunk apart from a tool invocation. And we persist the finished message to the database in the same pass. The stream itself is the source of truth, and the persisted copy is what we rehydrate the next time the user opens the chat.</p>
<p>On the frontend we consume the stream with plain <code>fetch</code>. No library needed, no SSE framing to worry about. Here's the hook we use, trimmed to the bits that matter.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(sendUrl, {
    <span class="hl-property">method</span>: <span class="hl-value">'POST'</span>,
    <span class="hl-property">headers</span>: { ...<span class="hl-property">csrfHeaders</span>(), <span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span> },
    <span class="hl-property">body</span>: <span class="hl-type">JSON</span>.<span class="hl-property">stringify</span>(<span class="hl-property">buildBody</span>(text, html)),
    <span class="hl-property">signal</span>: controller.<span class="hl-property">signal</span>,
});

<span class="hl-keyword">const</span> reader = response.<span class="hl-property">body</span>.<span class="hl-property">getReader</span>();
<span class="hl-keyword">const</span> decoder = <span class="hl-keyword">new</span> <span class="hl-type">TextDecoder</span>();
<span class="hl-keyword">let</span> buffer = <span class="hl-value">''</span>;

<span class="hl-keyword">while</span> (<span class="hl-keyword">true</span>) {
    <span class="hl-keyword">const</span> { done, value } = <span class="hl-keyword">await</span> reader.<span class="hl-property">read</span>();
    <span class="hl-keyword">if</span> (done) <span class="hl-keyword">break</span>;

    buffer += decoder.<span class="hl-property">decode</span>(value, { <span class="hl-property">stream</span>: <span class="hl-keyword">true</span> });
    <span class="hl-keyword">const</span> lines = buffer.<span class="hl-property">split</span>(<span class="hl-value">'\n'</span>);
    buffer = lines.<span class="hl-property">pop</span>() ?? <span class="hl-value">''</span>;

    <span class="hl-keyword">for</span> (<span class="hl-keyword">const</span> line <span class="hl-keyword">of</span> lines) {
        <span class="hl-property">processLine</span>(line);
    }
}

<span class="hl-keyword">if</span> (buffer.<span class="hl-property">trim</span>()) {
    <span class="hl-property">processLine</span>(buffer);
}
</pre>
<p>The buffer pattern is the only thing you have to get right. A single <code>reader.read()</code> call can hand you a partial line, a whole line, or several lines at once. We split on <code>\n</code>, keep the last (possibly incomplete) chunk in <code>buffer</code>, and flush it at the end. Every completed line is one of our events.</p>
<p>Dispatching each event to state is a short switch.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">if</span> (event.<span class="hl-property">type</span> === <span class="hl-value">'tool_call'</span>) {
    <span class="hl-property">setMessages</span>((prev) =&gt; <span class="hl-property">updateLastStreaming</span>(prev, {
        <span class="hl-property">toolStatus</span>: <span class="hl-property">formatToolName</span>(event.<span class="hl-property">tool_name</span>),
    }));
} <span class="hl-keyword">else</span> <span class="hl-keyword">if</span> (event.<span class="hl-property">type</span> === <span class="hl-value">'delta'</span>) {
    <span class="hl-property">setMessages</span>((prev) =&gt; <span class="hl-property">updateLastStreaming</span>(prev, (last) =&gt; ({
        <span class="hl-property">content</span>: last.<span class="hl-property">content</span> + event.<span class="hl-property">content</span>,
        <span class="hl-property">toolStatus</span>: <span class="hl-keyword">null</span>,
    })));
} <span class="hl-keyword">else</span> <span class="hl-keyword">if</span> (event.<span class="hl-property">type</span> === <span class="hl-value">'done'</span>) {
    <span class="hl-property">setMessages</span>((prev) =&gt; <span class="hl-property">updateLastStreaming</span>(prev, {
        <span class="hl-property">html</span>: event.<span class="hl-property">html</span>,
        <span class="hl-property">isStreaming</span>: <span class="hl-keyword">false</span>,
    }));
}
</pre>
<p>When a <code>tool_call</code> arrives, we show a small &quot;Looking up tickets&quot; label above the still-streaming reply. When deltas start landing, we clear the tool label and append to the message content. When <code>done</code> arrives, we swap in the server-rendered HTML and flip the streaming flag off. The whole round-trip feels like the agent is typing in front of you.</p>
<p>TODO: short video of the Ask There There agent replying to a question, with a &quot;Looking up tickets&quot; label appearing briefly and then the tokens streaming in.</p>
<h2 id="in-closing">In closing</h2>
<p>There are heavier ways to do this. Server-sent events, websockets, a full streaming library on each side. For a chat interface, none of it is necessary. A generator on the server, NDJSON on the wire, and a small buffer loop on the client is about a hundred lines end to end, and it behaves exactly like a proper streaming LLM chat should.</p>
<p>You can read more about the events the SDK emits in <a href="https://github.com/laravel/ai">the Laravel AI SDK repo</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-10-05T12:30:47+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ More Jev in There There: smart conditions and AI assignment]]></title>
            <link rel="alternate" href="https://freek.dev/3198-more-jev-in-there-there-smart-conditions-and-ai-assignment" />
            <id>https://freek.dev/3198</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Recently I <a href="https://freek.dev/3194-detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk">wrote about how we use Jev in There There</a> to detect spam, bounces, and automatic replies. Jev reads an incoming email and answers questions we define up front. We keep the probabilities and decide in our own code what counts as a match. The cheap checks on mail headers still run first.</p>
<p>That post ended with a promise that we'd find more uses for Jev. Here are two we have since added to There There: smart conditions for workflows, and automatic assignment to a team or person.</p>
<!--more-->
<h2 id="describing-a-workflow-condition-in-plain-language">Describing a workflow condition in plain language</h2>
<p>Workflows already let you match a subject, sender, or message body against text. That works when the words are predictable. A customer asking for a refund can also write &quot;I was charged twice&quot;, &quot;Could you reverse this payment?&quot;, or something in another language. A list of keywords gets long quickly.</p>
<p>With a smart condition, you describe what should match. Here's one of the example workflows in our local app:</p>
<div style="width:min(900px,max(calc(100vw - 200px),100%));margin-left:calc(100% - min(900px,max(calc(100vw - 200px),100%)))">
<a href="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-smart-condition-private-note-20260930.png"><img src="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-smart-condition-private-note-20260930.png" alt="A smart condition for refund requests followed by a fixed internal note action" style="display:block;width:100%;max-width:none"></a>
</div>
<p>The condition says &quot;The customer asks for a refund or to cancel a payment.&quot; When it matches, the workflow adds a private note asking us to review the payment. The note is fixed text. We turn the description into a Jev Boolean question:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">question</span>(<span class="hl-injection"><span class="hl-type">string</span> $description</span>): <span class="hl-type">Boolean</span>
{
    <span class="hl-keyword">return</span> <span class="hl-keyword">new</span> <span class="hl-type">Boolean</span>(
        <span class="hl-value">&quot;The incoming message matches this workflow description: {$description}&quot;</span>,
        [
            <span class="hl-value">'true'</span> =&gt; <span class="hl-value">'The incoming message fits the description.'</span>,
            <span class="hl-value">'false'</span> =&gt; <span class="hl-value">'The incoming message does not fit the description.'</span>,
        ],
    );
}
</pre>
<p>This question joins the spam and auto-reply questions from the first post in the same inbound classification request. The answer is stored with the message, and the workflow reads its probability from there. It doesn't call Jev again when the workflow runs.</p>
<p>The editor also lets you try a sample email or an existing ticket and see the score before you enable the workflow. Here, a customer asking about a duplicate charge gets a strong match:</p>
<div style="width:min(900px,max(calc(100vw - 200px),100%));margin-left:calc(100% - min(900px,max(calc(100vw - 200px),100%)))">
<a href="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-smart-condition-test-compact-20260930.png"><img src="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-smart-condition-test-compact-20260930.png" alt="Testing a smart condition with a sample refund request" style="display:block;width:100%;max-width:none"></a>
</div>
<p>Only incoming email is checked. Jev gets the subject, sender, and message text, not the whole ticket history.</p>
<h2 id="letting-jev-choose-who-should-handle-a-ticket">Letting Jev choose who should handle a ticket</h2>
<p>When a ticket comes in, we need to get it to the right person. At Spatie, one person might handle billing questions, while someone else is better placed to help with a browser issue or a design question. We know who can handle what. The hard part is translating every way a customer might describe a problem into fixed routing rules.</p>
<p>In a workflow, you select the people Jev may choose from and add a short description of what each handles. Here, Dries handles frontend issues, Jef takes billing questions, Jimi handles design, and Zuzana picks up general support:</p>
<div style="width:min(900px,max(calc(100vw - 200px),100%));margin-left:calc(100% - min(900px,max(calc(100vw - 200px),100%)))">
<a href="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-ai-person-assignment-20260930.png"><img src="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/jev-ai-person-assignment-20260930.png" alt="A workflow where AI chooses between Dries, Jef, Jimi, and Zuzana based on what each person handles" style="display:block;width:100%;max-width:none"></a>
</div>
<p>We ask Jev a Choice question with the people selected in the workflow. It chooses from that list, with one extra option for when nobody fits:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$response</span> = <span class="hl-type">Classification</span>::<span class="hl-property">of</span>(<span class="hl-variable">$state</span>)
    -&gt;<span class="hl-property">questions</span>([<span class="hl-value">'assignee'</span> =&gt; <span class="hl-keyword">new</span> <span class="hl-type">Choice</span>(
        <span class="hl-variable">$instructions</span>,
        [
            ...<span class="hl-property">collect</span>(<span class="hl-variable">$options</span>)
                -&gt;<span class="hl-property">map</span>(<span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Team|User</span> $assignee</span>) =&gt; <span class="hl-variable">$candidates</span>-&gt;<span class="hl-property">descriptionFor</span>(<span class="hl-variable">$assignee</span>))
                -&gt;<span class="hl-property">all</span>(),
            <span class="hl-value">'None of these'</span> =&gt; <span class="hl-value">'None of the others clearly fits this ticket.'</span>,
        ],
    )])
    -&gt;<span class="hl-property">timeout</span>(10)
    -&gt;<span class="hl-property">classify</span>();
</pre>
<p>We only assign when Jev has a clear favorite. Sending a vague ticket to the wrong person creates an unnecessary hand-off, so when nobody clearly fits, the action makes no change. Another workflow can pick it up later.</p>
<p>Before asking Jev, we remove disabled members and anyone who can't access the ticket's channel. The same approach can assign a team, with its own list and descriptions. Team and person assignment are separate actions, so choosing a team doesn't restrict which people Jev can choose from. Spam and automatic replies are skipped before either assignment action runs.</p>
<h2 id="in-closing">In closing</h2>
<p>These additions make our workflows a lot more flexible. We can describe what we're looking for in plain language and route matching tickets to the people best placed to help. The workflow still decides what happens next.</p>
<p>If you missed the first part, <a href="https://freek.dev/3194-detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk">read how we detect spam and auto-replies with Jev</a>. You can also read the <a href="https://there-there.app/docs/workflows/workflow-conditions">workflow conditions</a> and <a href="https://there-there.app/docs/workflows/workflow-actions">workflow actions</a> docs, or <a href="https://there-there.app">try There There</a>.</p>
]]>
            </summary>
                                    <updated>2026-09-30T16:09:48+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Detecting spam and auto-replies with Jev and the Laravel AI SDK]]></title>
            <link rel="alternate" href="https://freek.dev/3194-detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk" />
            <id>https://freek.dev/3194</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Yesterday <a href="https://x.com/taylorotwell/status/2100700952923713641">Taylor announced</a> that Jev support landed in the 1.x branch of the Laravel AI SDK. We started using it that same day for spam detection in <a href="https://there-there.app">There There</a>. Let's take a look at what Jev is and how you can use it.</p>
<!--more-->
<h2 id="how-jev-is-different-from-an-llm">How Jev is different from an LLM</h2>
<p><a href="https://docs.typesafe.ai">Jev</a> is made by TypeSafe. An LLM generates text: you ask it something, it writes an answer, and if you want structured data back you have to ask for it and hope. Jev doesn't generate anything. You give it some state and a question, and it gives you back a number.</p>
<p>TypeSafe calls these System One models. They read natural language like an LLM does, but instead of writing a reply they pick between answers that you define up front. The probabilities are calibrated, which means they're trained against real outcomes, so across a batch of answers a 0.9 should be right about nine times out of ten.</p>
<p>In practice that means no prose to parse, and no prompt asking the model to please respond with valid JSON.</p>
<p>The Syntax folks made a video explaining it:</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/QbYBRjOaGOo" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
<h2 id="noul-choice-and-score">Noul, Choice and Score</h2>
<p>You define what the answers can be using one of three question types.</p>
<p>A Noul is a yes or no question. The answer is a single number: the probability that the answer is yes. Here's how you ask one:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Classification</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Classification\Boolean</span>;

<span class="hl-variable">$result</span> = <span class="hl-type">Classification</span>::<span class="hl-property">of</span>(<span class="hl-value">'I have asked three times now. Can I please talk to a real person?'</span>)
    -&gt;<span class="hl-property">question</span>(<span class="hl-value">'urgent'</span>, <span class="hl-keyword">new</span> <span class="hl-type">Boolean</span>(<span class="hl-value">'Does this request need an immediate response?'</span>))
    -&gt;<span class="hl-property">classify</span>();

<span class="hl-variable">$result</span>[<span class="hl-value">'urgent'</span>]-&gt;<span class="hl-property">probability</span>;            <span class="hl-comment">// 0.94</span>
<span class="hl-variable">$result</span>[<span class="hl-value">'urgent'</span>]-&gt;<span class="hl-property">isTrue</span>(<span class="hl-property">threshold</span>: 0.8); <span class="hl-comment">// true</span>
</pre>
<p>Notice that you get back <code>0.94</code> instead of <code>true</code>. You decide where the cutoff is, which means you can set a different one per question.</p>
<p>A Choice picks one option out of a set that you name. Here's an example:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Laravel\Ai\Classification\Choice</span>;

<span class="hl-variable">$result</span> = <span class="hl-type">Classification</span>::<span class="hl-property">of</span>(<span class="hl-value">'My card was charged twice for order A-104. Please refund the duplicate.'</span>)
    -&gt;<span class="hl-property">question</span>(<span class="hl-value">'department'</span>, <span class="hl-keyword">new</span> <span class="hl-type">Choice</span>(<span class="hl-value">'Which team should handle this request?'</span>, [
        <span class="hl-value">'billing'</span> =&gt; <span class="hl-value">'Payments, invoices, and refunds'</span>,
        <span class="hl-value">'technical'</span> =&gt; <span class="hl-value">'Bugs, outages, and integrations'</span>,
        <span class="hl-value">'sales'</span> =&gt; <span class="hl-value">'Pricing, plans, and upgrades'</span>,
    ]))
    -&gt;<span class="hl-property">classify</span>();

<span class="hl-variable">$result</span>[<span class="hl-value">'department'</span>]-&gt;<span class="hl-property">choice</span>;                   <span class="hl-comment">// 'billing'</span>
<span class="hl-variable">$result</span>[<span class="hl-value">'department'</span>]-&gt;<span class="hl-property">probabilityOf</span>(<span class="hl-value">'billing'</span>); <span class="hl-comment">// 0.87</span>
<span class="hl-variable">$result</span>[<span class="hl-value">'department'</span>]-&gt;<span class="hl-property">confidence</span>;               <span class="hl-comment">// 0.82</span>
</pre>
<p>Next to the option it picked, you also get a probability for every option, and a confidence score that tells you how concentrated those probabilities were.</p>
<p>A Score rates something against levels that you describe yourself. You could ask how frustrated a customer is, where 0 is calm, 1 is frustrated and 2 is very angry. The answer can land between two levels, so a 1.4 is a perfectly good answer.</p>
<p>The state doesn't have to be a string. When a decision depends on more than one thing, you can pass an array so every part has a name:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Classification</span>::<span class="hl-property">of</span>([
    <span class="hl-value">'subject'</span> =&gt; <span class="hl-value">'Duplicate charge'</span>,
    <span class="hl-value">'message'</span> =&gt; <span class="hl-value">'My card was charged twice for order A-104.'</span>,
    <span class="hl-value">'order'</span> =&gt; [<span class="hl-value">'id'</span> =&gt; <span class="hl-value">'A-104'</span>, <span class="hl-value">'charges'</span> =&gt; [49, 49]],
    <span class="hl-value">'refund_policy'</span> =&gt; <span class="hl-value">'Duplicate charges are eligible for a refund.'</span>,
])-&gt;<span class="hl-property">question</span>(<span class="hl-value">'refund_due'</span>, <span class="hl-keyword">new</span> <span class="hl-type">Boolean</span>(<span class="hl-value">'The policy entitles this customer to a refund.'</span>))
    -&gt;<span class="hl-property">classify</span>();
</pre>
<p>That's still one state, even though it holds a message, an order and a policy.</p>
<p>You configure Jev like any other provider in <code>config/ai.php</code>, with a <code>TYPESAFE_API_KEY</code> in your env file.</p>
<h2 id="the-problem-we-wanted-to-solve">The problem we wanted to solve</h2>
<p>You might have noticed that we <a href="https://x.com/freekmurze/status/2100549614248308805">launched There There</a> yesterday as well, our new helpdesk. A lot of the mail that arrives in a helpdesk isn't from a customer. Out-of-office replies, bounces, subscription confirmations, DMARC reports. You don't want those in your inbox, and you don't want to pay an LLM to write a title and a summary for each one.</p>
<p>Some of that mail says what it is in the headers. <code>Auto-Submitted</code>, an empty return path, a sender called mailer-daemon. Checking those costs nothing, so we do that first.</p>
<p>Plenty of mail servers don't set those headers. For those we had a list of subject prefixes: <code>Automatische Antwort</code>, <code>Réponse automatique</code>, <code>Out of office</code>, in fifteen languages. Nine more for bounces. On top of that, rules so that a customer asking a question about auto-replies didn't get treated as one.</p>
<p>Every time we found mail that the list missed, we added another string to it.</p>
<h2 id="replacing-the-list">Replacing the list</h2>
<p>The header checks still run first. Everything they can't answer goes to Jev.</p>
<p>We describe each question once, as a case on an enum that also carries its own threshold. That way adding a fourth question is a single case instead of an edit in four files.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">enum</span> <span class="hl-type">InboundJudgement</span>: <span class="hl-type">string</span>
{
    <span class="hl-keyword">case</span> <span class="hl-property">IsAutoResponse</span> = <span class="hl-value">'is_auto_response'</span>;
    <span class="hl-keyword">case</span> <span class="hl-property">IsBounce</span> = <span class="hl-value">'is_bounce'</span>;
    <span class="hl-keyword">case</span> <span class="hl-property">IsSpam</span> = <span class="hl-value">'is_spam'</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">question</span>(): <span class="hl-type">Boolean</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">match</span> (<span class="hl-variable">$this</span>) {
            <span class="hl-type">self</span>::<span class="hl-property">IsAutoResponse</span> =&gt; <span class="hl-keyword">new</span> <span class="hl-type">Boolean</span>(
                <span class="hl-value">'A system sent this mail on its own, rather than a person choosing to write to us.'</span>,
                [
                    <span class="hl-value">'true'</span> =&gt; 'Sent on a trigger with no human involved at send <span class="hl-property">time</span>: out-of-office
                        notices, delivery reports, ticket acknowledgements, subscription
                        confirmations, digests <span class="hl-keyword">and</span> alerts. Wording composed in advance still counts',
                    <span class="hl-value">'false'</span> =&gt; '<span class="hl-property">A</span> person sat down <span class="hl-keyword">and</span> sent this. Still <span class="hl-keyword">false</span> when a contact form <span class="hl-keyword">or</span>
                        chat widget wrapped their words in a template <span class="hl-keyword">and</span> added lines such <span class="hl-keyword">as</span> Name,
                        <span class="hl-property">E</span>-mail <span class="hl-keyword">or</span> Subject',
                ],
            ),
            <span class="hl-type">self</span>::<span class="hl-property">IsSpam</span> =&gt; <span class="hl-keyword">new</span> <span class="hl-type">Boolean</span>(
                'This mail is unsolicited bulk mail, a scam, <span class="hl-keyword">or</span> phishing rather than a genuine
                    message from a customer.',
                [
                    <span class="hl-value">'true'</span> =&gt; 'Cold sales outreach, marketing blasts, scams, phishing, <span class="hl-keyword">or</span> anything
                        the recipient never asked for',
                    <span class="hl-value">'false'</span> =&gt; '<span class="hl-property">A</span> real person writing about the product, their account, <span class="hl-keyword">or</span> their own
                        support request, however brief <span class="hl-keyword">or</span> badly written',
                ],
            ),
            <span class="hl-comment">// ...</span>
        };
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">threshold</span>(): <span class="hl-type">float</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">match</span> (<span class="hl-variable">$this</span>) {
            <span class="hl-type">self</span>::<span class="hl-property">IsAutoResponse</span> =&gt; 0.75,
            <span class="hl-type">self</span>::<span class="hl-property">IsBounce</span>, <span class="hl-type">self</span>::<span class="hl-property">IsSpam</span> =&gt; 0.9,
        };
    }
}
</pre>
<p>Those <code>true</code> and <code>false</code> descriptions are optional, but I'd recommend writing them. They do more work than the question above them.</p>
<p>All the questions the headers couldn't answer go out in one request. Jev reads the state once and answers them in parallel, and you only pay for input tokens, so asking three questions costs the same as asking one. Here's the action that does it:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">execute</span>(<span class="hl-injection"><span class="hl-type">Message</span> $message, <span class="hl-type">Ticket</span> $ticket, <span class="hl-type">Workspace</span> $workspace</span>): <span class="hl-type">void</span>
{
    <span class="hl-variable">$judgements</span> = <span class="hl-property">array_filter</span>(
        <span class="hl-type">InboundJudgement</span>::<span class="hl-property">cases</span>(),
        <span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">InboundJudgement</span> $judgement</span>) =&gt; ! <span class="hl-variable">$judgement</span>-&gt;<span class="hl-property">settledByHeaders</span>(<span class="hl-variable">$message</span>),
    );

    <span class="hl-keyword">if</span> (<span class="hl-variable">$judgements</span> === []) {
        <span class="hl-keyword">return</span>;
    }

    <span class="hl-keyword">try</span> {
        <span class="hl-variable">$response</span> = <span class="hl-type">Classification</span>::<span class="hl-property">of</span>([
            <span class="hl-value">'subject'</span> =&gt; <span class="hl-variable">$ticket</span>-&gt;<span class="hl-property">subject</span>,
            <span class="hl-value">'from_name'</span> =&gt; <span class="hl-variable">$message</span>-&gt;<span class="hl-property">author_name</span>,
            <span class="hl-value">'from_email'</span> =&gt; <span class="hl-variable">$message</span>-&gt;<span class="hl-property">author_email</span> ?? <span class="hl-variable">$ticket</span>-&gt;<span class="hl-property">contact</span>?-&gt;<span class="hl-property">email</span>,
            <span class="hl-value">'message'</span> =&gt; <span class="hl-type">Str</span>::<span class="hl-property">limit</span>(<span class="hl-variable">$message</span>-&gt;<span class="hl-property">body_text</span>, 10_000),
        ])
            -&gt;<span class="hl-property">questions</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">questionsFor</span>(<span class="hl-variable">$judgements</span>))
            -&gt;<span class="hl-property">timeout</span>(10)
            -&gt;<span class="hl-property">classify</span>();

        <span class="hl-variable">$verdicts</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">verdicts</span>(<span class="hl-variable">$judgements</span>, <span class="hl-variable">$response</span>);
    } <span class="hl-keyword">catch</span> (<span class="hl-type">Throwable</span> <span class="hl-variable">$exception</span>) {
        <span class="hl-type">Log</span>::<span class="hl-property">warning</span>(<span class="hl-value">'Could not classify an inbound message.'</span>, [
            <span class="hl-value">'message_id'</span> =&gt; <span class="hl-variable">$message</span>-&gt;<span class="hl-property">id</span>,
            <span class="hl-value">'error'</span> =&gt; <span class="hl-variable">$exception</span>-&gt;<span class="hl-property">getMessage</span>(),
        ]);

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

    <span class="hl-variable">$message</span>-&gt;<span class="hl-property">updateQuietly</span>([...<span class="hl-variable">$verdicts</span>, <span class="hl-value">'classification'</span> =&gt; <span class="hl-variable">$response</span>-&gt;<span class="hl-property">answers</span>]);
}
</pre>
<p>There are two things in there I'd suggest copying. The whole call sits in a try block that logs the problem and moves on, because a classification is a nice to have and it shouldn't be able to break the mail pipeline it's helping. And we truncate the message, because an inbound mail can be megabytes long and nothing past the first part of it changes what the mail is.</p>
<p>Turning the answers into booleans is where each question's own threshold is applied. We store the raw probabilities next to them, so we can change a threshold later and see what it would have done:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">verdicts</span>(<span class="hl-injection"><span class="hl-type">array</span> $judgements, <span class="hl-type">ClassificationResponse</span> $response</span>): <span class="hl-type">array</span>
{
    <span class="hl-variable">$verdicts</span> = [];

    <span class="hl-keyword">foreach</span> (<span class="hl-variable">$judgements</span> <span class="hl-keyword">as</span> <span class="hl-variable">$judgement</span>) {
        <span class="hl-variable">$answer</span> = <span class="hl-variable">$response</span>-&gt;<span class="hl-property">answer</span>(<span class="hl-variable">$judgement</span>-&gt;<span class="hl-property">value</span>);

        <span class="hl-variable">$verdicts</span>[<span class="hl-variable">$judgement</span>-&gt;<span class="hl-property">value</span>] = <span class="hl-variable">$answer</span>-&gt;<span class="hl-property">isTrue</span>(<span class="hl-variable">$judgement</span>-&gt;<span class="hl-property">threshold</span>());
    }

    <span class="hl-keyword">return</span> <span class="hl-variable">$verdicts</span>;
}
</pre>
<p>Those verdicts are stored on the message. When a customer builds a workflow in There There with an &quot;Is spam&quot; condition, checking that condition reads a single column and doesn't call Jev at all.</p>
<h2 id="in-closing">In closing</h2>
<p>I like that Jev does one small thing. It gives you a number and leaves the rest of the decisions in your own code, where you can read them and write tests for them.</p>
<p>It's also fast and cheap enough that you don't really have to think about it. Classifying a mail with three questions at once takes 639ms, and we get around 48 per second when we run them in parallel. We didn't spend any time tuning that, so I'm sure you could get more out of it, but for what we're doing it's fast enough. Jev costs $0.042 per million input tokens and output tokens are free, which for us comes down to four hundredths of a cent per mail, or about 36 cents a month.</p>
<p>We have a list of other places where we want to use this in There There, and in our other products. Expect more Jev powered features soon.</p>
<p>If you want to read more, there are the <a href="https://docs.typesafe.ai">TypeSafe docs</a> and the <a href="https://github.com/laravel/ai">Laravel AI SDK</a>. And if you'd like to see the spam detection at work, you can try <a href="https://there-there.app">There There</a>.</p>
]]>
            </summary>
                                    <updated>2026-09-18T17:39:46+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Searching multiple columns with one URL parameter in laravel-query-builder]]></title>
            <link rel="alternate" href="https://freek.dev/3118-searching-multiple-columns-with-one-url-parameter-in-laravel-query-builder" />
            <id>https://freek.dev/3118</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released v7.3.0 of <a href="https://spatie.be/docs/laravel-query-builder">laravel-query-builder</a>, which adds a new way to group multiple filters under a single URL parameter. Before getting into the new feature, let me show you how the basics work, so the new bit makes sense in context.</p>
<!--more-->
<h2 id="the-basics">The basics</h2>
<p>Here's a typical setup in a controller:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\QueryBuilder\AllowedFilter</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\QueryBuilder\QueryBuilder</span>;

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">partial</span>(<span class="hl-value">'name'</span>),
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">exact</span>(<span class="hl-value">'status'</span>),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>With that in place, the package wires up two filters that clients can use through the query string.</p>
<p>A request to <code>/users?filter[name]=john</code> runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span> <span class="hl-keyword">WHERE</span> <span class="hl-property">LOWER</span>(name) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>'
</pre>
<p>A request to <code>/users?filter[status]=active</code> runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span> <span class="hl-keyword">WHERE</span> status = '<span class="hl-value">active</span>'
</pre>
<p>And a request to <code>/users?filter[name]=john&amp;filter[status]=active</code> runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span>
<span class="hl-keyword">WHERE</span> <span class="hl-property">LOWER</span>(name) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>'
  <span class="hl-keyword">AND</span> status = '<span class="hl-value">active</span>'
</pre>
<p>Multiple filters in the URL are joined with <code>AND</code>. That's the default and it's what you want most of the time.</p>
<p>The word &quot;allowed&quot; in <code>allowedFilters</code> is doing real work. If a client tries to filter by something you haven't whitelisted, the package throws an <code>InvalidFilterQuery</code> exception instead of silently ignoring it or, worse, letting it through. So a request to <code>/users?filter[email]=acme.com</code> against the controller above results in:</p>
<pre data-lang="txt" class="notranslate">Spatie\QueryBuilder\Exceptions\InvalidFilterQuery
Requested filter(s) `email` are not allowed.
Allowed filter(s) are `name, status`.
</pre>
<p>The exception has a <code>400 Bad Request</code> status code, so by default Laravel renders it as a clean error response to the client. You don't have to do anything special to get this behavior.</p>
<h2 id="the-new-bit-grouping-filters">The new bit: grouping filters</h2>
<p>Now imagine you want a single search box that matches across multiple columns. The user types &quot;john&quot; and you want to find rows where either the <code>name</code> or the <code>email</code> contains that value.</p>
<p>Before v7.3.0, you'd reach for a custom callback filter:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">exact</span>(<span class="hl-value">'status'</span>),
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">callback</span>(<span class="hl-value">'q'</span>, <span class="hl-keyword">function</span> (<span class="hl-injection">$query, $value</span>) {
            <span class="hl-variable">$query</span>-&gt;<span class="hl-property">where</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'LIKE'</span>, <span class="hl-value">&quot;%{$value}%&quot;</span>)
                -&gt;<span class="hl-property">orWhere</span>(<span class="hl-value">'email'</span>, <span class="hl-value">'LIKE'</span>, <span class="hl-value">&quot;%{$value}%&quot;</span>);
        }),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>Looks fine at a glance. But a request to <code>/users?filter[status]=active&amp;filter[q]=john</code> runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span>
<span class="hl-keyword">WHERE</span> status = '<span class="hl-value">active</span>'
  <span class="hl-keyword">AND</span> name <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>'
   <span class="hl-keyword">OR</span> email <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>'
</pre>
<p>Spot the bug. The <code>OR</code> binds looser than the <code>AND</code>, so this matches every row whose <code>email</code> contains &quot;john&quot;, including inactive users. To fix it, you have to wrap the callback's <code>where</code>s in their own nested closure. It's an easy thing to forget, and it silently returns wrong data when you do.</p>
<p>In v7.3.0, this becomes a one-liner. Here's how you register a group filter:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">groupOr</span>(<span class="hl-value">'q'</span>, [
            <span class="hl-type">AllowedFilter</span>::<span class="hl-property">partial</span>(<span class="hl-value">'name'</span>),
            <span class="hl-type">AllowedFilter</span>::<span class="hl-property">partial</span>(<span class="hl-value">'email'</span>),
        ]),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>A request to <code>/users?filter[q]=john</code> now runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span>
<span class="hl-keyword">WHERE</span> (<span class="hl-property">LOWER</span>(name) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>' <span class="hl-keyword">OR</span> <span class="hl-property">LOWER</span>(email) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>')
</pre>
<p>The value <code>john</code> is passed to every member of the group, and the conjunction joins them. There's also <code>AllowedFilter::groupAnd()</code> if you want all members to match instead.</p>
<h2 id="combining-group-filters-with-regular-filters">Combining group filters with regular filters</h2>
<p>Groups compose cleanly with the rest of your filters. Here's a setup that has both a top-level <code>status</code> filter and a group:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">exact</span>(<span class="hl-value">'status'</span>),
        <span class="hl-type">AllowedFilter</span>::<span class="hl-property">groupOr</span>(<span class="hl-value">'q'</span>, [
            <span class="hl-type">AllowedFilter</span>::<span class="hl-property">partial</span>(<span class="hl-value">'name'</span>),
            <span class="hl-type">AllowedFilter</span>::<span class="hl-property">partial</span>(<span class="hl-value">'email'</span>),
        ]),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>A request to <code>/users?filter[status]=active&amp;filter[q]=john</code> runs:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span>
<span class="hl-keyword">WHERE</span> status = '<span class="hl-value">active</span>'
  <span class="hl-keyword">AND</span> (<span class="hl-property">LOWER</span>(name) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>' <span class="hl-keyword">OR</span> <span class="hl-property">LOWER</span>(email) <span class="hl-keyword">LIKE</span> '<span class="hl-value">%john%</span>')
</pre>
<p>The whole group is wrapped in its own parentheses, so the <code>OR</code> can never leak into the <code>status</code> clause. That's the bug pattern that used to bite people who rolled their own callback filter for cross-column search.</p>
<p>You can also register multiple independent groups. Each one becomes its own wrapped scope, and they're joined with <code>AND</code> between them. The members can be any <code>AllowedFilter</code> type, so you can mix partial, exact, scope, and other filters in the same group.</p>
<h2 id="in-closing">In closing</h2>
<p>The upgrade is fully non-breaking. All existing filter behavior is unchanged. The new feature is documented in the <a href="https://spatie.be/docs/laravel-query-builder/v7/features/filtering">filtering docs</a>, and the <a href="https://github.com/spatie/laravel-query-builder/pull/1060">pull request</a> by Birtan Taşkın has the full reasoning behind the design.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be">Spatie</a>. If you want to support our open source work, consider picking up one of our <a href="https://spatie.be/products">paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-05-04T10:09:06+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Announcing laravel-sluggable v4 with self-healing URLs]]></title>
            <link rel="alternate" href="https://freek.dev/3112-announcing-laravel-sluggable-v4-with-self-healing-urls" />
            <id>https://freek.dev/3112</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>The <a href="https://github.com/spatie/laravel-sluggable"><code>spatie/laravel-sluggable</code></a> package has been around for close to a decade. A slug is the readable part of a URL that identifies a record, like <code>announcing-laravel-sluggable-v4-with-self-healing-urls</code> in this post's URL. The package generates one for any Eloquent model when you save it, derived from a title or another text field, and most of the time you don't have to think about it.</p>
<p>We just released v4, which adds a few things worth talking about. Let me walk you through them.</p>
<!--more-->
<h2 id="generating-slugs-with-an-attribute">Generating slugs with an attribute</h2>
<p>For most models, slug generation is mechanical. Pick a source field, pick a destination field, done. In v4, you can express that with an attribute on the model.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sluggable\Attributes\Sluggable</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Model</span>;

<span class="hl-injection"><span class="hl-attribute">#[<span class="hl-type">Sluggable</span>(<span class="hl-property">from</span>: <span class="hl-value">'title'</span>, <span class="hl-property">to</span>: <span class="hl-value">'slug'</span>)]</span></span>
<span class="hl-keyword">class</span> <span class="hl-type">Post</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span>
{
}
</pre>
<p>That's it. No trait, no <code>getSlugOptions()</code> method. Save a <code>Post</code> and the package fills in the slug:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$post</span> = <span class="hl-type">Post</span>::<span class="hl-property">create</span>([<span class="hl-value">'title'</span> =&gt; <span class="hl-value">'ActiveRecord is awesome'</span>]);

<span class="hl-variable">$post</span>-&gt;<span class="hl-property">slug</span>; <span class="hl-comment">// &quot;activerecord-is-awesome&quot;</span>
</pre>
<h3 id="self-healing-urls">Self-healing URLs</h3>
<p>Picture this. You publish a new post titled &quot;Anouncing v4 of laravel-sluggable&quot;. The slug becomes <code>anouncing-v4-of-laravel-sluggable</code>, you share the link, a few people bookmark it. Five minutes later you notice the missing <code>n</code> and fix the title. The slug regenerates to <code>announcing-v4-of-laravel-sluggable</code>, and now everyone who clicked the first version hits a 404.</p>
<p>The usual answer is a redirects table: store the old slug, forward it to the new one. It works, but every rename adds a row, and after a while it's another moving piece to maintain.</p>
<p>Self-healing URLs solve this differently. The route key combines the slug with the primary key, so the slug is decorative and the id is what actually identifies the record.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sluggable\Attributes\Sluggable</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sluggable\HasSlug</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Model</span>;

<span class="hl-injection"><span class="hl-attribute">#[<span class="hl-type">Sluggable</span>(<span class="hl-property">from</span>: <span class="hl-value">'title'</span>, <span class="hl-property">to</span>: <span class="hl-value">'slug'</span>, <span class="hl-property">selfHealing</span>: <span class="hl-keyword">true</span>)]</span></span>
<span class="hl-keyword">class</span> <span class="hl-type">Post</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">HasSlug</span>;
}
</pre>
<p>If your post has slug <code>hello-world</code> and id <code>5</code>, the URL becomes <code>/posts/hello-world-5</code>. Rename the post to <code>Greetings, World</code> and the canonical URL becomes <code>/posts/greetings-world-5</code>. Old links keep working because the package can still find the record by id, and it sends a permanent redirect to the new canonical URL.</p>
<p>The redirect status code is <code>308 Permanent Redirect</code>. In v3 it was <code>301</code>. The reason for the change is that <code>301</code> is allowed to silently downgrade <code>PUT</code>, <code>PATCH</code>, and <code>DELETE</code> to <code>GET</code> when followed, which gets you a <code>405 Method Not Allowed</code> if the new URL doesn't accept <code>GET</code>. <code>308</code> keeps the method intact.</p>
<h3 id="overridable-actions">Overridable actions</h3>
<p>Slug generation and the self-healing URL format are now expressed as three actions: one for generating the slug, one for building the self-healing route key, and one for parsing it back out. All three can be swapped through <code>config/sluggable.php</code>. The example below customizes the two self-healing actions; the slug generator stays on its default behavior.</p>
<p>The use case I had in mind was self-healing URLs that put the id first. Say you want <code>/posts/5-hello-world</code> instead of <code>/posts/hello-world-5</code>. Extend the default builder action and put the identifier in front:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Sluggable</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sluggable\Actions\BuildSelfHealingRouteKeyAction</span>;

<span class="hl-keyword">class</span> <span class="hl-type">IdFirstBuildAction</span> <span class="hl-keyword">extends</span> <span class="hl-type">BuildSelfHealingRouteKeyAction</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">execute</span>(<span class="hl-injection"><span class="hl-type">string</span> $slug, <span class="hl-type">int|string</span> $identifier, <span class="hl-type">string</span> $separator</span>): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">if</span> (<span class="hl-variable">$slug</span> === <span class="hl-value">''</span>) {
            <span class="hl-keyword">return</span> (<span class="hl-type">string</span>) <span class="hl-variable">$identifier</span>;
        }

        <span class="hl-keyword">return</span> <span class="hl-value">&quot;{$identifier}{$separator}{$slug}&quot;</span>;
    }
}
</pre>
<p>The extractor needs the inverse logic. The default action looks for the last separator, because slugs can contain the separator themselves, so swap it for one that reads the identifier from the front:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Sluggable</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\Str</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sluggable\Actions\ExtractIdentifierFromSelfHealingRouteKeyAction</span>;

<span class="hl-keyword">class</span> <span class="hl-type">IdFirstExtractAction</span> <span class="hl-keyword">extends</span> <span class="hl-type">ExtractIdentifierFromSelfHealingRouteKeyAction</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">execute</span>(<span class="hl-injection"><span class="hl-type">string</span> $value, <span class="hl-type">string</span> $separator</span>): <span class="hl-type">array</span>
    {
        <span class="hl-variable">$identifier</span> = <span class="hl-type">Str</span>::<span class="hl-property">before</span>(<span class="hl-variable">$value</span>, <span class="hl-variable">$separator</span>);

        <span class="hl-keyword">if</span> (<span class="hl-variable">$identifier</span> === <span class="hl-variable">$value</span> <span class="hl-operator">||</span> ! <span class="hl-property">ctype_digit</span>(<span class="hl-variable">$identifier</span>)) {
            <span class="hl-keyword">return</span> [<span class="hl-value">'slug'</span> =&gt; <span class="hl-variable">$value</span>, <span class="hl-value">'identifier'</span> =&gt; null];
        }

        <span class="hl-keyword">return</span> [
            <span class="hl-value">'slug'</span> =&gt; <span class="hl-type">Str</span>::<span class="hl-property">after</span>(<span class="hl-variable">$value</span>, <span class="hl-variable">$separator</span>),
            <span class="hl-value">'identifier'</span> =&gt; <span class="hl-variable">$identifier</span>,
        ];
    }
}
</pre>
<p><code>Str::before</code> returns the original string when the separator isn't present, which is how the no-separator case is detected. The <code>ctype_digit</code> check rejects values where the prefix isn't numeric, so models with non-numeric primary keys like ULIDs need to swap that for a regex.</p>
<p>Wire both into the config and you're done:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// config/sluggable.php</span>
<span class="hl-value">'build_self_healing_route_key'</span> =&gt; <span class="hl-type">App\Sluggable\IdFirstBuildAction</span>::<span class="hl-keyword">class</span>,
<span class="hl-value">'extract_identifier_from_self_healing_route_key'</span> =&gt; <span class="hl-type">App\Sluggable\IdFirstExtractAction</span>::<span class="hl-keyword">class</span>,
</pre>
<p>The full reference, including a simpler uppercase variant and a custom slug generator, lives in the <a href="https://spatie.be/docs/laravel-sluggable/v4/advanced-usage/overriding-actions">overriding actions docs</a>.</p>
<h3 id="a-bundled-boost-skill">A bundled Boost skill</h3>
<p><a href="https://github.com/laravel/boost">Laravel Boost</a> is an MCP server from the Laravel team that exposes your application's installed packages and versions to your AI assistant, so prompts produce code that fits your stack instead of generic Laravel snippets. Packages can bundle their own Boost skills, and Boost discovers them automatically.</p>
<p>This package ships with a <a href="https://github.com/spatie/laravel-sluggable/blob/main/resources/boost/skills/sluggable-development/SKILL.md">sluggable-development skill</a> that walks your assistant through adding a sluggable model: picking the source field, deciding whether you need self-healing URLs, generating the migration, and dropping the attribute or trait on the model. If you have Boost installed, this is the easiest way to add a new sluggable model to your project.</p>
<h2 id="in-closing">In closing</h2>
<p>You can find the <a href="https://github.com/spatie/laravel-sluggable">package on GitHub</a> and the full <a href="https://spatie.be/docs/laravel-sluggable/v4">documentation on our docs site</a>. Coming from v3? The <a href="https://github.com/spatie/laravel-sluggable/blob/main/UPGRADING.md">upgrade guide</a> covers the breaking changes (mostly minimum versions and a <code>callable</code> to <code>Closure</code> migration).</p>
<p>This is one of <a href="https://spatie.be/open-source">many packages we've built at Spatie</a>. If you want to support our open source work, consider picking up one of our <a href="https://spatie.be/products">paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-04-29T16:36:36+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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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[★ 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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/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[★ Scotty: a beautiful SSH task runner]]></title>
            <link rel="alternate" href="https://freek.dev/3064-scotty-a-beautiful-ssh-task-runner" />
            <id>https://freek.dev/3064</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released <a href="https://github.com/spatie/scotty">Scotty</a>, a beautiful SSH task runner. It lets you define deploy scripts and other remote tasks, run them from your terminal, and watch every step as it happens. It supports both Laravel Envoy's Blade format and a new plain bash format.</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-run-deploy.jpg?raw=true" alt="Deploy output" /></p>
<h2 id="why-we-built-scotty">Why we built Scotty</h2>
<p>Even though services like Laravel Cloud make it possible to never think about servers again, I still prefer deploying to my own servers for some projects. I know my way around them, I can pick whichever server provider I want, and I have full control over the environment.</p>
<p>Tools like <a href="https://forge.laravel.com">Laravel Forge</a> have built-in deploy functionality, and it works well. But I've always preferred running deploy scripts manually from my terminal. I want to see exactly what's happening while it runs, and when something goes wrong, I want to be staring at the output the moment it happens, not clicking through a web UI to find a log.</p>
<p>For years, I used <a href="https://laravel.com/docs/envoy">Laravel Envoy</a> for this. It does the job, but I wanted nicer output while tasks are running, and the ability to pause execution mid-deploy. Envoy uses a Blade-based file format, which works fine for us. But not everyone wants to write Blade syntax for their deploy scripts. So Scotty also offers a plain bash format for people who prefer that.</p>
<p>Scotty was built with the help of AI, using the <a href="https://github.com/laravel/envoy">Envoy codebase</a> as a source. Even though Scotty is a rebuild from scratch, in spirit it is a fork of Envoy. Credits to Laravel for providing the foundation. You can read the <a href="https://github.com/spatie/scotty#acknowledgements">full acknowledgement in the Scotty repo</a>.</p>
<h2 id="using-scotty">Using Scotty</h2>
<h3 id="defining-tasks-and-deploying">Defining tasks and deploying</h3>
<p>You define your tasks in a <code>Scotty.sh</code> file. It's just plain bash with some annotation comments. Your editor highlights it correctly, you get full shell support out of the box. Here's what that looks like:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment">#!/usr/bin/env scotty</span>

<span class="hl-comment"># @servers remote=deployer@your-server.com</span>

<span class="hl-comment"># @task on:remote</span>
deploy() {
    cd /var/www/my-app
    git pull origin main
    php artisan migrate --force
}
</pre>
<p>Deploy with a single command:</p>
<pre data-lang="bash" class="notranslate">scotty run deploy
</pre>
<p>Scotty connects to your server, runs each task in order, and shows you what's happening in real time. Each task displays its name, a step counter, elapsed time, and the command currently being executed. When everything finishes, you get a summary table with timing for each step.</p>
<p>These screenshots are from deploying this very blog. You can find the <a href="https://github.com/spatie/freek.dev/blob/main/Envoy.blade.php">deploy file on GitHub</a>.</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-run-deploy.jpg?raw=true" alt="Deploy output" /></p>
<p>If a task fails, its output is shown and execution stops right there so you can investigate.</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-failure.jpg?raw=true" alt="Task failure" /></p>
<h3 id="writing-plain-bash-instead-of-blade">Writing plain bash instead of Blade</h3>
<p>In addition to being able to read <code>Envoy.blade.php</code> files, Scotty introduces a new <code>Scotty.sh</code> format. Every line is real bash. Tasks are just bash functions with a <code># @task</code> annotation that tells Scotty which server to run them on. Macros group tasks into a sequence. Variables are plain bash variables, available everywhere.</p>
<p>Because it's all valid bash, you can use helper functions, computed values like <code>$(date +%Y%m%d-%H%M%S)</code>, and any shell construct you'd normally use. No Blade directives, no PHP <code>@setup</code> blocks, no <code>{{ $variable }}</code> interpolation.</p>
<p>You can also pass variables from the command line. Running <code>scotty run deploy --branch=develop</code> makes <code>$BRANCH</code> available in your tasks. The key is automatically uppercased.</p>
<p>You can list all available tasks and macros with <code>scotty tasks</code>:</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-tasks.jpg?raw=true" alt="scotty tasks" /></p>
<h3 id="pausing-resuming-and-pretending">Pausing, resuming, and pretending</h3>
<p>You can press <code>p</code> at any time during execution to pause after the current task finishes. Press <code>Enter</code> to resume, or <code>Ctrl+C</code> to cancel. This is handy when you want to check something on the server mid-deploy before continuing.</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-pause.jpg?raw=true" alt="Pause and resume" /></p>
<p>There's also a pretend mode (<code>scotty run deploy --pretend</code>) that shows you exactly which SSH commands would be executed without actually running them. And a summary mode (<code>scotty run deploy --summary</code>) that hides task output and only shows results.</p>
<h3 id="validating-your-setup-with-doctor">Validating your setup with doctor</h3>
<p>Before your first deploy to a new server, you can run <code>scotty doctor</code> to validate your setup. It checks that your file parses correctly, verifies SSH connectivity to each server, and confirms that tools like <code>php</code>, <code>composer</code>, <code>node</code>, and <code>git</code> are available on the remote machine.</p>
<p><img src="https://github.com/spatie/scotty/blob/main/docs/images/scotty-doctor.jpg?raw=true" alt="scotty doctor" /></p>
<h3 id="migrating-from-envoy">Migrating from Envoy</h3>
<p>If you're already using Laravel Envoy, Scotty can read your <code>Envoy.blade.php</code> file directly. No changes needed. Just run <code>scotty run deploy</code> and it works. From there, you can gradually migrate to the <code>Scotty.sh</code> format if you want to, or keep using Blade.</p>
<p>If you're new to all of this, the <a href="https://spatie.be/docs/scotty/v1/basic-usage/your-first-deploy-script">Your first deploy script</a> page in the docs walks you through everything step by step.</p>
<h2 id="in-closing">In closing</h2>
<p>Scotty gives you a clean, modern way to run deploy scripts and other SSH tasks from your terminal. Plain bash, beautiful output, and full control over every step.</p>
<p>You can find <a href="https://spatie.be/docs/scotty/v1/introduction">the full documentation on our docs site</a> and the source code <a href="https://github.com/spatie/scotty">on GitHub</a>. This is one of the many tools and packages we've created at <a href="https://spatie.be">Spatie</a>. If you want to support our open source work, consider picking up one of <a href="https://spatie.be/products">our paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-30T09:44:16+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ What's new in laravel-activitylog v5]]></title>
            <link rel="alternate" href="https://freek.dev/3058-whats-new-in-laravel-activitylog-v5" />
            <id>https://freek.dev/3058</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released v5 of <a href="https://spatie.be/docs/laravel-activitylog">laravel-activitylog</a>, our package for logging user activity and model events in Laravel.</p>
<p>In <a href="https://flareapp.io">Flare</a>, <a href="https://mailcoach.app">Mailcoach</a>, and <a href="https://ohdear.app?utm_source=freek.dev&amp;utm_medium=referral&amp;utm_campaign=blogpost-3058-whats-new-in-laravel-activitylog-v5">Oh Dear</a> we use it to build audit logs, so we can track what users are doing: who changed a setting, who deleted a project, who invited a team member. If you need something similar in your app, this package makes it easy.</p>
<p>This major release requires PHP 8.4+ and Laravel 12+, and brings a cleaner API, a better database schema, and customizable internals. Let me walk you through what the package can do and what's new in v5.</p>
<!--more-->
<h2 id="using-the-package">Using the package</h2>
<p>At its core, the package lets you log what happens in your application. The simplest usage looks like this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-property">activity</span>()-&gt;<span class="hl-property">log</span>(<span class="hl-value">'Look mum, I logged something'</span>);
</pre>
<p>You can retrieve all logged activities using the <code>Activity</code> model:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Activitylog\Models\Activity</span>;

<span class="hl-variable">$lastActivity</span> = <span class="hl-type">Activity</span>::<span class="hl-property">all</span>()-&gt;<span class="hl-property">last</span>();

<span class="hl-comment">// returns 'Look mum, I logged something'</span>
<span class="hl-variable">$lastActivity</span>-&gt;<span class="hl-property">description</span>;
</pre>
<p>Most of the time you want to know two things: what was affected, and who did it. The package calls these the subject and the causer. You can also attach custom properties for any extra context you need:</p>
<pre data-lang="php" class="notranslate"><span class="hl-property">activity</span>()
   -&gt;<span class="hl-property">performedOn</span>(<span class="hl-variable">$article</span>)
   -&gt;<span class="hl-property">causedBy</span>(<span class="hl-variable">$user</span>)
   -&gt;<span class="hl-property">withProperties</span>([<span class="hl-value">'via'</span> =&gt; <span class="hl-value">'admin-panel'</span>])
   -&gt;<span class="hl-property">log</span>(<span class="hl-value">'edited'</span>);

<span class="hl-variable">$lastActivity</span> = <span class="hl-type">Activity</span>::<span class="hl-property">all</span>()-&gt;<span class="hl-property">last</span>();

<span class="hl-comment">// the article that was edited</span>
<span class="hl-variable">$lastActivity</span>-&gt;<span class="hl-property">subject</span>;

<span class="hl-comment">// the user who edited it</span>
<span class="hl-variable">$lastActivity</span>-&gt;<span class="hl-property">causer</span>;

<span class="hl-comment">// 'admin-panel'</span>
<span class="hl-variable">$lastActivity</span>-&gt;<span class="hl-property">getProperty</span>(<span class="hl-value">'via'</span>);
</pre>
<p>The <code>Activity</code> model provides query scopes to filter your activity log:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Activity</span>::<span class="hl-property">forSubject</span>(<span class="hl-variable">$newsItem</span>)-&gt;<span class="hl-property">get</span>();
<span class="hl-type">Activity</span>::<span class="hl-property">causedBy</span>(<span class="hl-variable">$user</span>)-&gt;<span class="hl-property">get</span>();
<span class="hl-type">Activity</span>::<span class="hl-property">forEvent</span>(<span class="hl-value">'updated'</span>)-&gt;<span class="hl-property">get</span>();
<span class="hl-type">Activity</span>::<span class="hl-property">inLog</span>(<span class="hl-value">'payment'</span>)-&gt;<span class="hl-property">get</span>();

<span class="hl-comment">// or combine them</span>
<span class="hl-type">Activity</span>::<span class="hl-property">forSubject</span>(<span class="hl-variable">$newsItem</span>)
    -&gt;<span class="hl-property">causedBy</span>(<span class="hl-variable">$user</span>)
    -&gt;<span class="hl-property">forEvent</span>(<span class="hl-value">'updated'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<h3 id="automatic-model-event-logging">Automatic model event logging</h3>
<p>Imagine you want to track whenever a model is created, updated, or deleted. Just add the <code>LogsActivity</code> trait to your model. In v5, that's all you need for basic logging:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Model</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Activitylog\Models\Concerns\LogsActivity</span>;

<span class="hl-keyword">class</span> <span class="hl-type">NewsItem</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">LogsActivity</span>;
}
</pre>
<p>That's it. No <code>getActivitylogOptions()</code> method needed. Now imagine you also want to track which attributes changed. Override the method and tell it what to watch:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Activitylog\Support\LogOptions</span>;

<span class="hl-keyword">class</span> <span class="hl-type">NewsItem</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">LogsActivity</span>;

    <span class="hl-keyword">protected</span> <span class="hl-property">$fillable</span> = [<span class="hl-value">'name'</span>, <span class="hl-value">'text'</span>];

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">getActivitylogOptions</span>(): <span class="hl-type">LogOptions</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-type">LogOptions</span>::<span class="hl-property">defaults</span>()
            -&gt;<span class="hl-property">logOnly</span>([<span class="hl-value">'name'</span>, <span class="hl-value">'text'</span>]);
    }
}
</pre>
<p>Now when you update a news item, the package tracks exactly what changed:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$newsItem</span>-&gt;<span class="hl-property">name</span> = <span class="hl-value">'updated name'</span>;
<span class="hl-variable">$newsItem</span>-&gt;<span class="hl-property">save</span>();

<span class="hl-variable">$activity</span> = <span class="hl-type">Activity</span>::<span class="hl-property">all</span>()-&gt;<span class="hl-property">last</span>();
<span class="hl-variable">$activity</span>-&gt;<span class="hl-property">attribute_changes</span>;
<span class="hl-comment">// [</span>
<span class="hl-comment">//     'attributes' =&gt; [</span>
<span class="hl-comment">//         'name' =&gt; 'updated name',</span>
<span class="hl-comment">//         'text' =&gt; 'Lorem',</span>
<span class="hl-comment">//     ],</span>
<span class="hl-comment">//     'old' =&gt; [</span>
<span class="hl-comment">//         'name' =&gt; 'original name',</span>
<span class="hl-comment">//         'text' =&gt; 'Lorem',</span>
<span class="hl-comment">//     ],</span>
<span class="hl-comment">// ]</span>
</pre>
<p>You can log all fillable attributes with <code>logFillable()</code>, all unguarded attributes with <code>logUnguarded()</code>, or use <code>logAll()</code> combined with <code>logExcept()</code> to log everything except sensitive fields like passwords.</p>
<p>If you only want to see what actually changed rather than all tracked attributes, chain <code>logOnlyDirty()</code>.</p>
<h3 id="running-code-before-an-activity-is-saved">Running code before an activity is saved</h3>
<p>When a model event is logged, you can hook into the process by defining a <code>beforeActivityLogged()</code> method on your model:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">NewsItem</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">LogsActivity</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">beforeActivityLogged</span>(<span class="hl-injection"><span class="hl-type">Activity</span> $activity, <span class="hl-type">string</span> $eventName</span>): <span class="hl-type">void</span>
    {
        <span class="hl-variable">$activity</span>-&gt;<span class="hl-property">properties</span> = <span class="hl-variable">$activity</span>-&gt;<span class="hl-property">properties</span>-&gt;<span class="hl-property">merge</span>([
            <span class="hl-value">'ip_address'</span> =&gt; <span class="hl-property">request</span>()-&gt;<span class="hl-property">ip</span>(),
        ]);
    }
}
</pre>
<p>This runs right before the activity is persisted, giving you a chance to enrich it with extra data.</p>
<h3 id="customizable-action-classes">Customizable action classes</h3>
<p>The core operations of the package (logging activities and cleaning old records) are now handled by action classes. You can extend these and swap them in via config.</p>
<p>For example, say you want to save activities to the queue instead of writing them to the database during the request. Extend the action and override the <code>save()</code> method:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Activitylog\Actions\LogActivityAction</span>;

<span class="hl-keyword">class</span> <span class="hl-type">QueuedLogAction</span> <span class="hl-keyword">extends</span> <span class="hl-type">LogActivityAction</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">save</span>(<span class="hl-injection"><span class="hl-type">Model</span> $activity</span>): <span class="hl-type">void</span>
    {
        <span class="hl-property">dispatch</span>(<span class="hl-keyword">new</span> <span class="hl-type">SaveActivityJob</span>(<span class="hl-variable">$activity</span>-&gt;<span class="hl-property">toArray</span>()));
    }
}
</pre>
<p>Then tell the package to use your custom action in <code>config/activitylog.php</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-value">'actions'</span> =&gt; [
    <span class="hl-value">'log_activity'</span> =&gt; <span class="hl-type">QueuedLogAction</span>::<span class="hl-keyword">class</span>,
],
</pre>
<p>You can also override <code>transformChanges()</code> to manipulate the changes array before saving. Here's an example that redacts password changes so they never end up in your activity log:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Model</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\Arr</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Activitylog\Actions\LogActivityAction</span>;

<span class="hl-keyword">class</span> <span class="hl-type">RedactSensitiveFieldsAction</span> <span class="hl-keyword">extends</span> <span class="hl-type">LogActivityAction</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">transformChanges</span>(<span class="hl-injection"><span class="hl-type">Model</span> $activity</span>): <span class="hl-type">void</span>
    {
        <span class="hl-variable">$changes</span> = <span class="hl-variable">$activity</span>-&gt;<span class="hl-property">attribute_changes</span>?-&gt;<span class="hl-property">toArray</span>() ?? [];

        <span class="hl-type">Arr</span>::<span class="hl-property">forget</span>(<span class="hl-variable">$changes</span>, [<span class="hl-value">'attributes.password'</span>, <span class="hl-value">'old.password'</span>]);

        <span class="hl-variable">$activity</span>-&gt;<span class="hl-property">attribute_changes</span> = <span class="hl-property">collect</span>(<span class="hl-variable">$changes</span>);
    }
}
</pre>
<h3 id="buffering-activities">Buffering activities</h3>
<p>Say you have an endpoint that updates product prices in bulk. Each product update triggers a model event, and each model event logs an activity. With 200 products, that's 200 <code>INSERT</code> queries just for activity logging.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">foreach</span> (<span class="hl-variable">$products</span> <span class="hl-keyword">as</span> <span class="hl-variable">$product</span>) {
    <span class="hl-comment">// each update triggers an activity INSERT query</span>
    <span class="hl-variable">$product</span>-&gt;<span class="hl-property">update</span>([<span class="hl-value">'price'</span> =&gt; <span class="hl-variable">$newPrices</span>[<span class="hl-variable">$product</span>-&gt;<span class="hl-property">id</span>]]);
}
</pre>
<p>With buffering enabled, the package collects all those activities in memory during the request and inserts them in a single bulk query after the response has been sent to the client. Your user gets a fast response, and the database does one insert instead of 200.</p>
<p>Buffering is off by default. You can enable it in the <code>config/activitylog.php</code> config file. No other code changes needed. All existing logging code (both automatic model event logging and manual <code>activity()-&gt;log()</code> calls) will be buffered automatically.</p>
<p>Under the hood, the <code>ActivityBuffer</code> class collects activities in an array and inserts them all at once when flushed:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">ActivityBuffer</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-type">array</span> <span class="hl-property">$pending</span> = [];

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">add</span>(<span class="hl-injection"><span class="hl-type">Model</span> $activity</span>): <span class="hl-type">void</span>
    {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">pending</span>[] = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">prepareForInsert</span>(<span class="hl-variable">$activity</span>);
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">flush</span>(): <span class="hl-type">void</span>
    {
        <span class="hl-keyword">if</span> (<span class="hl-keyword">empty</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">pending</span>)) {
            <span class="hl-keyword">return</span>;
        }

        <span class="hl-variable">$modelClass</span> = <span class="hl-type">Config</span>::<span class="hl-property">activityModel</span>();
        <span class="hl-variable">$modelClass</span>::<span class="hl-property">query</span>()-&gt;<span class="hl-property">insert</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">pending</span>);

        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">pending</span> = [];
    }
}
</pre>
<p>The service provider takes care of flushing the buffer at the right time:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">registerActivityBufferFlushing</span>(): <span class="hl-type">void</span>
{
    <span class="hl-comment">// flush after the response has been sent</span>
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">app</span>-&gt;<span class="hl-property">terminating</span>(<span class="hl-keyword">fn</span> () =&gt; <span class="hl-property">app</span>(<span class="hl-type">ActivityBuffer</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">flush</span>());

    <span class="hl-comment">// flush after each queued job</span>
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">app</span>[<span class="hl-value">'events'</span>]-&gt;<span class="hl-property">listen</span>(
        [<span class="hl-type">JobProcessed</span>::<span class="hl-keyword">class</span>, <span class="hl-type">JobFailed</span>::<span class="hl-keyword">class</span>],
        <span class="hl-keyword">fn</span> () =&gt; <span class="hl-property">app</span>(<span class="hl-type">ActivityBuffer</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">flush</span>(),
    );

    <span class="hl-comment">// safety net if the application terminates unexpectedly</span>
    <span class="hl-property">register_shutdown_function</span>(<span class="hl-injection">function (</span>) {
        <span class="hl-keyword">try</span> {
            <span class="hl-property">app</span>(<span class="hl-type">ActivityBuffer</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">flush</span>();
        } <span class="hl-keyword">catch</span> (\Throwable) {
        }
    });
}
</pre>
<p>One thing to be aware of: buffered activities won't have a database ID until the buffer is flushed. If you need to read back the activity ID immediately after logging, don't enable buffering.</p>
<p>This works with Octane (the buffer is a scoped binding, so it resets between requests) and queues out of the box.</p>
<h2 id="in-closing">In closing</h2>
<p>v5 doesn't bring a lot of new features, but it modernizes the package, cleans up the internals, and makes the things that were hard to customize in v4 easy to swap out. If you're upgrading from v4, be aware that there are quite a few breaking changes. Check the <a href="https://github.com/spatie/laravel-activitylog/blob/v5/UPGRADING.md">upgrade guide</a> for the full list.</p>
<p>You can find the complete documentation at <a href="https://spatie.be/docs/laravel-activitylog">spatie.be/docs/laravel-activitylog</a> and the source code <a href="https://github.com/spatie/laravel-activitylog">on GitHub</a>.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be/open-source">Spatie</a>. If you want to support our open source work, consider picking up one of our <a href="https://spatie.be/products">paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-25T11:39:31+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Laravel Query Builder v7: a must-have package for building APIs in Laravel]]></title>
            <link rel="alternate" href="https://freek.dev/3052-laravel-query-builder-v7-a-must-have-package-for-building-apis-in-laravel" />
            <id>https://freek.dev/3052</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released v7 of <a href="https://github.com/spatie/laravel-query-builder">spatie/laravel-query-builder</a>, our package that makes it easy to build flexible API endpoints. If you're building an API with Laravel, you'll almost certainly need to let consumers filter results, sort them, include relationships and select specific fields. Writing that logic by hand for every endpoint gets repetitive fast, and it's easy to accidentally expose columns or relationships you didn't intend to.</p>
<p>Our query builder takes care of all of that. It reads query parameters from the URL, translates them into the right Eloquent queries, and makes sure only the things you've explicitly allowed can be queried.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// GET /users?filter[name]=John&amp;include=posts&amp;sort=-created_at</span>

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(<span class="hl-value">'posts'</span>)
    -&gt;<span class="hl-property">allowedSorts</span>(<span class="hl-value">'created_at'</span>)
    -&gt;<span class="hl-property">get</span>();

<span class="hl-comment">// select * from users where name = 'John' order by created_at desc</span>
</pre>
<p>This major version requires PHP 8.3+ and Laravel 12 or higher, and brings a cleaner API along with some features we've been wanting to add for a while.</p>
<p>Let me walk you through how the package works and what's new.</p>
<!--more-->
<h2 id="using-the-package">Using the package</h2>
<p>The idea is simple: your API consumers pass query parameters in the URL, and the package translates those into the right Eloquent query. You just define what's allowed.</p>
<p>Say you have a <code>User</code> model and you want to let API consumers filter by name. Here's all you need:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\QueryBuilder\QueryBuilder</span>;

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>Now when someone requests <code>/users?filter[name]=John</code>, the package adds the appropriate <code>WHERE</code> clause to the query:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">select</span> * <span class="hl-keyword">from</span> <span class="hl-type">users</span> <span class="hl-keyword">where</span> name = '<span class="hl-value">John</span>'
</pre>
<p>Only the filters you've explicitly allowed will work. If someone tries <code>/users?filter[secret_column]=something</code>, the package throws an <code>InvalidFilterQuery</code> exception. Your database schema stays hidden from API consumers.</p>
<p>You can allow multiple filters at once and combine them with sorting:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>)
    -&gt;<span class="hl-property">allowedSorts</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'created_at'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>A request to <code>/users?filter[name]=John&amp;sort=-created_at</code> now filters by name and sorts by <code>created_at</code> descending (the <code>-</code> prefix means descending).</p>
<p>Including relationships works the same way. If you want consumers to be able to eager-load a user's posts:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(<span class="hl-value">'posts'</span>, <span class="hl-value">'permissions'</span>)
    -&gt;<span class="hl-property">allowedSorts</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'created_at'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>A request to <code>/users?include=posts&amp;filter[name]=John&amp;sort=-created_at</code> now returns users named John, sorted by creation date, with their posts eager-loaded.</p>
<p>You can also select specific fields to keep your responses lean:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFields</span>(<span class="hl-value">'id'</span>, <span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(<span class="hl-value">'posts'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>With <code>/users?fields=id,email&amp;include=posts</code>, only the <code>id</code> and <code>email</code> columns are selected.</p>
<p>The <code>QueryBuilder</code> extends Laravel's default Eloquent builder, so all your favorite methods still work. You can combine it with existing queries:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$query</span> = <span class="hl-type">User</span>::<span class="hl-property">where</span>(<span class="hl-value">'active'</span>, <span class="hl-keyword">true</span>);

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-variable">$query</span>)
    -&gt;<span class="hl-property">withTrashed</span>()
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(<span class="hl-value">'posts'</span>, <span class="hl-value">'permissions'</span>)
    -&gt;<span class="hl-property">where</span>(<span class="hl-value">'score'</span>, <span class="hl-value">'&gt;'</span>, 42)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>The query parameter names follow the <a href="http://jsonapi.org/">JSON API specification</a> as closely as possible. This means you get a consistent, well-documented API surface without having to think about naming conventions.</p>
<h2 id="whats-new-in-v7">What's new in v7</h2>
<h3 id="variadic-parameters">Variadic parameters</h3>
<p>All the <code>allowed*</code> methods now accept variadic arguments instead of arrays.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// Before (v6)</span>
<span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>([<span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>])
    -&gt;<span class="hl-property">allowedSorts</span>([<span class="hl-value">'name'</span>])
    -&gt;<span class="hl-property">allowedIncludes</span>([<span class="hl-value">'posts'</span>]);

<span class="hl-comment">// After (v7)</span>
<span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedFilters</span>(<span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>)
    -&gt;<span class="hl-property">allowedSorts</span>(<span class="hl-value">'name'</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(<span class="hl-value">'posts'</span>);
</pre>
<p>If you have a dynamic list, use the spread operator:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$filters</span> = [<span class="hl-value">'name'</span>, <span class="hl-value">'email'</span>];
<span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">allowedFilters</span>(...<span class="hl-variable">$filters</span>);
</pre>
<h3 id="aggregate-includes">Aggregate includes</h3>
<p>This is the biggest new feature. You can now include aggregate values for related models using <code>AllowedInclude::min()</code>, <code>AllowedInclude::max()</code>, <code>AllowedInclude::sum()</code>, and <code>AllowedInclude::avg()</code>. Under the hood, these map to Laravel's <code>withMin()</code>, <code>withMax()</code>, <code>withSum()</code> and <code>withAvg()</code> methods.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\QueryBuilder\AllowedInclude</span>;

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(
        <span class="hl-value">'posts'</span>,
        <span class="hl-type">AllowedInclude</span>::<span class="hl-property">count</span>(<span class="hl-value">'postsCount'</span>),
        <span class="hl-type">AllowedInclude</span>::<span class="hl-property">sum</span>(<span class="hl-value">'postsViewsSum'</span>, <span class="hl-value">'posts'</span>, <span class="hl-value">'views'</span>),
        <span class="hl-type">AllowedInclude</span>::<span class="hl-property">avg</span>(<span class="hl-value">'postsViewsAvg'</span>, <span class="hl-value">'posts'</span>, <span class="hl-value">'views'</span>),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>A request to <code>/users?include=posts,postsCount,postsViewsSum</code> now returns users with their posts, the post count, and the total views across all posts.</p>
<p>You can constrain these aggregates too. For example, to only count published posts:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\QueryBuilder\AllowedInclude</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Builder</span>;

<span class="hl-variable">$users</span> = <span class="hl-type">QueryBuilder</span>::<span class="hl-keyword">for</span>(<span class="hl-type">User</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">allowedIncludes</span>(
        <span class="hl-type">AllowedInclude</span>::<span class="hl-property">count</span>(
            <span class="hl-value">'publishedPostsCount'</span>,
            <span class="hl-value">'posts'</span>,
            <span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Builder</span> $query</span>) =&gt; <span class="hl-variable">$query</span>-&gt;<span class="hl-property">where</span>(<span class="hl-value">'published'</span>, <span class="hl-keyword">true</span>)
        ),
        <span class="hl-type">AllowedInclude</span>::<span class="hl-property">sum</span>(
            <span class="hl-value">'publishedPostsViewsSum'</span>,
            <span class="hl-value">'posts'</span>,
            <span class="hl-value">'views'</span>,
            <span class="hl-property">constraint</span>: <span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Builder</span> $query</span>) =&gt; <span class="hl-variable">$query</span>-&gt;<span class="hl-property">where</span>(<span class="hl-value">'published'</span>, <span class="hl-keyword">true</span>)
        ),
    )
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>All four aggregate types support these constraint closures, making it possible to build endpoints that return computed data alongside your models without writing custom query logic.</p>
<h2 id="a-perfect-match-for-laravels-jsonapi-resources">A perfect match for Laravel's JSON:API resources</h2>
<p>Laravel 13 is adding built-in support for <a href="https://laravel.com/docs/master/eloquent-resources#jsonapi-resources">JSON:API resources</a>. These new <code>JsonApiResource</code> classes handle the serialization side: they produce responses compliant with the JSON:API specification.</p>
<p>You create one by adding the <code>--json-api</code> flag:</p>
<pre data-lang="txt" class="notranslate">php artisan make:resource PostResource --json-api
</pre>
<p>This generates a resource class where you define attributes and relationships:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Http\Resources\JsonApi\JsonApiResource</span>;

<span class="hl-keyword">class</span> <span class="hl-type">PostResource</span> <span class="hl-keyword">extends</span> <span class="hl-type">JsonApiResource</span>
{
    <span class="hl-keyword">public</span> <span class="hl-property">$attributes</span> = [
        <span class="hl-value">'title'</span>,
        <span class="hl-value">'body'</span>,
        <span class="hl-value">'created_at'</span>,
    ];

    <span class="hl-keyword">public</span> <span class="hl-property">$relationships</span> = [
        <span class="hl-value">'author'</span>,
        <span class="hl-value">'comments'</span>,
    ];
}
</pre>
<p>Return it from your controller, and Laravel produces a fully compliant JSON:API response:</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
    <span class="hl-keyword">&quot;data&quot;</span>: <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;id&quot;</span>: <span class="hl-value">&quot;1&quot;</span>,
        <span class="hl-keyword">&quot;type&quot;</span>: <span class="hl-value">&quot;posts&quot;</span>,
        <span class="hl-keyword">&quot;attributes&quot;</span>: <span class="hl-property">{</span>
            <span class="hl-keyword">&quot;title&quot;</span>: <span class="hl-value">&quot;Hello World&quot;</span>,
            <span class="hl-keyword">&quot;body&quot;</span>: <span class="hl-value">&quot;This is my first post.&quot;</span>
        <span class="hl-property">}</span>,
        <span class="hl-keyword">&quot;relationships&quot;</span>: <span class="hl-property">{</span>
            <span class="hl-keyword">&quot;author&quot;</span>: <span class="hl-property">{</span>
                <span class="hl-keyword">&quot;data&quot;</span>: <span class="hl-property">{</span> <span class="hl-keyword">&quot;id&quot;</span>: <span class="hl-value">&quot;1&quot;</span>, <span class="hl-keyword">&quot;type&quot;</span>: <span class="hl-value">&quot;users&quot;</span> <span class="hl-property">}</span>
            <span class="hl-property">}</span>
        <span class="hl-property">}</span>
    <span class="hl-property">}</span>,
    <span class="hl-keyword">&quot;included&quot;</span>: <span class="hl-property">[</span>
        <span class="hl-property">{</span>
            <span class="hl-keyword">&quot;id&quot;</span>: <span class="hl-value">&quot;1&quot;</span>,
            <span class="hl-keyword">&quot;type&quot;</span>: <span class="hl-value">&quot;users&quot;</span>,
            <span class="hl-keyword">&quot;attributes&quot;</span>: <span class="hl-property">{</span> <span class="hl-keyword">&quot;name&quot;</span>: <span class="hl-value">&quot;Taylor Otwell&quot;</span> <span class="hl-property">}</span>
        <span class="hl-property">}</span>
    <span class="hl-property">]</span>
<span class="hl-property">}</span>
</pre>
<p>Clients can request specific fields and includes via query parameters like <code>/api/posts?fields[posts]=title&amp;include=author</code>. Laravel's JSON:API resources handle all of that on the response side.</p>
<p>The <a href="https://laravel.com/docs/master/eloquent-resources#jsonapi-resources">Laravel docs</a> explicitly mention our package as a companion:</p>
<blockquote>
<p>Laravel's JSON:API resources handle the serialization of your responses. If you also need to parse incoming JSON:API query parameters such as filters and sorts, Spatie's Laravel Query Builder is a great companion package.</p>
</blockquote>
<p>So while Laravel's new JSON:API resources take care of the output format, our query builder handles the input side: parsing <code>filter</code>, <code>sort</code>, <code>include</code> and <code>fields</code> parameters from the request and translating them into the right Eloquent queries. Together they give you a full JSON:API implementation with very little boilerplate.</p>
<h2 id="in-closing">In closing</h2>
<p>To upgrade from v6, check the <a href="https://github.com/spatie/laravel-query-builder/blob/main/UPGRADING.md">upgrade guide</a>. The changes are mostly mechanical. Check the guide for the full list.</p>
<p>You can find the full source code and documentation <a href="https://github.com/spatie/laravel-query-builder">on GitHub</a>. We also have extensive <a href="https://spatie.be/docs/laravel-query-builder/v7/introduction">documentation</a> on the Spatie website.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be/open-source">Spatie</a>. If you want to support our open source work, consider picking up one of our <a href="https://spatie.be/products">paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-16T14:57:04+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Laravel Site Search v3 is here: crawl and search your entire site using just your database]]></title>
            <link rel="alternate" href="https://freek.dev/3040-laravel-site-search-v3-is-here-crawl-and-search-your-entire-site-using-just-your-database" />
            <id>https://freek.dev/3040</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We're proud to release v3 of <a href="https://spatie.be/docs/laravel-site-search">laravel-site-search</a>, a package that crawls and indexes your entire site. Think of it as your own private Google. Point it at a URL, let it crawl every page, and get full-text search results back.</p>
<p>Previous versions required Meilisearch as the search engine. That works well, but it means running a separate service.</p>
<p>With v3, your application's own database is all you need. It supports SQLite, MySQL, PostgreSQL, and MariaDB out of the box, and it's the new default.</p>
<p>Let me walk you through it.</p>
<!--more-->
<h2 id="using-laravel-site-search">Using Laravel Site Search</h2>
<p>First, install the package via composer:</p>
<pre data-lang="txt" class="notranslate">composer require spatie/laravel-site-search
</pre>
<p>Next, you should publish and run the migrations:</p>
<pre data-lang="txt" class="notranslate">php artisan vendor:publish --tag=&quot;site-search-migrations&quot;
php artisan migrate
</pre>
<p>Then, create a search profile. The package provides an artisan command for this:</p>
<pre data-lang="txt" class="notranslate">php artisan site-search:create-profile
</pre>
<p>This will ask you for the name and URL of the site you want to index. Once configured, crawl the site:</p>
<pre data-lang="txt" class="notranslate">php artisan site-search:crawl
</pre>
<p>That's it. No Meilisearch to install, no external service to configure. The package uses your existing database. Under the hood, the crawling is powered by our own <a href="https://github.com/spatie/crawler">spatie/crawler</a> package, which <a href="https://freek.dev/3041-laravel-sitemap-v8-is-here-automatic-splitting-xsl-stylesheets-and-crawler-v9">recently got a major update</a> as well.</p>
<p>The <code>Search</code> class provides a fluent API for querying your index:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\SiteSearch\Search</span>;

<span class="hl-variable">$searchResults</span> = <span class="hl-type">Search</span>::<span class="hl-property">onIndex</span>(<span class="hl-value">'my-site'</span>)
    -&gt;<span class="hl-property">query</span>(<span class="hl-value">'laravel middleware'</span>)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>You can limit the number of results:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$searchResults</span> = <span class="hl-type">Search</span>::<span class="hl-property">onIndex</span>(<span class="hl-value">'my-site'</span>)
    -&gt;<span class="hl-property">query</span>(<span class="hl-value">'laravel middleware'</span>)
    -&gt;<span class="hl-property">limit</span>(10)
    -&gt;<span class="hl-property">get</span>();
</pre>
<p>Or paginate them, which integrates with Laravel's built-in pagination:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$searchResults</span> = <span class="hl-type">Search</span>::<span class="hl-property">onIndex</span>(<span class="hl-value">'my-site'</span>)
    -&gt;<span class="hl-property">query</span>(<span class="hl-value">'laravel middleware'</span>)
    -&gt;<span class="hl-property">paginate</span>(20);
</pre>
<p>Each result is a <code>Hit</code> object with properties like <code>url</code>, <code>pageTitle</code>, <code>entry</code>, and <code>description</code>. You can also get a highlighted snippet where matching terms are wrapped in <code>&lt;em&gt;</code> tags:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">foreach</span> (<span class="hl-variable">$searchResults</span>-&gt;<span class="hl-property">hits</span> <span class="hl-keyword">as</span> <span class="hl-variable">$hit</span>) {
    <span class="hl-keyword">echo</span> <span class="hl-variable">$hit</span>-&gt;<span class="hl-property">url</span>;
    <span class="hl-keyword">echo</span> <span class="hl-variable">$hit</span>-&gt;<span class="hl-property">title</span>();
    <span class="hl-keyword">echo</span> <span class="hl-variable">$hit</span>-&gt;<span class="hl-property">highlightedSnippet</span>();
}
</pre>
<h2 id="the-database-driver">The database driver</h2>
<p>The biggest addition in v3 is the <code>DatabaseDriver</code>. Instead of relying on Meilisearch, it stores all indexed documents in a <code>site_search_documents</code> table in your own database and uses each database engine's native full-text search capabilities.</p>
<p>For SQLite, it creates FTS5 virtual tables with porter stemming and unicode support. BM25 is used for relevance ranking, and highlighted snippets are generated natively by FTS5.</p>
<p>For MySQL and MariaDB, it sets up FULLTEXT indexes and uses boolean mode search. Highlighting is handled in PHP after the query.</p>
<p>For PostgreSQL, the driver creates tsvector columns with GIN indexes. It uses <code>ts_rank()</code> for relevance scoring and <code>ts_headline()</code> for generating highlighted snippets. Content is weighted so that matches in headings rank higher than matches in the body text.</p>
<p>All supported databases provide stemming (so &quot;running&quot; matches &quot;run&quot;), prefix matching (&quot;auth&quot; matches &quot;authentication&quot;), relevance ranking, and highlighted search results with matching terms wrapped in <code>&lt;em&gt;</code> tags.</p>
<p>Since the database driver is the new default, getting started requires no configuration beyond what you already have. Install the package, run the migration, set up a search profile, and you're ready to crawl.</p>
<p>Meilisearch is still supported. If you're already using Meilisearch or need its advanced features like synonyms and custom ranking rules, nothing changes for you. The Meilisearch driver is still fully supported. You can switch between drivers by setting the <code>default_driver</code> in the config or per search profile via the <code>driver_class</code> attribute.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// config/site-search.php</span>

<span class="hl-value">'default_driver'</span> =&gt; <span class="hl-type">\Spatie\SiteSearch\Drivers\MeiliSearchDriver</span>::<span class="hl-keyword">class</span>,
</pre>
<h2 id="we-use-it-ourselves">We use it ourselves</h2>
<p>We're using laravel-site-search in production on several of our own sites. The search on <a href="https://freek.dev/search">freek.dev</a>, the documentation search on <a href="https://ohdear.app/docs?utm_source=freek.dev&amp;utm_medium=referral&amp;utm_campaign=blogpost-3040-laravel-site-search-v3-is-here-crawl-and-search-your-entire-site-using-just-your-database">Oh Dear</a>, and the documentation search on <a href="https://mailcoach.app/self-hosted/documentation">Mailcoach</a> are all powered by this package.</p>
<p><img src="https://fls-a2e13fcd-3430-450d-96b4-4902fda0a655.laravel.cloud/admin-uploads/sTTvhmqjDnQbrtVUvZkQRqKiRhtgvLtumdc3MVvn.jpg" alt="" /></p>
<p>The source code of freek.dev is <a href="https://github.com/spatie/freek.dev">open source</a>, so you can see exactly how the package is integrated. The <a href="https://github.com/spatie/freek.dev/blob/main/resources/views/livewire/%E2%9A%A1search.blade.php">Livewire search component</a> handles querying and displaying results. The <a href="https://github.com/spatie/freek.dev/blob/main/app/Services/Search/SearchProfile.php">search profile</a> configures the crawler and determines which pages should be indexed. A custom <a href="https://github.com/spatie/freek.dev/blob/main/app/Services/Search/Indexer.php">indexer</a> cleans up page titles before storing them. And the <a href="https://github.com/spatie/freek.dev/blob/main/config/site-search.php">config file</a> ties it all together, specifying which CSS selectors and URLs to ignore during indexing.</p>
<h2 id="in-closing">In closing</h2>
<p>With v3, laravel-site-search no longer requires any external dependencies. Install it, point it at your site, and search. If you're using MySQL, PostgreSQL, SQLite, or MariaDB, you already have everything you need.</p>
<p>You can find the full documentation on <a href="https://spatie.be/docs/laravel-site-search">the documentation site</a>. The source code is available <a href="https://github.com/spatie/laravel-site-search">on GitHub</a>.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be">Spatie</a>. 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-03-12T13:52:50+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ How to easily access private properties and methods in PHP]]></title>
            <link rel="alternate" href="https://freek.dev/3048-how-to-easily-access-private-properties-and-methods-in-php" />
            <id>https://freek.dev/3048</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Sometimes you need to access a private property or method on an object that isn't yours. Maybe you're writing a test and need to assert some internal state. Maybe you're building a package that needs to reach into another object's internals. Whatever the reason, PHP's visibility rules are standing in your way.</p>
<p>Our <a href="https://github.com/spatie/invade">spatie/invade</a> package provides a tiny <code>invade</code> function that lets you read, write, and call private members on any object.</p>
<p>You probably shouldn't reach for this package often. It's most useful in tests or when you're building a package that needs to integrate deeply with objects you don't control.</p>
<p>Let me walk you through how it works.</p>
<!--more-->
<h2 id="using-invade">Using invade</h2>
<p>Imagine you have a class with private members:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">MyClass</span>
{
    <span class="hl-keyword">private</span> <span class="hl-type">string</span> <span class="hl-property">$privateProperty</span> = <span class="hl-value">'private value'</span>;

    <span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">privateMethod</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-value">'private return value'</span>;
    }
}
</pre>
<p>If you try to access that private property from outside the class, PHP will stop you:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$myClass</span> = <span class="hl-keyword">new</span> <span class="hl-type">MyClass</span>();

<span class="hl-variable">$myClass</span>-&gt;<span class="hl-property">privateProperty</span>;
<span class="hl-comment">// Error: Cannot access private property MyClass::$privateProperty</span>
</pre>
<p>With <code>invade</code>, you can get around that. Install the package via composer:</p>
<pre data-lang="txt" class="notranslate">composer require spatie/invade
</pre>
<p>Now you can read that private property:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// returns 'private value'</span>
<span class="hl-property">invade</span>(<span class="hl-variable">$myClass</span>)-&gt;<span class="hl-property">privateProperty</span>;
</pre>
<p>You can set it too:</p>
<pre data-lang="php" class="notranslate"><span class="hl-property">invade</span>(<span class="hl-variable">$myClass</span>)-&gt;<span class="hl-property">privateProperty</span> = <span class="hl-value">'changed value'</span>;

<span class="hl-comment">// returns 'changed value'</span>
<span class="hl-property">invade</span>(<span class="hl-variable">$myClass</span>)-&gt;<span class="hl-property">privateProperty</span>;
</pre>
<p>And you can call private methods:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// returns 'private return value'</span>
<span class="hl-property">invade</span>(<span class="hl-variable">$myClass</span>)-&gt;<span class="hl-property">privateMethod</span>();
</pre>
<p>The API is clean and reads well. But the interesting part is what happens under the hood. Before we look at the package code, there's a PHP rule you need to know about first.</p>
<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>
<p>Let me walk you through how the package works internally. We'll first look at the old approach using reflection, and then the current solution that uses closure binding.</p>
<h3 id="the-first-version-reflection">The first version: reflection</h3>
<p>In v1 of the package, we used PHP's Reflection API to access private members. Here's what the <code>Invader</code> class <a href="https://github.com/spatie/invade/blob/1.1.1/src/Invader.php">looked like</a>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Invader</span>
{
    <span class="hl-keyword">public</span> <span class="hl-type">object</span> <span class="hl-property">$obj</span>;
    <span class="hl-keyword">public</span> <span class="hl-type">ReflectionClass</span> <span class="hl-property">$reflected</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-type">object</span> $obj</span>)
    {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span> = <span class="hl-variable">$obj</span>;
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">reflected</span> = <span class="hl-keyword">new</span> <span class="hl-type">ReflectionClass</span>(<span class="hl-variable">$obj</span>);
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__get</span>(<span class="hl-injection"><span class="hl-type">string</span> $name</span>): <span class="hl-type">mixed</span>
    {
        <span class="hl-variable">$property</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">reflected</span>-&gt;<span class="hl-property">getProperty</span>(<span class="hl-variable">$name</span>);
        <span class="hl-variable">$property</span>-&gt;<span class="hl-property">setAccessible</span>(<span class="hl-keyword">true</span>);

        <span class="hl-keyword">return</span> <span class="hl-variable">$property</span>-&gt;<span class="hl-property">getValue</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
    }
}
</pre>
<p>When you create an <code>Invader</code>, it wraps your object and creates a <code>ReflectionClass</code> for it. When you try to access a property like <code>invade($myClass)-&gt;privateProperty</code>, PHP triggers the <code>__get</code> magic method. It uses the reflection instance to find the property by name, calls <code>setAccessible(true)</code> on it, and then reads the value from the original object. The <code>setAccessible(true)</code> call tells PHP to skip the visibility check for that reflected property. Without it, trying to read a private property through reflection would throw an error, just like accessing it directly.</p>
<p>This worked fine, but it required creating a <code>ReflectionClass</code> instance and calling <code>setAccessible(true)</code> on every property or method you wanted to access. In v2, we replaced all of this with a much simpler approach using closures. To understand how, we first need to look at a lesser-known PHP visibility rule.</p>
<h3 id="private-visibility-is-scoped-to-the-class">Private visibility is scoped to the class</h3>
<p>In PHP, <code>private</code> visibility is scoped to the class, not to a specific object instance. Any code running inside a class can access the private properties and methods of any instance of that class.</p>
<p>Here's a concrete example:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Wallet</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">private</span> <span class="hl-type">int</span> <span class="hl-property">$balance</span>
    </span>) {
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">hasMoreThan</span>(<span class="hl-injection"><span class="hl-type">Wallet</span> $other</span>): <span class="hl-type">bool</span>
    {
        <span class="hl-comment">// This works: we can read $other's private $balance</span>
        <span class="hl-comment">// because we're inside the Wallet class scope</span>
        <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">balance</span> &gt; <span class="hl-variable">$other</span>-&gt;<span class="hl-property">balance</span>;
    }
}

<span class="hl-variable">$mine</span> = <span class="hl-keyword">new</span> <span class="hl-type">Wallet</span>(100);
<span class="hl-variable">$yours</span> = <span class="hl-keyword">new</span> <span class="hl-type">Wallet</span>(50);

<span class="hl-comment">// returns true</span>
<span class="hl-variable">$mine</span>-&gt;<span class="hl-property">hasMoreThan</span>(<span class="hl-variable">$yours</span>);
</pre>
<p>Notice how <code>hasMoreThan</code> reads <code>$other-&gt;balance</code> directly, even though <code>$balance</code> is private. This compiles and runs without errors because the code is running inside the <code>Wallet</code> class. PHP doesn't care which instance the property belongs to. As long as you're in the right class scope, all private members of all instances of that class are accessible.</p>
<p>This is the foundation that makes v2 of the <code>invade</code> package work. If you can get your code to run inside the scope of the target class, you get access to its private members. PHP closures give us a way to do exactly that.</p>
<h3 id="how-closures-can-change-their-scope">How closures can change their scope</h3>
<p>PHP closures carry the scope of the class they were defined in. But the <code>Closure::call()</code> method lets you change that. It temporarily rebinds <code>$this</code> inside the closure to a different object, and it also changes the scope to the class of that object.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$readBalance</span> = <span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">balance</span>;

<span class="hl-variable">$wallet</span> = <span class="hl-keyword">new</span> <span class="hl-type">Wallet</span>(100);
<span class="hl-comment">// returns 100</span>
<span class="hl-variable">$readBalance</span>-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$wallet</span>);
</pre>
<p>Even though <code>$balance</code> is private, this works. The <code>-&gt;call($wallet)</code> method binds the closure to the <code>$wallet</code> object and puts it in the <code>Wallet</code> class scope. When PHP evaluates <code>$this-&gt;balance</code>, it sees that the code is running in the scope of <code>Wallet</code>, so it allows the access.</p>
<p>This is the entire trick that <code>invade</code> v2 is built on. Now let's look at the actual code.</p>
<h3 id="the-current-invader-class">The current Invader class</h3>
<p>When you call <code>invade($object)</code>, it returns an <code>Invader</code> instance that wraps your object. The <a href="https://github.com/spatie/invade/blob/main/src/Invader.php">current version</a> of the <code>Invader</code> class is surprisingly small:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Invader</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">object</span> <span class="hl-property">$obj</span>
    </span>) {
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__get</span>(<span class="hl-injection"><span class="hl-type">string</span> $name</span>): <span class="hl-type">mixed</span>
    {
        <span class="hl-keyword">return</span> (<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;{<span class="hl-variable">$name</span>})-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__set</span>(<span class="hl-injection"><span class="hl-type">string</span> $name, <span class="hl-type">mixed</span> $value</span>): <span class="hl-type">void</span>
    {
        (<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;{<span class="hl-variable">$name</span>} = <span class="hl-variable">$value</span>)-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__call</span>(<span class="hl-injection"><span class="hl-type">string</span> $name, <span class="hl-type">array</span> $params = []</span>): <span class="hl-type">mixed</span>
    {
        <span class="hl-keyword">return</span> (<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;{<span class="hl-variable">$name</span>}(...<span class="hl-variable">$params</span>))-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
    }
}
</pre>
<p>That's the entire class. No reflection, no complex tricks. Just PHP magic methods and closures.</p>
<p>When you write <code>invade($myClass)-&gt;privateProperty</code>, the <a href="https://github.com/spatie/invade/blob/main/src/functions.php"><code>invade</code> function</a> creates a new <code>Invader</code> instance. PHP can't find <code>privateProperty</code> on the <code>Invader</code> class, so it triggers <code>__get('privateProperty')</code>. The <code>__get</code> method creates a short closure <code>fn () =&gt; $this-&gt;{$name}</code> and calls it with <code>-&gt;call($this-&gt;obj)</code>. As we just learned, this binds <code>$this</code> inside the closure to your original object and puts the closure in that object's class scope. PHP then evaluates <code>$this-&gt;privateProperty</code> inside the scope of <code>MyClass</code>, and the private access is allowed.</p>
<p>The <code>__set</code> method uses the same pattern, but assigns a value instead of reading one:</p>
<pre data-lang="php" class="notranslate">(<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;{<span class="hl-variable">$name</span>} = <span class="hl-variable">$value</span>)-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
</pre>
<p>The <code>$value</code> variable is captured from the enclosing scope of the <code>__set</code> method, so it's available inside the closure.</p>
<p>For calling private methods, <code>__call</code> follows the same approach:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">return</span> (<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;{<span class="hl-variable">$name</span>}(...<span class="hl-variable">$params</span>))-&gt;<span class="hl-property">call</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">obj</span>);
</pre>
<p>The closure calls the method by name, spreading the parameters. Since <code>-&gt;call()</code> binds the closure to the target object, PHP sees this as a call from within the class itself, and the private method becomes accessible.</p>
<h2 id="in-closing">In closing</h2>
<p>The <code>invade</code> package is a fun example of how PHP closures and scope binding can bypass visibility restrictions in a clean way. It's a small trick, but understanding why it works teaches you something interesting about how PHP handles class scope and closure binding.</p>
<p>The original idea for the <code>invade</code> function came from <a href="https://twitter.com/calebporzio">Caleb Porzio</a>, who first introduced it as a helper in Livewire to replace a more verbose <code>ObjectPrybar</code> class. We liked the concept so much that we turned it into its own package.</p>
<p>Just remember: use it sparingly. It works great in tests or when you're building a package that needs deep integration with objects you don't control. In your regular project code, you probably don't need invade.</p>
<p>You can find the package <a href="https://github.com/spatie/invade">on GitHub</a>. This is one of the many packages we've created at <a href="https://spatie.be/open-source">Spatie</a>. If you want to support our open source work, consider picking up <a href="https://spatie.be/open-source/support-us">one of our paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-12T11:39:39+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Laravel Backup v10: serializable events, resilient multi-destination backups, and more]]></title>
            <link rel="alternate" href="https://freek.dev/3015-laravel-backup-v10-serializable-events-resilient-multi-destination-backups-and-more" />
            <id>https://freek.dev/3015</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released v10 of <a href="https://spatie.be/docs/laravel-backup">laravel-backup</a>, our package that creates backups of your Laravel app. The backup is a zip file containing all files in the directories you specify, along with a dump of your database. You can store it on any of the filesystems Laravel supports, and you can even back up to multiple disks at once.</p>
<p>We originally created this package after <a href="https://freek.dev/374-today-digitalocean-lost-our-entire-server">DigitalOcean lost one of our servers</a>. That experience taught us the hard way that you should never rely solely on your hosting provider for backups. The package has been actively maintained ever since.</p>
<h2 id="taking-backups">Taking backups</h2>
<p>With the package installed, taking a backup is as simple as running:</p>
<pre data-lang="txt" class="notranslate">php artisan backup:run
</pre>
<p>This creates a zip of your configured files and databases and stores it on your configured disks. You can also back up just the database or just the files:</p>
<pre data-lang="txt" class="notranslate">php artisan backup:run --only-db
php artisan backup:run --only-files
</pre>
<p>Or target a specific disk:</p>
<pre data-lang="txt" class="notranslate">php artisan backup:run --only-to-disk=s3
</pre>
<p>In most setups you'll want to schedule this. In your <code>routes/console.php</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\Facades\Schedule</span>;

<span class="hl-type">Schedule</span>::<span class="hl-property">command</span>(<span class="hl-value">'backup:run'</span>)-&gt;<span class="hl-property">daily</span>()-&gt;<span class="hl-property">at</span>(<span class="hl-value">'01:00'</span>);
</pre>
<h2 id="listing-and-monitoring-backups">Listing and monitoring backups</h2>
<p>To see an overview of all your backups, run:</p>
<pre data-lang="txt" class="notranslate">php artisan backup:list
</pre>
<p>This shows a table with the backup name, disk, date, and size for each backup.</p>
<p>The package also ships with a monitor that checks whether your backups are healthy. A backup is considered unhealthy when it's too old or when the total backup size exceeds a configured threshold.</p>
<pre data-lang="txt" class="notranslate">php artisan backup:monitor
</pre>
<p>You'll typically schedule the monitor to run daily:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Schedule</span>::<span class="hl-property">command</span>(<span class="hl-value">'backup:monitor'</span>)-&gt;<span class="hl-property">daily</span>()-&gt;<span class="hl-property">at</span>(<span class="hl-value">'03:00'</span>);
</pre>
<p>When the monitor detects a problem, it fires an event that triggers notifications. Out of the box the package supports mail, Slack, Discord, and (new in v10) a generic webhook channel.</p>
<h2 id="cleaning-up-old-backups">Cleaning up old backups</h2>
<p>Over time backups pile up. The package includes a cleanup command that removes old backups based on a configurable retention strategy:</p>
<pre data-lang="txt" class="notranslate">php artisan backup:clean
</pre>
<p>The default strategy keeps all backups for a certain number of days, then keeps one daily backup, then one weekly backup, and so on. It will never delete the most recent backup. You'll want to schedule this alongside your backup command:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Schedule</span>::<span class="hl-property">command</span>(<span class="hl-value">'backup:clean'</span>)-&gt;<span class="hl-property">daily</span>()-&gt;<span class="hl-property">at</span>(<span class="hl-value">'02:00'</span>);
</pre>
<h2 id="whats-new-in-v10">What's new in v10</h2>
<p>v10 is mostly about addressing long-standing community requests and cleaning up internals.</p>
<p>The biggest change is that all events now carry primitive data (<code>string $diskName</code>, <code>string $backupName</code>) instead of <code>BackupDestination</code> objects. This means events can now be used with queued listeners, which was previously impossible because those objects weren't serializable. If you have existing listeners, you'll need to update them to use <code>$event-&gt;diskName</code> instead of <code>$event-&gt;backupDestination-&gt;diskName()</code>.</p>
<p>Events and notifications are now decoupled. Events always fire, even when <code>--disable-notifications</code> is used. This fixes an issue where <code>BackupWasSuccessful</code> never fired when notifications were disabled, which also broke encryption since it depends on the <code>BackupZipWasCreated</code> event.</p>
<p>There's a new <code>continue_on_failure</code> config option for multi-destination backups. When enabled, a failure on one destination won't abort the entire backup. It fires a failure event for that destination and continues with the rest.</p>
<p>Other additions include a <code>verify_backup</code> config option that validates the zip archive after creation, a generic webhook notification channel for Mattermost/Teams/custom integrations, new command options (<code>--filename-suffix</code>, <code>--exclude</code>, <code>--destination-path</code>), and improved health checks that now report all failures instead of stopping at the first one.</p>
<p>On the internals side, the <code>ConsoleOutput</code> singleton has been replaced by a <code>backupLogger()</code> helper, encryption config now uses a proper enum, and <code>storage/framework</code> is excluded from backups by default.</p>
<p>The full list of breaking changes and migration instructions can be found in the <a href="https://github.com/spatie/laravel-backup/blob/main/UPGRADING.md">upgrade guide</a>.</p>
<h2 id="in-closing">In closing</h2>
<p>You can find the complete documentation at <a href="https://spatie.be/docs/laravel-backup">spatie.be/docs/laravel-backup</a> and the source code <a href="https://github.com/spatie/laravel-backup">on GitHub</a>.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be">Spatie</a>. If you want to support our open source work, consider picking up one of <a href="https://spatie.be/products">our paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-04T14:19:52+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ Laravel Sitemap v8 is here: automatic splitting, XSL stylesheets, and crawler v9]]></title>
            <link rel="alternate" href="https://freek.dev/3041-laravel-sitemap-v8-is-here-automatic-splitting-xsl-stylesheets-and-crawler-v9" />
            <id>https://freek.dev/3041</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>We just released v8 of <a href="https://spatie.be/docs/laravel-sitemap">Laravel Sitemap</a>. This package that can generate sitemaps by crawling your entire site or by manually adding URLs. This version upgrades the underlying crawler to v9, adds a couple of nice new features, and cleans up the internals.</p>
<p>Let me walk you through what the package can do and what's new in v8.</p>
<!--more-->
<h2 id="generating-a-sitemap-by-crawling">Generating a sitemap by crawling</h2>
<p>The simplest way to use the package is to point it at your site and let it crawl every page.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sitemap\SitemapGenerator</span>;

<span class="hl-type">SitemapGenerator</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)-&gt;<span class="hl-property">writeToFile</span>(<span class="hl-variable">$path</span>);
</pre>
<p>That's it. The generator will follow all internal links and produce a complete <code>sitemap.xml</code>. You can filter which URLs end up in the sitemap using the <code>shouldCrawl</code> callback.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">SitemapGenerator</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">shouldCrawl</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url</span>) {
        <span class="hl-keyword">return</span> ! <span class="hl-property">str_contains</span>(<span class="hl-property">parse_url</span>(<span class="hl-variable">$url</span>, <span class="hl-property">PHP_URL_PATH</span>) ?? <span class="hl-value">''</span>, <span class="hl-value">'/admin'</span>);
    })
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-variable">$path</span>);
</pre>
<h2 id="creating-a-sitemap-manually">Creating a sitemap manually</h2>
<p>If you'd rather have full control, you can build the sitemap yourself.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Carbon\Carbon</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sitemap\Sitemap</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sitemap\Tags\Url</span>;

<span class="hl-type">Sitemap</span>::<span class="hl-property">create</span>()
    -&gt;<span class="hl-property">add</span>(<span class="hl-type">Url</span>::<span class="hl-property">create</span>(<span class="hl-value">'/home'</span>)
        -&gt;<span class="hl-property">setLastModificationDate</span>(<span class="hl-type">Carbon</span>::<span class="hl-property">yesterday</span>())
        -&gt;<span class="hl-property">setChangeFrequency</span>(<span class="hl-type">Url</span>::<span class="hl-property">CHANGE_FREQUENCY_YEARLY</span>)
        -&gt;<span class="hl-property">setPriority</span>(0.1))
    -&gt;<span class="hl-property">add</span>(<span class="hl-type">Url</span>::<span class="hl-property">create</span>(<span class="hl-value">'/contact'</span>))
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-variable">$path</span>);
</pre>
<p>You can also combine both approaches: let the crawler do the heavy lifting, then add extra URLs on top.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">SitemapGenerator</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">getSitemap</span>()
    -&gt;<span class="hl-property">add</span>(<span class="hl-type">Url</span>::<span class="hl-property">create</span>(<span class="hl-value">'/extra-page'</span>))
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-variable">$path</span>);
</pre>
<h2 id="adding-models-directly">Adding models directly</h2>
<p>If your models implement the <code>Sitemapable</code> interface, you can add them to the sitemap directly.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sitemap\Contracts\Sitemapable</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Sitemap\Tags\Url</span>;

<span class="hl-keyword">class</span> <span class="hl-type">Post</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span> <span class="hl-keyword">implements</span><span class="hl-type"> Sitemapable
</span>{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">toSitemapTag</span>(): <span class="hl-type">Url</span> | string | array
    {
        <span class="hl-keyword">return</span> <span class="hl-property">route</span>(<span class="hl-value">'blog.post.show'</span>, <span class="hl-variable">$this</span>);
    }
}
</pre>
<p>Now you can pass a single model or an entire collection.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Sitemap</span>::<span class="hl-property">create</span>()
    -&gt;<span class="hl-property">add</span>(<span class="hl-variable">$post</span>)
    -&gt;<span class="hl-property">add</span>(<span class="hl-type">Post</span>::<span class="hl-property">all</span>())
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-variable">$path</span>);
</pre>
<h2 id="automatic-sitemap-splitting">Automatic sitemap splitting</h2>
<p>Large sites can easily exceed the 50,000 URL limit that the sitemap protocol allows per file. New in v8, you can call <code>maxTagsPerSitemap()</code> on your sitemap, and the package will automatically split it into multiple files with a sitemap index.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Sitemap</span>::<span class="hl-property">create</span>()
    -&gt;<span class="hl-property">maxTagsPerSitemap</span>(10000)
    -&gt;<span class="hl-property">add</span>(<span class="hl-variable">$allUrls</span>)
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-property">public_path</span>(<span class="hl-value">'sitemap.xml'</span>));
</pre>
<p>If your sitemap contains more than 10,000 URLs, this will write <code>sitemap_1.xml</code>, <code>sitemap_2.xml</code>, etc., and a <code>sitemap.xml</code> index file that references them all. If your sitemap stays under the limit, it just writes a single file as usual.</p>
<h2 id="xsl-stylesheet-support">XSL stylesheet support</h2>
<p>Sitemaps are XML files, and they look pretty rough when opened in a browser. Also new in v8, you can attach an XSL stylesheet to make them human-readable.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Sitemap</span>::<span class="hl-property">create</span>()
    -&gt;<span class="hl-property">setStylesheet</span>(<span class="hl-value">'/sitemap.xsl'</span>)
    -&gt;<span class="hl-property">add</span>(<span class="hl-type">Post</span>::<span class="hl-property">all</span>())
    -&gt;<span class="hl-property">writeToFile</span>(<span class="hl-property">public_path</span>(<span class="hl-value">'sitemap.xml'</span>));
</pre>
<p>This works on both <code>Sitemap</code> and <code>SitemapIndex</code>. When combined with <code>maxTagsPerSitemap()</code>, the stylesheet is automatically applied to all split files and the index.</p>
<h2 id="in-closing">In closing</h2>
<p>Under the hood, we've upgraded the package to use <a href="https://freek.dev/3039-a-better-way-to-crawl-websites-with-php">spatie/crawler v9</a>.</p>
<p>You'll find the complete <a href="https://spatie.be/docs/laravel-sitemap">documentation on our docs site</a>. The package is available <a href="https://github.com/spatie/laravel-sitemap">on GitHub</a>.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be">Spatie</a>. If you want to support our open source work, consider picking up <a href="https://spatie.be/open-source/support-us">one of our paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-03T11:50:27+01:00</updated>
        </entry>
            <entry>
            <title><![CDATA[★ A better way to crawl websites with PHP]]></title>
            <link rel="alternate" href="https://freek.dev/3039-a-better-way-to-crawl-websites-with-php" />
            <id>https://freek.dev/3039</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Our <a href="https://github.com/spatie/crawler">spatie/crawler</a>.  package is one of the first one I created. It allows you to crawl a website with PHP.  It is used extensively in <a href="https://ohdear.app?utm_source=freek.dev&amp;utm_medium=referral&amp;utm_campaign=blogpost-3039-a-better-way-to-crawl-websites-with-php">Oh Dear</a> and our <a href="https://spatie.be/docs/laravel-sitemap">laravel-sitemap</a> package.</p>
<p>Throughout the years, the API had accumulated some rough edges. With v9, we cleaned all of that up and added a bunch of features we've wanted for a long time.</p>
<p>Let me walk you through all of it!</p>
<!--more-->
<h2 id="using-the-crawler">Using the crawler</h2>
<p>The simplest way to crawl a site is to pass a URL to <code>Crawler::create()</code> and attach a callback via <code>onCrawled()</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Crawler\Crawler</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Crawler\CrawlResponse</span>;

<span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">onCrawled</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">CrawlResponse</span> $response</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;{$url}: {$response-&gt;status()}\n&quot;</span>;
    })
    -&gt;<span class="hl-property">start</span>();
</pre>
<p>The callable gets a <code>CrawlResponse</code> object. It has these methods</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$response</span>-&gt;<span class="hl-property">status</span>();        <span class="hl-comment">// int</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">body</span>();          <span class="hl-comment">// string</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">header</span>(<span class="hl-value">'some-header'</span>);  <span class="hl-comment">// ?string</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">dom</span>();           <span class="hl-comment">// Symfony DomCrawler instance</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">isSuccessful</span>();  <span class="hl-comment">// bool</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">isRedirect</span>();    <span class="hl-comment">// bool</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">foundOnUrl</span>();    <span class="hl-comment">// ?string</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">linkText</span>();      <span class="hl-comment">// ?string</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">depth</span>();         <span class="hl-comment">// int</span>
</pre>
<p>The body is cached, so calling <code>body()</code> multiple times won't re-read the stream. And if you still need the raw PSR-7 response for some reason, <code>toPsrResponse()</code> has you covered.</p>
<p>You can control how many URLs are fetched at the same time with <code>concurrency()</code>, and set a hard cap with <code>limit()</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">concurrency</span>(5)
    -&gt;<span class="hl-property">limit</span>(200) <span class="hl-comment">// will stop after crawling this amount of pages</span>
    -&gt;<span class="hl-property">onCrawled</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">CrawlResponse</span> $response</span>) {
        <span class="hl-comment">// ...</span>
    })
    -&gt;<span class="hl-property">start</span>();
</pre>
<p>There are a couple of other <code>on</code> closure callbacks you can use:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">onCrawled</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">CrawlResponse</span> $response, <span class="hl-type">CrawlProgress</span> $progress</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;[{$progress-&gt;urlsProcessed}/{$progress-&gt;urlsFound}] {$url}\n&quot;</span>;
    })
    -&gt;<span class="hl-property">onFailed</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">RequestException</span> $e, <span class="hl-type">CrawlProgress</span> $progress</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Failed: {$url}\n&quot;</span>;
    })
    -&gt;<span class="hl-property">onFinished</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">FinishReason</span> $reason, <span class="hl-type">CrawlProgress</span> $progress</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Done: {$reason-&gt;name}\n&quot;</span>;
    })
    -&gt;<span class="hl-property">start</span>();
</pre>
<p>Every <code>on</code> callback now receives a <code>CrawlProgress</code> object that tells you exactly where you are in the crawl:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$progress</span>-&gt;<span class="hl-property">urlsProcessed</span>;  <span class="hl-comment">// how many URLs have been crawled</span>
<span class="hl-variable">$progress</span>-&gt;<span class="hl-property">urlsFailed</span>;     <span class="hl-comment">// how many failed</span>
<span class="hl-variable">$progress</span>-&gt;<span class="hl-property">urlsFound</span>;      <span class="hl-comment">// total discovered so far</span>
<span class="hl-variable">$progress</span>-&gt;<span class="hl-property">urlsPending</span>;    <span class="hl-comment">// still in the queue</span>
</pre>
<p>The <code>start()</code> method now returns a <code>FinishReason</code> enum, so you know exactly why the crawler stopped:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$reason</span> = <span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">limit</span>(100)
    -&gt;<span class="hl-property">start</span>();

<span class="hl-comment">// $reason is one of: Completed, CrawlLimitReached, TimeLimitReached, Interrupted</span>
</pre>
<p>Each <code>CrawlResponse</code> also carries a <code>TransferStatistics</code> object with detailed timing data for the request:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">onCrawled</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">CrawlResponse</span> $response</span>) {
        <span class="hl-variable">$stats</span> = <span class="hl-variable">$response</span>-&gt;<span class="hl-property">transferStats</span>();

        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;{$url}\n&quot;</span>;
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;  Transfer time: {$stats-&gt;transferTimeInMs()}ms\n&quot;</span>;
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;  DNS lookup: {$stats-&gt;dnsLookupTimeInMs()}ms\n&quot;</span>;
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;  TLS handshake: {$stats-&gt;tlsHandshakeTimeInMs()}ms\n&quot;</span>;
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;  Time to first byte: {$stats-&gt;timeToFirstByteInMs()}ms\n&quot;</span>;
        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;  Download speed: {$stats-&gt;downloadSpeedInBytesPerSecond()} B/s\n&quot;</span>;
    })
    -&gt;<span class="hl-property">start</span>();
</pre>
<p>All timing methods return values in milliseconds. They return <code>null</code> when the stat is unavailable, for example <code>tlsHandshakeTimeInMs()</code> will be <code>null</code> for plain HTTP requests.</p>
<h3 id="throttling-the-crawl">Throttling the crawl</h3>
<p>I wanted the crawler to a well behaved piece of software. Using the crawler at full speed and with large concurrency could overload some servers. That's why throttling is a polished feature of the package.</p>
<p>We ship two throttling strategies. The first one is <code>FixedDelayThrottle</code> that can give a fixed delay between all requests.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// 200ms between requests</span>
<span class="hl-variable">$crawler</span>-&gt;<span class="hl-property">throttle</span>(<span class="hl-keyword">new</span> <span class="hl-type">FixedDelayThrottle</span>(200)); 
</pre>
<p><code>AdaptiveThrottle</code> is a strategy that adjusts the delay based on how fast the server responds. If the server responds fast, the minimum delay will be low. If the server responds slow, we'll automatically slow down crawling.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$crawler</span>-&gt;<span class="hl-property">throttle</span>(<span class="hl-keyword">new</span> <span class="hl-type">AdaptiveThrottle</span>(
    <span class="hl-property">minDelayMs</span>: 50,
    <span class="hl-property">maxDelayMs</span>: 5000,
));
</pre>
<h3 id="testing-with-fake">Testing with fake()</h3>
<p>Like Laravel's <code>HTTP</code> client, the crawler now has a <code>fake</code> to define which response should be returned for a request without making the actually request.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Crawler</span>::<span class="hl-property">create</span>(<span class="hl-value">'https://example.com'</span>)
    -&gt;<span class="hl-property">fake</span>([
        <span class="hl-value">'https://example.com'</span> =&gt; <span class="hl-value">'&lt;html&gt;&lt;a href=&quot;/about&quot;&gt;About&lt;/a&gt;&lt;/html&gt;'</span>,
        <span class="hl-value">'https://example.com/about'</span> =&gt; <span class="hl-value">'&lt;html&gt;About page&lt;/html&gt;'</span>,
    ])
    -&gt;<span class="hl-property">onCrawled</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $url, <span class="hl-type">CrawlResponse</span> $response</span>) {
        <span class="hl-comment">// your assertions here</span>
    })
    -&gt;<span class="hl-property">start</span>();
</pre>
<p>Using this faking helps to keep your tests executing fast.</p>
<h3 id="driver-based-javascript-rendering">Driver-based JavaScript rendering</h3>
<p>Like in our <a href="https://spatie.be/docs/laravel-pdf">Laravel PDF</a>, <a href="https://spatie.be/docs/laravel-screenshot">Laravel Screenshot</a>, and <a href="https://spatie.be/docs/laravel-og-image">Laravel OG Image</a> packages, <a href="https://spatie.be/docs/browsershot">Browsershot</a> is no longer a hard dependency. JavaScript rendering is now driver-based, so you can use Browsershot, a new Cloudflare renderer, or write your own:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$crawler</span>-&gt;<span class="hl-property">executeJavaScript</span>(<span class="hl-keyword">new</span> <span class="hl-type">CloudflareRenderer</span>(<span class="hl-variable">$endpoint</span>));
</pre>
<h2 id="in-closing">In closing</h2>
<p>I'm usually very humble, but think that in this case I can say that our crawler package is the best available crawler in the entire PHP ecosystem.</p>
<p>You can find the package <a href="https://github.com/spatie/crawler">on GitHub</a>. The full documentation is available <a href="https://spatie.be/docs/crawler">on our documentation site</a>.</p>
<p>This is one of the many packages we've created at <a href="https://spatie.be/open-source">Spatie</a>. If you want to support our open source work, consider picking up <a href="https://spatie.be/open-source/support-us">one of our paid products</a>.</p>
]]>
            </summary>
                                    <updated>2026-03-02T21:56:46+01:00</updated>
        </entry>
    </feed>
