<?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/php</id>
                                <link href="https://freek.dev/feed/php" rel="self"></link>
                                <title><![CDATA[freek.dev - all PHP blogposts]]></title>
                    
                                <subtitle>All PHP blogposts on freek.dev</subtitle>
                                                    <updated>2026-06-16T14:55:29+02:00</updated>
                        <entry>
            <title><![CDATA[Rust Tutorial For PHP and JavaScript Developers]]></title>
            <link rel="alternate" href="https://freek.dev/3141-rust-tutorial-for-php-and-javascript-developers" />
            <id>https://freek.dev/3141</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

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

<a href='https://youtu.be/lJdqrDFswns?si=xs8_bE4e1JwIsZHu'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-16T14:55:29+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[#[RouteParameter] Does Not Bind Your Model]]></title>
            <link rel="alternate" href="https://freek.dev/3137-routeparameter-does-not-bind-your-model" />
            <id>https://freek.dev/3137</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Michael Dyrynda explains a subtle Laravel gotcha: #[RouteParameter] only reads the current route parameter value, it does not perform implicit model binding. Good reminder that the controller signature still matters when you expect a bound model inside a form request.</p>


<a href='https://dyrynda.com.au/blog/route-parameters-can-hurt'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-12T14:30:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Prompt-Injection Guardrails in Laravel: Defend the Tools, Not the Prompt]]></title>
            <link rel="alternate" href="https://freek.dev/3136-prompt-injection-guardrails-in-laravel-defend-the-tools-not-the-prompt" />
            <id>https://freek.dev/3136</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>You can't out-prompt an attacker — to the model, your system instructions and a malicious support ticket are the same text. So stop defending the prompt and lock down the boundaries you actually control: tools scoped to the authenticated user server-side, middleware that screens and logs, output handled as untrusted input, a human in front of anything irreversible, and a fake-free test that fails CI the moment someone drops the auth scope.</p>


<a href='https://mujahidabbas.dev/blog/laravel-prompt-injection-guardrails/'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-11T14:10:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Logging is here!]]></title>
            <link rel="alternate" href="https://freek.dev/3147-logging-is-here" />
            <id>https://freek.dev/3147</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Flare now supports log collection for Laravel and PHP apps, with real-time filtering and search in the same polished interface. A nice overview of what logging adds and how to get started with the new SDK release.</p>


<a href='https://flareapp.io/blog/logging-is-here'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-09T14:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The Reason I Love Tempest for APIs]]></title>
            <link rel="alternate" href="https://freek.dev/3127-the-reason-i-love-tempest-for-apis" />
            <id>https://freek.dev/3127</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Steve King explains why Tempest feels so nice for API work: typed request objects, declarative validation, route discovery, and a low-ceremony action flow. It is a good look at how the framework removes boilerplate without giving up clarity.</p>


<a href='https://www.juststeveking.com/articles/the-reason-i-love-tempest-for-apis/'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-03T14:30:27+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Using property hooks in PHP]]></title>
            <link rel="alternate" href="https://freek.dev/3125-using-property-hooks-in-php" />
            <id>https://freek.dev/3125</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Michael Dyrynda shows how PHP 8.4 property hooks can replace simple computed getter methods with virtual properties. He makes the case for using them when you want a clean, property-based API for derived values.</p>


<a href='https://dyrynda.com.au/blog/using-property-hooks-in-php'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-01T14:30:31+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Validating distinct data in requests]]></title>
            <link rel="alternate" href="https://freek.dev/3124-validating-distinct-data-in-requests" />
            <id>https://freek.dev/3124</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Michael Dyrynda shows how Laravel's <code>distinct</code> validation rule can protect nested request payloads from duplicate reference values before they corrupt relationship mapping. He also highlights the <code>strict</code> and <code>ignore_case</code> options for cases where loose comparison is not enough.</p>


<a href='https://dyrynda.com.au/blog/validating-distinct-request-data'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-31T14:30:37+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The stack behind There There]]></title>
            <link rel="alternate" href="https://freek.dev/3133-the-stack-behind-there-there" />
            <id>https://freek.dev/3133</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>An inside look at the stack powering There There: Laravel, Inertia, React, TypeScript, Horizon, Reverb, and a bunch of Spatie packages and services. A nice overview of the pragmatic tooling choices behind the product.</p>


<a href='https://there-there.app/blog/the-stack-behind-there-there'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-30T10:13:56+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[An Update on Composer & Packagist Supply Chain Security]]></title>
            <link rel="alternate" href="https://freek.dev/3128-an-update-on-composer-packagist-supply-chain-security" />
            <id>https://freek.dev/3128</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Composer and Packagist share a solid overview of the supply chain security work already in place, what is shipping now, and what is coming next. Worth reading if you maintain PHP packages or care about how the ecosystem is hardening against package compromise.</p>


<a href='https://blog.packagist.com/an-update-on-composer-packagist-supply-chain-security/'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-27T21:09:01+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Using the ADR (Action/Domain/Responder) Pattern in Laravel]]></title>
            <link rel="alternate" href="https://freek.dev/3074-using-the-adr-actiondomainresponder-pattern-in-laravel" />
            <id>https://freek.dev/3074</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Learn what the ADR (Action/Domain/Responder) pattern is and how to apply it in Laravel with a simple, practical example.</p>


<a href='https://wendelladriel.com/blog/using-the-adr-action-domain-responder-pattern-in-laravel'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-12T14:30:29+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Flaky Test Retries in Pest v4.5.0]]></title>
            <link rel="alternate" href="https://freek.dev/3096-flaky-test-retries-in-pest-v450" />
            <id>https://freek.dev/3096</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Pest v4.5.0 adds first-class support for flaky tests with retries and a dedicated CLI filter. The release also brings a useful casing assertion for catching namespace and file path mismatches, plus a coverage option that hides uncovered files.</p>


<a href='https://laravel-news.com/pest-4-5-0'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-10T14:30:29+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Idempotency: What, Why and How]]></title>
            <link rel="alternate" href="https://freek.dev/3113-idempotency-what-why-and-how" />
            <id>https://freek.dev/3113</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A deep dive into idempotency, from the theory behind safe retries to a practical Laravel implementation using the Laravel Idempotency package.</p>


<a href='https://wendelladriel.com/blog/idempotency-what-why-and-how'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-07T14:33:25+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[RBAC in Laravel: A Practical Deep Dive]]></title>
            <link rel="alternate" href="https://freek.dev/3101-rbac-in-laravel-a-practical-deep-dive" />
            <id>https://freek.dev/3101</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A deep dive into Role-Based Access Control, from the theory behind roles and permissions to a practical, team-aware Laravel implementation without external packages.</p>


<a href='https://wendelladriel.com/blog/rbac-in-laravel-a-practical-deep-dive'>Read more</a>]]>
            </summary>
                                    <updated>2026-05-01T14:40:29+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[Validating Array Inputs in Laravel Without the N+1]]></title>
            <link rel="alternate" href="https://freek.dev/3070-validating-array-inputs-in-laravel-without-the-n1" />
            <id>https://freek.dev/3070</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

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


<a href='https://daryllegion.com/validating-array-inputs-in-laravel-without-the-n-plus-1'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-16T14:22:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why use static closures?]]></title>
            <link rel="alternate" href="https://freek.dev/3066-why-use-static-closures" />
            <id>https://freek.dev/3066</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

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


<a href='https://f2r.github.io/en/static-closures'>Read more</a>]]>
            </summary>
                                    <updated>2026-04-14T12:30:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Liminal - A full Laravel playground in your browser]]></title>
            <link rel="alternate" href="https://freek.dev/3021-liminal-a-full-laravel-playground-in-your-browser" />
            <id>https://freek.dev/3021</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A static, free, and open-source Laravel playground that runs entirely in the browser! Comes with a sqlite db, artisan commands, two-way file syncing, github imports, and more.</p>


<a href='https://liminal.aschmelyun.com'>Read more</a>]]>
            </summary>
                                    <updated>2026-03-25T13:30:28+01: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">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[Moving from PHPStorm to Zed for Laravel development]]></title>
            <link rel="alternate" href="https://freek.dev/3020-moving-from-phpstorm-to-zed-for-laravel-development" />
            <id>https://freek.dev/3020</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Chris Mellor wrote a practical guide on setting up Zed as a Laravel IDE, covering PHP extensions, Pint formatting, Blade support, and how it compares to PHPStorm.</p>


<a href='https://x.com/cmellor/status/2024109224146440404'>Read more</a>]]>
            </summary>
                                    <updated>2026-03-23T13:30:28+01:00</updated>
        </entry>
    </feed>
