<?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-10-01T12:30:28+02:00</updated>
                        <entry>
            <title><![CDATA[15 years of Packagist: Over 200 billion package installs]]></title>
            <link rel="alternate" href="https://freek.dev/3199-15-years-of-packagist-over-200-billion-package-installs" />
            <id>https://freek.dev/3199</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A look back at Packagist's first 15 years, from the first Composer experiments to more than 200 billion package installs. It also covers the team's recent supply chain security work and what they're building next.</p>


<a href='https://blog.packagist.com/15-years-of-packagist-over-200-billion-package-installs/'>Read more</a>]]>
            </summary>
                                    <updated>2026-10-01T12:30:28+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://freek.dev/admin-uploads/jev-smart-condition-private-note-20260930.png"><img src="https://freek.dev/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://freek.dev/admin-uploads/jev-smart-condition-test-compact-20260930.png"><img src="https://freek.dev/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://freek.dev/admin-uploads/jev-ai-person-assignment-20260930.png"><img src="https://freek.dev/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[What's New in PHP 8.6]]></title>
            <link rel="alternate" href="https://freek.dev/3192-whats-new-in-php-86" />
            <id>https://freek.dev/3192</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A concise overview of what's coming in PHP 8.6, including partial function application, clamp(), the new Duration class, and several smaller language and standard library improvements.</p>


<a href='https://laravel-news.com/php-8-6'>Read more</a>]]>
            </summary>
                                    <updated>2026-09-16T14:30:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Using AI as a deterministic translation tool in Laravel]]></title>
            <link rel="alternate" href="https://freek.dev/3186-using-ai-as-a-deterministic-translation-tool-in-laravel" />
            <id>https://freek.dev/3186</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Split a task into reasoning and execution to get deterministic behaviour out of a non-deterministic model.</p>


<a href='https://koomai.net/posts/using-ai-as-a-deterministic-translation-tool-in-laravel/'>Read more</a>]]>
            </summary>
                                    <updated>2026-09-07T14:53:24+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Digital Sovereignty Is Written in PHP]]></title>
            <link rel="alternate" href="https://freek.dev/3185-digital-sovereignty-is-written-in-php" />
            <id>https://freek.dev/3185</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>An interesting article on how PHP is used by the government.</p>


<a href='https://thephp.foundation/blog/2026/09/02/digital-sovereignty-is-written-in-php/'>Read more</a>]]>
            </summary>
                                    <updated>2026-09-04T14:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Build Your Own AI-Powered Slack Bot with the Laravel AI SDK]]></title>
            <link rel="alternate" href="https://freek.dev/3184-build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk" />
            <id>https://freek.dev/3184</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>In this article, we’ll build an AI-powered Slack bot with the Laravel AI SDK. Unlike tools such as Claude Tag, it can switch AI providers, answer questions, execute tasks, and retrieve information from a knowledge base you control.</p>


<a href='https://tighten.com/insights/build-your-own-ai-powered-slack-bot-with-the-laravel-ai-sdk/'>Read more</a>]]>
            </summary>
                                    <updated>2026-08-31T14:59:25+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Immutability in PHP Beyond readonly]]></title>
            <link rel="alternate" href="https://freek.dev/3181-immutability-in-php-beyond-readonly" />
            <id>https://freek.dev/3181</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 immutability in PHP, from readonly properties and interior mutability to immutable value objects, collections, dates, cloning, boundaries, and testing.</p>


<a href='https://wendelladriel.com/blog/immutability-in-php-beyond-readonly'>Read more</a>]]>
            </summary>
                                    <updated>2026-08-17T14:12:30+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[`exit()` may silently break your parallel tests]]></title>
            <link rel="alternate" href="https://freek.dev/3177-exit-may-silently-break-your-parallel-tests" />
            <id>https://freek.dev/3177</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Michael explains why exit() can make parallel test workers crash without useful diagnostics, and why verbose flags do not help when the process dies outside PHPUnit's control. The fix is simple: throw an exception instead, so the failure is reported normally with a stack trace.</p>


<a href='https://dyrynda.com.au/blog/exit-may-silently-break-your-parallel-tests'>Read more</a>]]>
            </summary>
                                    <updated>2026-08-12T12:30:31+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[sbx: Sandboxed Claude, complete with PHP and tools]]></title>
            <link rel="alternate" href="https://freek.dev/3175-sbx-sandboxed-claude-complete-with-php-and-tools" />
            <id>https://freek.dev/3175</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A practical walkthrough for running Claude Code inside Docker's new sbx sandbox, with a custom PHP setup and extra tools for open source work. Benjamin also explains why sandboxing AI coding agents is the sensible default.</p>


<a href='https://www.beberlei.de/post/sbx-sandboxed-claude-complete-with-php-and-tools'>Read more</a>]]>
            </summary>
                                    <updated>2026-08-10T12:30:04+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What's new in PHP 8.6]]></title>
            <link rel="alternate" href="https://freek.dev/3174-whats-new-in-php-86" />
            <id>https://freek.dev/3174</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Brent surveys the most notable additions coming in PHP 8.6, including partial function application, the new polling API, readonly property defaults, and several smaller improvements and deprecations.</p>


<a href='https://stitcher.io/blog/new-in-php-86'>Read more</a>]]>
            </summary>
                                    <updated>2026-08-05T14:14:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP version stats: July, 2026]]></title>
            <link rel="alternate" href="https://freek.dev/3171-php-version-stats-july-2026" />
            <id>https://freek.dev/3171</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Brendt shares fresh Packagist-based PHP version stats for July 2026: PHP 8.4 is now the most-used version, while PHP 8.5 reached 16% adoption after six months. He also notes that package minimum requirements still lag behind, with relatively few popular packages requiring a version that still gets active bugfix support.</p>


<a href='https://stitcher.io/blog/php-version-stats-july-2026'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-29T17:23:14+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Building an order fulfilment workflow in Laravel]]></title>
            <link rel="alternate" href="https://freek.dev/3170-building-an-order-fulfilment-workflow-in-laravel" />
            <id>https://freek.dev/3170</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Steve King builds a real order fulfilment flow to show how a workflow engine handles long-running processes in Laravel. The post covers steps, signals, retries, branching, human approval, compensation, and the surrounding tooling in one practical example.</p>


<a href='https://www.juststeveking.com/articles/building-an-order-fulfilment-workflow/'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-25T14:30:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Preventing SSRF in Laravel: Why Blocking Private IPs Fails]]></title>
            <link rel="alternate" href="https://freek.dev/3167-preventing-ssrf-in-laravel-why-blocking-private-ips-fails" />
            <id>https://freek.dev/3167</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>I found there wasn't any good SSRF prevention libraries for Laravel, so I built one. Very comprehensive tests based on this paper:
https://www.usenix.org/conference/usenixsecurity24/presentation/wessels</p>
<p>https://github.com/securized/laravel-ssrf</p>


<a href='https://securized.dev/laravel-ssrf-package'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-22T15:24:24+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP Attributes: What, Why, How and When]]></title>
            <link rel="alternate" href="https://freek.dev/3166-php-attributes-what-why-how-and-when" />
            <id>https://freek.dev/3166</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 PHP attributes, from metadata and Reflection to practical design patterns, Laravel examples, performance, testing, and the cases where explicit code is better.</p>


<a href='https://wendelladriel.com/blog/php-attributes-what-why-how-and-when'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-21T14:43:26+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Supercharge your PHP apps with Go-powered PHP extensions]]></title>
            <link rel="alternate" href="https://freek.dev/3164-supercharge-your-php-apps-with-go-powered-php-extensions" />
            <id>https://freek.dev/3164</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>A practical conference session on building PHP extensions with Go and FrankenPHP, then wiring them into Laravel and Symfony as native-feeling features. It uses an in-memory LRU cache as the example and offers a nice tour of framework flexibility and PHP internals.</p>


<a href='https://phpconference.nl/session/supercharge-your-php-apps-with-go-powered-php-extensions/'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-17T14:30:31+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[New in PHP 8.6: Faster array_map with first-class callables]]></title>
            <link rel="alternate" href="https://freek.dev/3162-new-in-php-86-faster-array-map-with-first-class-callables" />
            <id>https://freek.dev/3162</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>In this video, Tideways shows how PHP 8.6 speeds up array_map when you use first-class callables.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/Bbc0fXNXB3U" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
]]>
            </summary>
                                    <updated>2026-07-15T14:30:27+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The Laravel Scheduler Under the Hood]]></title>
            <link rel="alternate" href="https://freek.dev/3161-the-laravel-scheduler-under-the-hood" />
            <id>https://freek.dev/3161</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 the Laravel Scheduler, from event registration and cron evaluation to mutexes, background processes, sub-minute tasks, and production-safe scheduling.</p>


<a href='https://wendelladriel.com/blog/the-laravel-scheduler-under-the-hood'>Read more</a>]]>
            </summary>
                                    <updated>2026-07-14T14:02:25+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Everything About the Context Facade]]></title>
            <link rel="alternate" href="https://freek.dev/3152-everything-about-the-context-facade" />
            <id>https://freek.dev/3152</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 Laravel Context, from request metadata and automatic log enrichment to hidden context, scoped values, queues, scheduled commands, and internals.</p>


<a href='https://wendelladriel.com/blog/everything-about-the-context-facade'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-29T14:13:28+02:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Improving Laravel Architecture With Expressive]]></title>
            <link rel="alternate" href="https://freek.dev/3134-improving-laravel-architecture-with-expressive" />
            <id>https://freek.dev/3134</id>
            <author>
                <name><![CDATA[Freek Van der Herten]]></name>
                <email><![CDATA[freek@spatie.be]]></email>

            </author>
            <summary type="html">
                <![CDATA[<p>Learn how Expressive can improve a Laravel application by keeping Eloquent as the database layer while moving business logic to fully typed objects.</p>


<a href='https://wendelladriel.com/blog/improving-laravel-architecture-with-expressive'>Read more</a>]]>
            </summary>
                                    <updated>2026-06-23T14:30:27+02:00</updated>
        </entry>
    </feed>
