<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Pest 5 TIA: Run Only the Tests Your Code Change Touched</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 30 Jul 2026 05:22:01 +0000</pubDate>
      <link>https://dev.to/hafiz619/pest-5-tia-run-only-the-tests-your-code-change-touched-1k3d</link>
      <guid>https://dev.to/hafiz619/pest-5-tia-run-only-the-tests-your-code-change-touched-1k3d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Nuno Maduro announced Pest 5 on stage at Laracon US yesterday, and one feature is worth your attention today: the Tia Engine. TIA stands for Test Impact Analysis, and the pitch is simple enough to sound too good to be true. A Laravel suite that took 10 minutes now finishes in around 4 seconds, because Pest reruns only the tests your latest change could actually break and replays cached results for everything else.&lt;/p&gt;

&lt;p&gt;This shipped yesterday, so this is a walkthrough of how it works from the docs and the release, not a battle-tested war story. I'll be clear about what's proven and what you'll want to verify on your own suite. But the mechanism is well documented, the numbers come from Pest directly, and the setup really is a one-flag start. Here's what TIA does, how to turn it on, the CI trick that makes it fast for your whole team, and the catch nobody mentions in the announcement tweets.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Test Impact Analysis Actually Does
&lt;/h2&gt;

&lt;p&gt;The idea behind TIA isn't new (large monorepos at Google and Meta have done impact analysis for years), but it's new to have it built into a PHP testing framework you already use. The concept: most of your test suite is irrelevant to any given change. You edit one model, and 765 of your 774 tests couldn't possibly behave differently. Running all 774 is wasted time. TIA figures out which handful actually depend on what you touched, runs those, and reconstructs the rest from cache.&lt;/p&gt;

&lt;p&gt;The first run builds a baseline: Pest records a dependency graph of which tests exercise which files. Every run after that is a replay. Pest diffs your working tree against the baseline and reruns only the affected tests. The output tells you exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tests:    774 passed (2658 assertions, 7 affected, 2 uncached, 765 replayed)
Duration: 3.92s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that line: 7 tests actually ran because their dependencies changed, 2 ran because no cached result existed yet, and 765 were served from cache. Four seconds instead of ten minutes.&lt;/p&gt;

&lt;p&gt;The part I found most reassuring in the docs: a replay isn't a shortcut that skips coverage. Pest stores what each test covered, down to lines and branches, so a replayed run reports the same coverage as a full run. Your &lt;code&gt;--coverage&lt;/code&gt; report, coverage thresholds, and &lt;code&gt;--min&lt;/code&gt; all behave as if every test executed. You get the speed without lying to your coverage gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning It On
&lt;/h2&gt;

&lt;p&gt;One flag on any Pest invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./vendor/bin/pest &lt;span class="nt"&gt;--parallel&lt;/span&gt; &lt;span class="nt"&gt;--tia&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one hard requirement, and it's the thing that'll trip people up: &lt;strong&gt;TIA needs a code coverage driver, either PCOV or Xdebug, installed and enabled.&lt;/strong&gt; Pest uses it to record which files each test touches while building the baseline. No coverage driver, no dependency graph, no TIA. If you already run coverage in CI you have this; if you've never installed PCOV locally, that's your first step.&lt;/p&gt;

&lt;p&gt;The other requirement is the bigger gate: &lt;strong&gt;Pest 5 requires PHP 8.4 or higher.&lt;/strong&gt; It's built on PHP 8.4 and PHPUnit 13. If you're on an older PHP version, TIA is a reason to plan the upgrade, not something you can try this afternoon. (If you're mid-migration on the framework side, my &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers that half.)&lt;/p&gt;

&lt;p&gt;If you'd rather not type the flag every time, configure it in &lt;code&gt;tests/Pest.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;pest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tia&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;always&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;// run TIA on every invocation, no --tia flag&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;locally&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="c1"&gt;// but only on local machines, skip on CI&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;baselined&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// fetch the shared baseline from CI when missing&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// narrow PHPUnit to only affected test files&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt; pairing is the sensible default for most teams: fast replays while you work, full runs on CI where you want every test to execute for real. An explicit &lt;code&gt;--tia&lt;/code&gt; always wins, and &lt;code&gt;--no-tia&lt;/code&gt; disables it for a single run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How TIA Decides What To Rerun
&lt;/h2&gt;

&lt;p&gt;This is where it stops being magic and starts being understandable, and understanding it is what tells you whether to trust it. TIA maps different file types to tests in different ways:&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;PHP source files&lt;/strong&gt;, the coverage driver does the work. Change &lt;code&gt;app/Models/User.php&lt;/code&gt; and Pest reruns only tests that touched &lt;code&gt;User&lt;/code&gt;. For &lt;strong&gt;migrations&lt;/strong&gt;, Pest intersects the change against the tables each test queried during the baseline, so a column rename in the users migration reruns only tests that hit the &lt;code&gt;users&lt;/code&gt; table. &lt;strong&gt;Blade templates&lt;/strong&gt; rerun only the tests that rendered them. If you're on Inertia, Pest walks Vite's module graph to trace which pages import a changed component and reruns only the tests that server-side-rendered those pages.&lt;/p&gt;

&lt;p&gt;Then there's the honest fallback. Config files, route files, fixtures, anything outside the recorded graph triggers a broader pattern. Edit &lt;code&gt;config/app.php&lt;/code&gt; and Pest reruns the entire suite, because it can't statically prove which tests depend on it. That's the correct behavior, and it's a good sign: TIA errs toward running too much rather than missing a regression. Some changes rebuild the graph itself, like &lt;code&gt;composer.lock&lt;/code&gt;, &lt;code&gt;phpunit.xml&lt;/code&gt;, and Node lockfiles.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/pest-5-tia-run-only-tests-your-change-touched" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Detail That Sells It: Cosmetic Edits Run Nothing
&lt;/h2&gt;

&lt;p&gt;Here's the feature that makes TIA feel different from a dumb file-timestamp cache. Pest normalizes file content before comparing. PHP files get whitespace, line comments, and docblocks stripped before hashing. Blade strips its &lt;code&gt;{{-- --}}&lt;/code&gt; comments. JS, TS, Vue, and Svelte lose their comments too.&lt;/p&gt;

&lt;p&gt;The practical result: a comment-only edit, a Prettier reformat, a &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Pint&lt;/a&gt; pass, or a README tweak produces an identical hash. The file never enters the changed set. Zero tests run. If you've ever watched your full suite grind through CI because someone reformatted a file, you understand why that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing the Baseline: The CI Trick
&lt;/h2&gt;

&lt;p&gt;Recording the baseline takes as long as one full suite run, which on a large project is minutes. Paying that on every developer's machine, and every fresh checkout, would undercut the whole point. Pest's answer: record the baseline once in CI, and have everyone else download it.&lt;/p&gt;

&lt;p&gt;You enable fetching with &lt;code&gt;pest()-&amp;gt;tia()-&amp;gt;baselined()&lt;/code&gt; in &lt;code&gt;tests/Pest.php&lt;/code&gt; (preferred for teams). When Pest finds no local graph, it uses the GitHub CLI to pull the &lt;code&gt;pest-tia-baseline&lt;/code&gt; artifact from your latest successful baseline workflow, validates it against your project state, and adopts it if it matches. Every developer's first &lt;code&gt;--tia&lt;/code&gt; run then replays immediately, paying no record cost.&lt;/p&gt;

&lt;p&gt;The workflow that produces that artifact, dropped into &lt;code&gt;.github/workflows/tia-baseline.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TIA Baseline&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;baseline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shivammathur/setup-php@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;php-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.4'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;xdebug&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composer install --no-interaction --prefer-dist&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./vendor/bin/pest --parallel --tia --coverage --fresh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Resolve TIA baseline path&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;baseline&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "path=$(./vendor/bin/pest --baseline)" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload TIA baseline&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pest-tia-baseline&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.baseline.outputs.path }}&lt;/span&gt;
          &lt;span class="na"&gt;include-hidden-files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know here. &lt;code&gt;./vendor/bin/pest --baseline&lt;/code&gt; prints the absolute path to the TIA storage directory (&lt;code&gt;~/.pest/tia/&amp;lt;project-key&amp;gt;/&lt;/code&gt;), which is what the upload step bundles. And &lt;code&gt;include-hidden-files: true&lt;/code&gt; is required, because that directory is dot-prefixed and Actions skips hidden files by default. Fetching relies on the GitHub CLI being installed and authenticated, so it's GitHub-only for now; on any failure Pest falls back to recording locally and tells you why. If you don't have a Pest CI pipeline yet, my &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;GitHub Actions guide for Laravel&lt;/a&gt; is the place to start before wiring this in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Catch Nobody's Tweeting About
&lt;/h2&gt;

&lt;p&gt;The announcement numbers are real, but here's what to keep in mind before you assume your suite drops to 4 seconds tomorrow.&lt;/p&gt;

&lt;p&gt;TIA's speedup is proportional to how well-isolated your tests are. If most of your tests are unit tests touching a handful of classes, a small change reruns almost nothing and you get the headline numbers. If your suite is mostly full feature tests that boot the whole app and hit the database, more of them qualify as "affected" by any given change, and the win shrinks. The 10-minutes-to-4-seconds figure describes a well-structured suite, not a guaranteed outcome for every codebase.&lt;/p&gt;

&lt;p&gt;There's also a trust question worth being deliberate about. TIA is deciding not to run tests. That's the entire point, and the coverage-fidelity design is reassuring, but this is day-one software. The safe pattern is exactly what the config nudges you toward: &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt; for fast local replays, and full untia'd runs on CI as the source of truth. Let TIA speed up your inner loop first. Trust it to gate your deploys only once you've watched it behave on your real codebase for a while. That's not skepticism about Pest; it's how you'd adopt any tool that skips work on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Else Shipped in Pest 5
&lt;/h2&gt;

&lt;p&gt;TIA is the headline, but the release bundled several first-party plugins that matured across the Pest 4 cycle. Worth knowing they exist:&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Agent plugin&lt;/strong&gt; gives AI coding agents a single command to verify a change actually works against your real suite, and with the Browser plugin, in a real browser too. &lt;strong&gt;Evals&lt;/strong&gt; let you score LLM output and AI-generated results from inside your test suite, mixing deterministic checks with AI scorers. And &lt;strong&gt;PHPStan and Rector are now built in&lt;/strong&gt;, so static analysis and automated refactoring live alongside your tests instead of as separate tooling. If you're still on Pest 4, my &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;complete Pest 4 testing guide&lt;/a&gt; still applies for the fundamentals; Pest 5's testing API is the same, with these plugins layered on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need to change how I write tests to use Pest 5 TIA?
&lt;/h3&gt;

&lt;p&gt;No. TIA works with your existing tests unchanged. You add the &lt;code&gt;--tia&lt;/code&gt; flag (or configure it in &lt;code&gt;tests/Pest.php&lt;/code&gt;) and Pest builds the dependency graph automatically from a normal run. Your test files, expectations, and structure stay exactly as they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does TIA reduce my code coverage numbers?
&lt;/h3&gt;

&lt;p&gt;No, and this is the clever part. When Pest caches a test it stores the exact lines and branches that test covered, so a replayed run reports the same coverage as a full run. Coverage thresholds, the &lt;code&gt;--coverage&lt;/code&gt; report, and &lt;code&gt;--min&lt;/code&gt; all behave as if every test executed from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when I change a config or route file?
&lt;/h3&gt;

&lt;p&gt;Pest reruns the entire suite. Files outside the recorded dependency graph, including config and route files, can't be statically traced to specific tests, so TIA falls back to running everything rather than risk missing a regression. It errs toward safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can my whole team skip the slow first baseline run?
&lt;/h3&gt;

&lt;p&gt;Yes, if you're on GitHub. Record the baseline once in a CI workflow, upload it as the &lt;code&gt;pest-tia-baseline&lt;/code&gt; artifact, and enable &lt;code&gt;pest()-&amp;gt;tia()-&amp;gt;baselined()&lt;/code&gt;. Each developer's first &lt;code&gt;--tia&lt;/code&gt; run downloads that baseline and replays immediately instead of recording locally. It relies on the GitHub CLI, so it's GitHub-only for now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I trust TIA to gate my production deploys?
&lt;/h3&gt;

&lt;p&gt;Be cautious at first. TIA shipped at Laracon US 2026, so it's brand new. The sensible adoption path is &lt;code&gt;always()-&amp;gt;locally()&lt;/code&gt;: fast replays while you develop, full runs on CI as your source of truth. Once you've watched TIA behave correctly on your real suite over time, you can decide whether to lean on it further.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Pest 5 TIA need a specific PHP version?
&lt;/h3&gt;

&lt;p&gt;Yes. Pest 5 requires PHP 8.4 or greater and is built on PHPUnit 13. TIA also needs a coverage driver (PCOV or Xdebug) to record the baseline. If you're on an older PHP version, upgrading is a prerequisite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;TIA is the kind of feature that changes a daily habit rather than adding one. The slow test suite you run less often than you should, or gate behind a coffee break, becomes something fast enough to run after every save. Add &lt;code&gt;--tia&lt;/code&gt;, make sure you've got PHP 8.4 and a coverage driver, and let it speed up your local loop first. Wire up the CI baseline once your team wants the shared graph. And keep your CI running the full suite untia'd until you've earned trust in the replays on your own codebase.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>pest</category>
      <category>testing</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Laravel Scout in 2026: Meilisearch vs Typesense vs Algolia vs Database Driver</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 27 Jul 2026 06:41:24 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-scout-in-2026-meilisearch-vs-typesense-vs-algolia-vs-database-driver-2omi</link>
      <guid>https://dev.to/hafiz619/laravel-scout-in-2026-meilisearch-vs-typesense-vs-algolia-vs-database-driver-2omi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-scout-2026-meilisearch-vs-typesense-vs-algolia-vs-database" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel Scout gives you full-text search on your Eloquent models with one trait and a config value. What it doesn't give you is an opinion. &lt;a href="https://laravel.com/docs/13.x/scout" rel="noopener noreferrer"&gt;The docs&lt;/a&gt; list five drivers (Algolia, Meilisearch, Typesense, database, and collection) and treat them as interchangeable. They're not. The driver you pick decides whether search costs you nothing, a fixed monthly fee, or a bill that grows every time your product succeeds.&lt;/p&gt;

&lt;p&gt;I've shipped Scout with three of these drivers across client projects and my own products. This post is the comparison I wish existed: what each driver actually does, what it costs at real usage levels, and a straight answer on which one to pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Scout Actually Abstracts
&lt;/h2&gt;

&lt;p&gt;Quick grounding for anyone who hasn't used it. Scout syncs your models to a search index automatically. Add the &lt;code&gt;Searchable&lt;/code&gt; trait, and every create, update, and delete flows through to your search engine via model observers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Scout\Searchable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Searchable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toSearchableArray&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strip_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching is one line: &lt;code&gt;Post::search('pgbouncer')-&amp;gt;paginate(15)&lt;/code&gt;. The abstraction is the whole point: your application code stays identical across all five drivers, which means the driver decision is about operations and money, not code.&lt;/p&gt;

&lt;p&gt;One setting that isn't optional in production: &lt;code&gt;'queue' =&amp;gt; true&lt;/code&gt; in &lt;code&gt;config/scout.php&lt;/code&gt;. Without it, every model save blocks on an HTTP call to your search engine. Push index updates through &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;your queue workers&lt;/a&gt; and the write path stays fast. And a detail that surprises people: even with queueing off, Algolia and Meilisearch index asynchronously on their side. Your job finishing doesn't mean the record is searchable yet. Write your tests accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Real Contenders
&lt;/h2&gt;

&lt;p&gt;The collection driver filters records in PHP after pulling them all from the database. It exists for local development and works everywhere, including SQLite. It's not a production option, so it's out of the comparison. That leaves four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database driver&lt;/strong&gt; runs full-text indexes and LIKE clauses against MySQL or PostgreSQL. No new infrastructure, no sync problems (it queries your actual tables), no cost. Also: no typo tolerance, no relevance tuning worth the name, no facets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meilisearch&lt;/strong&gt; is an open-source engine written in Rust. Sub-50ms responses, typo tolerance, faceting, and highlighting out of the box with almost no configuration. Its defaults are good enough that most projects never touch relevance settings. Self-host it free (MIT license) or use Meilisearch Cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typesense&lt;/strong&gt; is the closest thing Meilisearch has to a twin: open-source, typo-tolerant, instant search, written in C++. It differentiates on operational predictability. Typesense Cloud gives you a dedicated cluster priced by RAM and CPU per hour, with no per-record or per-operation charges at all. It also has more mature vector and geo search than Meilisearch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Algolia&lt;/strong&gt; is the incumbent hosted service. The most polished dashboards, the best frontend libraries (InstantSearch), AI re-ranking, and enterprise features nobody else matches. You pay for it with usage-based pricing that scales with your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Comparison: Where They Actually Differ
&lt;/h2&gt;

&lt;p&gt;On core search quality (typo tolerance, prefix matching, ranking) Meilisearch, Typesense, and Algolia are closer than their marketing suggests. All three return results in tens of milliseconds and handle "labrvel" finding "laravel" without configuration. For a typical SaaS search box, users can't tell them apart.&lt;/p&gt;

&lt;p&gt;The differences live at the edges. Algolia leads on merchandising and AI features: synonyms management, A/B testing relevance, personalization. If you're building e-commerce search where a 2% conversion lift pays for the tooling, that matters. Typesense leads on vector search and its Search Delivery Network, which replicates your index across regions like a CDN. Meilisearch leads on getting to good results with the least configuration of the three.&lt;/p&gt;

&lt;p&gt;The database driver isn't playing the same sport. It matches words, roughly in the order your database returns them. Search for "posgres" and you get nothing, because there's no typo tolerance. For a public-facing product search, that's disqualifying. For an admin panel where your team searches customers by name or email, it's completely fine, and I'd argue anything more is over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Money: What Search Actually Costs
&lt;/h2&gt;

&lt;p&gt;This is where the comparison stops being close, so let's do real math. All prices verified this week; search providers change pricing often, so treat these as July 2026 numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Algolia&lt;/strong&gt; has a free Build tier (10,000 search requests a month) that's generous for prototyping. The paid Grow plan is pay-as-you-go: $0.50 per 1,000 search requests and $0.40 per 1,000 records beyond the included 10,000 requests and 100,000 records. Grow Plus, which adds the AI features, jumps to $1.75 per 1,000 requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meilisearch Cloud&lt;/strong&gt; starts at $30/month for the Build plan and climbs into the hundreds at the million-document tier. Self-hosting is free under MIT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typesense Cloud&lt;/strong&gt; bills the cluster, not the usage: pick RAM and CPU, pay per hour it runs. The smallest configurations work out to roughly $7-25/month, and you can throw as many searches at it as the hardware handles. Self-hosting is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database driver&lt;/strong&gt; costs zero. Your database is already running.&lt;/p&gt;

&lt;p&gt;Now the worked example. Say your app does 500,000 searches a month across 250,000 records, a realistic mid-sized SaaS:&lt;/p&gt;

&lt;p&gt;Algolia Grow: 490,000 billable requests ($245) plus 150,000 extra records ($60) lands around $305/month. Every new user who searches makes next month's bill bigger. Meilisearch Cloud at that document count sits in its higher tiers, roughly the $200-300/month range. Typesense Cloud handles that load comfortably on a small dedicated cluster in the $25-70/month range, and the bill doesn't move when traffic doubles until the hardware runs out. Self-hosted Meilisearch or Typesense on a basic Hetzner or DigitalOcean instance: about €5-10/month for the server, and the real cost is you being the person who gets paged.&lt;/p&gt;

&lt;p&gt;That's a 30x spread for the same feature. The pricing model matters more than the sticker price: Algolia charges you for growth, Typesense charges you for capacity, self-hosting charges you in responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Database Driver Is Honestly Enough
&lt;/h2&gt;

&lt;p&gt;The advice you'll find everywhere is "just use Meilisearch," and it's not wrong. But I want to defend the boring option, because I keep seeing projects run a search server for workloads that don't need one.&lt;/p&gt;

&lt;p&gt;Use the database driver when all of these are true: your searchable dataset is under roughly 100,000 rows, your users are internal or forgiving (admin panels, back-office tools, MVPs), and you're searching short fields like names, emails, and titles. MySQL and Postgres full-text indexes are fast at this scale, and &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;a proper index&lt;/a&gt; is the only tuning you'll ever do. You skip a service, a sync pipeline, a deploy dependency, and a monthly bill.&lt;/p&gt;

&lt;p&gt;Move up when users start expecting typo tolerance, when you need facets and filters on the search page, or when search becomes part of the product rather than a utility. You'll feel the moment. And because Scout abstracts the engine, the migration is a config change plus &lt;code&gt;php artisan scout:import&lt;/code&gt;, not a rewrite. That's the quiet superpower of building on Scout even when you start on the database driver.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-scout-2026-meilisearch-vs-typesense-vs-algolia-vs-database" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  My Recommendation
&lt;/h2&gt;

&lt;p&gt;For most Laravel apps in 2026: &lt;strong&gt;self-hosted Meilisearch&lt;/strong&gt; if you already run a VPS, &lt;strong&gt;Typesense Cloud&lt;/strong&gt; if you don't want to.&lt;/p&gt;

&lt;p&gt;Meilisearch wins the self-host case because it's the least operational search server I've run: one binary, sane defaults, and it sits happily in 512MB of RAM for small datasets. If you deploy with Docker or manage a Hetzner box already, adding it costs you an evening. Typesense wins the managed case because resource-based pricing means your search bill is a known number, and known numbers are worth a lot to a bootstrapped product. A &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS charging monthly&lt;/a&gt; should not have a cost line that scales per-search.&lt;/p&gt;

&lt;p&gt;Pick Algolia when its extras are the point: merchandising tools, the InstantSearch frontend stack, AI ranking for an e-commerce catalog where relevance is revenue. It's the best product of the three and priced like it knows.&lt;/p&gt;

&lt;p&gt;And pick the database driver more often than the internet suggests. Half the Scout setups I've reviewed could have been a full-text index.&lt;/p&gt;

&lt;p&gt;One operational note whichever engine you choose: if you're running &lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; or serious queue concurrency, keep Scout's queue on and give indexing jobs their own queue name. A bulk import re-indexing 100K models on the same queue as your password-reset emails is a bad afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can Laravel Scout do semantic or vector search?
&lt;/h3&gt;

&lt;p&gt;Scout itself is engine-agnostic, so it depends on the driver. Typesense has the most mature vector search of the three engines; Meilisearch's is newer and improving fast. If semantic search is central to your product, that's a different architecture conversation than a search box, and I've covered the options in my &lt;a href="https://hafiz.dev/blog/laravel-search-in-2026-full-text-semantic-and-vector-search-explained" rel="noopener noreferrer"&gt;guide to full-text, semantic, and vector search in Laravel&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Scout driver is fastest?
&lt;/h3&gt;

&lt;p&gt;For end-user perception, Meilisearch, Typesense, and Algolia are all effectively instant (tens of milliseconds) on typical datasets. The database driver is fast at small scale and degrades as tables grow and queries lean on LIKE. If you're choosing between the three engines on latency alone, you're optimizing the wrong variable; choose on cost model and features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does switching Scout drivers require code changes?
&lt;/h3&gt;

&lt;p&gt;Almost none, and that's Scout's best feature. Your models, &lt;code&gt;toSearchableArray()&lt;/code&gt;, and search calls stay the same. You change &lt;code&gt;SCOUT_DRIVER&lt;/code&gt;, add the new engine's credentials, and run &lt;code&gt;php artisan scout:import&lt;/code&gt; to build the index. The exceptions are engine-specific features like Typesense's schema definitions or Algolia-specific query options, which you'd need to recreate or remove.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the database driver production-ready?
&lt;/h3&gt;

&lt;p&gt;Yes, within its limits. It uses real full-text indexes on MySQL and PostgreSQL rather than naive LIKE scans, and it can't drift out of sync because it queries your actual tables. What it lacks is typo tolerance, ranked relevance, and facets. Internal tools and small catalogs: absolutely. Public product search where users type fast and expect Google-ish forgiveness: use a real engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Scout at all, or can I use Meilisearch directly?
&lt;/h3&gt;

&lt;p&gt;You can use any engine's SDK directly, and for complex multi-index setups some teams do. Scout earns its place through the automatic model syncing and the driver abstraction that makes engines swappable. For the common case of "make these models searchable," writing your own sync layer is reinventing a wheel Scout ships for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Scout's driver choice is a cost-model choice wearing a technical costume. The database driver is free and underrated for internal tools. Self-hosted Meilisearch is the sweet spot for developers who already run servers. Typesense Cloud buys you a predictable bill. Algolia buys you the most product for the most money, priced per unit of your own growth. Start boring, and let Scout's abstraction make the upgrade a config change instead of a project.&lt;/p&gt;

&lt;p&gt;Setting up search for a Laravel app, or trying to cut an Algolia bill that grew faster than revenue? I've done both and I'm happy to talk through your setup. &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelscout</category>
      <category>meilisearch</category>
      <category>search</category>
    </item>
    <item>
      <title>Laravel Telegram Bot: How I Cleared My YouTube Watch Later with AI Summaries</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:28:54 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-telegram-bot-how-i-cleared-my-youtube-watch-later-with-ai-summaries-1840</link>
      <guid>https://dev.to/hafiz619/laravel-telegram-bot-how-i-cleared-my-youtube-watch-later-with-ai-summaries-1840</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-telegram-bot-ai-watch-later-summaries" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;My YouTube Watch Later had 42 videos in it. I'd watched maybe ten. X bookmarks were worse because I couldn't even remember why I'd saved half of them. Every platform has a save button, and every save button leads to the same place: a list nobody ever opens again.&lt;/p&gt;

&lt;p&gt;The obvious fix is "build something that syncs those lists and reminds you." I tried. That version is impossible, and knowing why shapes the whole design. So I built the opposite: a Telegram bot backed by a small Laravel app that replaces the save button instead of syncing it. I forward a link, it fetches the content, summarizes it with the AI SDK, and then, once a day, it forces me to decide: watch, snooze, or archive.&lt;/p&gt;

&lt;p&gt;I've been running it for ten days. Here's the build, including the security bug that almost shipped and the fallback path that fires way more often than I expected.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneenk8bka3sgvo71ft7c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneenk8bka3sgvo71ft7c.webp" alt="A digest item marked as watched, with the video link unfurled below it in the chat" width="800" height="1377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Can't Sync Watch Later (So Stop Trying)
&lt;/h2&gt;

&lt;p&gt;The dream version of this tool connects to your accounts and pulls your saved items. Two hard walls and one soft one:&lt;/p&gt;

&lt;p&gt;YouTube's Data API deliberately excludes the Watch Later playlist. Access was removed years ago and never came back. You can read any playlist except that one.&lt;/p&gt;

&lt;p&gt;Facebook's saved items have no API at all.&lt;/p&gt;

&lt;p&gt;X bookmarks are the soft one. They do have an API, and since April 2026 reading your own data costs $0.001 per item, so pulling a thousand bookmarks runs about a dollar. But you still need a paid developer account with credits loaded and an OAuth flow to get there, and it only solves X. YouTube and Facebook stay shut either way.&lt;/p&gt;

&lt;p&gt;This is why no existing tool does real sync. The workaround is to own the capture step: don't read their list, become the list. On mobile that's the native share sheet, which already has Telegram in it. Share, tap the bot, done. On desktop it's a bookmarklet that POSTs the current URL to the app. Either way, the platform's save button never gets touched again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The stack is deliberately boring: Laravel 13, SQLite in WAL mode, the database queue driver, and the Telegram Bot API called through plain HTTP. No Telegram SDK package. The bot surface is four methods (sendMessage, editMessageText, answerCallbackQuery, setWebhook), and a thin service class wrapping Laravel's Http client covers them in about 60 lines. Zero extra infrastructure: no Redis, no Node process, no third-party queue.&lt;/p&gt;

&lt;p&gt;There's also no frontend. Telegram is the UI, which means the tool works on mobile, desktop, and web from day one without me writing a single screen.&lt;/p&gt;

&lt;p&gt;The flow from save to digest looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-telegram-bot-ai-watch-later-summaries" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The webhook does three jobs: it accepts new links, it answers commands like /stats, and it handles the button presses coming back from digest messages. Everything slow happens in &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;a queued job&lt;/a&gt;, so the bot always answers in under a second with "Saving..." before the real confirmation arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning URLs Is Half the Feature
&lt;/h2&gt;

&lt;p&gt;The first thing the SaveUrl action does is canonicalize. The same video arrives as youtube.com/watch, youtu.be, a Shorts URL, or any of those with tracking junk attached (si=, utm_*, the X s= and t= params). All of them collapse to one canonical form, so saving the same video twice gets caught as a duplicate instead of cluttering the inbox. Timestamps survive the cleaning because "watch from 18:30" is information I actually want.&lt;/p&gt;

&lt;p&gt;One small decision I like: re-saving something already watched or archived revives it back to the inbox. If I cared enough to save it twice, it's relevant again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Content Without Scraping Anything
&lt;/h2&gt;

&lt;p&gt;For YouTube, oEmbed gives me the title and channel for free, no API key. Duration comes from the Data API's videos.list if a key is present, and gets skipped when it's not. Transcripts are best-effort against the caption endpoints.&lt;/p&gt;

&lt;p&gt;For X, the public oEmbed endpoint at publish.twitter.com returns the tweet text and author for any public tweet. No auth, no paid tier. That's the entire "X integration."&lt;/p&gt;

&lt;p&gt;Everything else falls through to an article fetcher that grabs the title tag and meta description. Not glamorous, works fine.&lt;/p&gt;

&lt;p&gt;Then the queue job hands whatever content it collected to the AI SDK with a schema-validated structured output request: a two-to-four sentence summary plus three to five lowercase tags, returned as strict JSON. If you haven't used structured outputs in the SDK yet, &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;the smart assistant tutorial&lt;/a&gt; covers the pattern; it's the same idea here with a smaller schema.&lt;/p&gt;

&lt;p&gt;The app runs DeepSeek by default because summaries are a cheap-model task, but the provider is configuration, not code. Two env vars switch it to Anthropic or OpenAI, and &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-ollama-local-llms-guide" rel="noopener noreferrer"&gt;the same swap works for local models through Ollama&lt;/a&gt; if you want summaries that cost nothing. Since &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-goes-stable-what-changed-what-to-check" rel="noopener noreferrer"&gt;the SDK went stable&lt;/a&gt;, this kind of provider independence is the main reason I keep reaching for it in side projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Probably:" Fallback Fires More Than You'd Think
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. YouTube transcript fetching fails constantly. Captions are disabled, auto-captions aren't exposed, the endpoint shape shifts. I expected transcripts to be the normal path and the fallback to be rare. Ten days of real usage says it's closer to the other way around.&lt;/p&gt;

&lt;p&gt;So the fallback is a first-class feature, not an error state. When there's no transcript, the model summarizes from the title and channel alone, and the summary is prefixed with "Probably:" so I know it's an educated guess rather than a digest of the actual content. Treating low confidence as something to label instead of hide turned out to be the right call. A guessed summary still answers the only question that matters at digest time: do I still care about this?&lt;/p&gt;

&lt;p&gt;If I later want real transcripts, the escape hatch is downloading captions server-side per video. That's a v2 problem. For deciding watch-or-archive, "Probably:" is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Item a Day, Three Buttons
&lt;/h2&gt;

&lt;p&gt;Every evening at 19:00 the scheduler picks exactly one item and sends it with its summary and three inline buttons: Watch, Snooze 7d, Archive.&lt;/p&gt;

&lt;p&gt;The selection logic matters more than it looks. Due snoozes win first, oldest snooze first. Otherwise it picks from the inbox: never-surfaced items first, then whatever was surfaced longest ago. That rotation means ignoring a digest doesn't show you the same item every day forever, which is exactly the failure mode that makes reminder apps unbearable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwxzbn6gs2z98jilx17o.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwxzbn6gs2z98jilx17o.webp" alt="A digest item marked as watched, with the video link unfurled below it in the chat" width="800" height="1380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The buttons edit the original message in place. Tap Watch and the message updates to show the plain URL so it unfurls and I can tap through. Tap Archive and it becomes one line. No app, no tab, a two-second decision inside a chat I already have open.&lt;/p&gt;

&lt;p&gt;And because guilt is the actual product being fought here: anything untouched for 90 days gets auto-archived by a weekly job. The clock keys on updated_at, so snoozing counts as engagement and resets it. The list can only shrink.&lt;/p&gt;

&lt;p&gt;A /stats command replaces the dashboard I refused to build. Inbox count, watched, archived, watch rate, age of the oldest item. One message. That's the whole confession.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpzi72vtk8l2xs6lhfnm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpzi72vtk8l2xs6lhfnm.webp" alt="The /stats command output showing inbox count, watched, archived, watch rate and age of the oldest saved item" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Almost Shipped: hash_equals('', '')
&lt;/h2&gt;

&lt;p&gt;The first live smoke test caught something the 47 passing tests didn't. With a fresh .env where the secrets weren't filled in yet, every authenticated endpoint let everyone in.&lt;/p&gt;

&lt;p&gt;The webhook check compared the configured secret against the header Telegram sends. The ingest endpoint compared the configured token against the bearer token. Both used hash_equals(). And hash_equals('', '') returns true, because an empty string does equal an empty string. Blank config plus absent header equals open endpoint, on a public VPS.&lt;/p&gt;

&lt;p&gt;The tests never caught it because every test sets the secrets. Real config drift is exactly the case your test suite doesn't model.&lt;/p&gt;

&lt;p&gt;The fix is a rule worth stealing for any personal tool: fail closed when config is blank. Every auth check now short-circuits to a 403 if the configured secret is empty, before any comparison runs, with regression tests pinning it. If a required secret isn't set, the correct behavior is "nothing works," never "everything works."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Deliberately Didn't Build
&lt;/h2&gt;

&lt;p&gt;No user accounts, no dashboard, no Chrome extension, no Facebook support, no multi-tenancy. This is a single-user tool on my own VPS, and every one of those features would have turned a weekend build into a month of speculation about users who don't exist yet.&lt;/p&gt;

&lt;p&gt;That's also my honest recommendation if you build one: resist the SaaS reflex. The value here is that it's yours, it's small, and it costs nothing to run. SQLite plus the database queue on a box you already have is the whole bill. If strangers start asking to use it, that's a different project and a different decision, and it should be made then, with real people asking, not upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why can't it just read my existing YouTube Watch Later list?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the API doesn't allow it. YouTube removed Watch Later access from the Data API years ago, and no official way back exists. Any tool claiming to sync it is scraping, which breaks routinely. Replacing the save action is the reliable path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I don't configure an AI provider?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Links still get saved with full metadata (title, channel, duration). The bot just says no summary is configured. The AI layer is optional, not a dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Telegram instead of WhatsApp?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Telegram's Bot API is free, instant to set up, and supports inline buttons and message editing, which the digest depends on. WhatsApp's Business API costs money and requires approval. This isn't close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it handle X bookmarks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It replaces them rather than reading them. The bookmarks API is affordable now (roughly a dollar per thousand of your own items since April 2026), but it needs a funded developer account and an OAuth flow, and it only covers X. The public oEmbed endpoint returns any public tweet's text for free, so forwarding a tweet link gets you the same summarize-and-resurface treatment with no account at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does it decide which item to resurface?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Due snoozes first, then never-surfaced inbox items oldest-first, then the item that's gone longest without being shown. Ignoring the digest rotates the backlog instead of repeating one item, and anything untouched for 90 days archives itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch Rate Over Library Size
&lt;/h2&gt;

&lt;p&gt;Ten days in: 12 sitting in the inbox, 7 watched, 3 archived, one snoozed. That's a 70% watch rate on the things I've actually decided about, and I'll be honest that the number flatters me. These are still the early days and the sample is ten decisions. Ask again in a month when the novelty has worn off.&lt;/p&gt;

&lt;p&gt;The whole thing is on GitHub at &lt;a href="https://github.com/hzeeshan/watch-later" rel="noopener noreferrer"&gt;hzeeshan/watch-later&lt;/a&gt; if you want to run your own. It's a single-user tool by design, so expect to spend ten minutes on a bot token and a cron entry rather than a signup form.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>telegram</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Laravel PostgreSQL Connection Pooling: Native PgBouncer Support Is Here</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:31:23 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-postgresql-connection-pooling-native-pgbouncer-support-is-here-4ig7</link>
      <guid>https://dev.to/hafiz619/laravel-postgresql-connection-pooling-native-pgbouncer-support-is-here-4ig7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-postgresql-connection-pooling-native-pgbouncer-support" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel PostgreSQL connection pooling used to mean one thing: workarounds. If you've ever run Laravel against Supabase, Neon, or a self-hosted PgBouncer, you know the ritual. Set &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt;, install a community package to patch boolean bindings, keep a second connection config for migrations, and hope nobody on the team forgets which one is which.&lt;/p&gt;

&lt;p&gt;That ritual is over. Laravel 13.17 shipped framework-level support for Postgres transaction poolers. One config flag, an optional &lt;code&gt;direct&lt;/code&gt; block, and the framework handles everything the pooler requires. Emulated prepares, boolean binding, routing migrations around the pooler. All of it.&lt;/p&gt;

&lt;p&gt;This post covers what the feature does, why the problem existed in the first place, the exact setup for Supabase, Neon, and self-hosted PgBouncer, and which of your old hacks you can now delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres Connections Are Expensive
&lt;/h2&gt;

&lt;p&gt;If you come from MySQL (like most Laravel developers), this problem probably never bit you. MySQL handles connections with lightweight threads. Postgres forks an entire OS process per connection, and each one eats roughly 5-10MB of RAM on the server. The default &lt;code&gt;max_connections&lt;/code&gt; on most Postgres setups sits around 100.&lt;/p&gt;

&lt;p&gt;Now do the math for a typical Laravel deployment. PHP-FPM opens a fresh database connection for every request. A moderately busy app with 50 concurrent requests is already holding 50 Postgres processes. Add a queue worker or three, a scheduler, and a second app server, and you're staring at &lt;code&gt;FATAL: too many connections&lt;/code&gt; in your logs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; makes this worse, not better. Persistent workers hold their database connection for their entire lifetime. 32 Swoole workers means 32 permanent Postgres processes, most of them idle at any given moment. There's a years-old GitHub discussion of Octane users hitting exactly this wall and hand-rolling PgBouncer setups to survive it.&lt;/p&gt;

&lt;p&gt;The fix isn't more connections. It's a pooler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Transaction Pooler Actually Does
&lt;/h2&gt;

&lt;p&gt;A pooler like PgBouncer is a lightweight middleman between your app and Postgres. Your app opens hundreds of cheap connections to the pooler. The pooler multiplexes them over a small set of real Postgres connections, maybe 20.&lt;/p&gt;

&lt;p&gt;In transaction mode (the mode that matters here), a real connection is borrowed only for the duration of a single transaction. The moment your transaction commits, that connection goes straight back into the pool for the next request. This is how Supabase's Supavisor serves thousands of clients, how Neon's pooler endpoints work, and how AWS RDS Proxy keeps Lambda functions from melting a small RDS instance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-postgresql-connection-pooling-native-pgbouncer-support" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Transaction mode has one big catch: it breaks server-side prepared statements. Your next query might execute on a different real connection, one that never saw the &lt;code&gt;PREPARE&lt;/code&gt;. The same goes for session-level features like &lt;code&gt;SET&lt;/code&gt; commands and &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;. That catch is the entire reason Laravel needed framework support.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Pain: What We All Had to Do Before 13.17
&lt;/h2&gt;

&lt;p&gt;Before this release, running Laravel through a transaction pooler meant assembling your own solution from three parts.&lt;/p&gt;

&lt;p&gt;First, you disabled server-side prepares by adding &lt;code&gt;PDO::ATTR_EMULATE_PREPARES =&amp;gt; true&lt;/code&gt; to your connection's &lt;code&gt;options&lt;/code&gt; array. That made queries pooler-safe.&lt;/p&gt;

&lt;p&gt;But it introduced a second bug: with emulated prepares, Laravel sends PHP booleans to Postgres as &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;0&lt;/code&gt; instead of &lt;code&gt;'true'&lt;/code&gt; and &lt;code&gt;'false'&lt;/code&gt;, and Postgres rejects them. Entire community packages exist purely to patch this. Their whole job is a custom &lt;code&gt;PostgresConnection&lt;/code&gt; class that reformats boolean bindings. If you have one of those in your &lt;code&gt;composer.json&lt;/code&gt;, you already know.&lt;/p&gt;

&lt;p&gt;Third, migrations and DDL can't run through a transaction pooler at all. So you kept a second connection config pointing at the real database host and remembered to pass &lt;code&gt;--database=pgsql_direct&lt;/code&gt; to every &lt;code&gt;migrate&lt;/code&gt; call. Every deployment script grew a little ritual around it. Forget once and your migration hangs or fails in a way that takes twenty minutes to diagnose.&lt;/p&gt;

&lt;p&gt;None of this was hard, exactly. It was just fragile, undocumented tribal knowledge that every team rediscovered the painful way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.17 Ships
&lt;/h2&gt;

&lt;p&gt;PR #60425 (merged June 21, shipped in v13.17.0) moves all of that into the framework. Yes, the same release that gave us &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;route metadata&lt;/a&gt;. 13.17 was a good week. Interesting detail: Laravel Cloud has had internal pooled-connection handling since 2025, but it was private plumbing. This PR is the first time it's a public, documented API that any Laravel app can use.&lt;/p&gt;

&lt;p&gt;Here's the whole configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'pgsql'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pgsql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5432'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="s1"&gt;'pooled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_POOLED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'direct'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_PORT'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'sslmode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_SSLMODE'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main connection points at your pooler. The &lt;code&gt;direct&lt;/code&gt; block points at the real database. With &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt;, the framework does four things automatically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emulated prepares turn on for the pooled connection.&lt;/strong&gt; Direct connections keep native prepares. You never touch &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt; yourself again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boolean bindings get fixed.&lt;/strong&gt; Under emulated prepares, Postgres now receives &lt;code&gt;'true'&lt;/code&gt; and &lt;code&gt;'false'&lt;/code&gt; strings instead of integer literals. Delete the community patch package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema operations route around the pooler.&lt;/strong&gt; Migrations, &lt;code&gt;schema:dump&lt;/code&gt;, schema load, &lt;code&gt;db:wipe&lt;/code&gt;, &lt;code&gt;db:show&lt;/code&gt;, and &lt;code&gt;db:table&lt;/code&gt; all use the direct connection without you passing any flags. Your deployment script gets simpler, not more complicated. The full command list lives in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt; if you want to check what else touches the schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;php artisan db&lt;/code&gt; defaults to direct.&lt;/strong&gt; When pooled mode is on and a direct endpoint exists, the interactive database CLI connects directly, which is what you want for poking at the schema. Pass &lt;code&gt;--pooled&lt;/code&gt; if you specifically want to inspect behavior through the pooler.&lt;/p&gt;

&lt;p&gt;And when your own application code needs the real connection (say, a &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; listener or a long-running cursor), append the &lt;code&gt;::direct&lt;/code&gt; suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pgsql::direct'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LISTEN order_events'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backward compatibility is clean. Without &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt;, nothing changes. Existing &lt;code&gt;::read&lt;/code&gt; and &lt;code&gt;::write&lt;/code&gt; suffixes keep their meaning. Adding a &lt;code&gt;direct&lt;/code&gt; block alone doesn't reroute anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up With Real Providers
&lt;/h2&gt;

&lt;p&gt;The config shape is identical everywhere. The only thing that changes is where the pooled and direct hosts come from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase
&lt;/h3&gt;

&lt;p&gt;Supabase gives you two connection paths. The direct connection runs on port 5432 at your project's database host. The transaction pooler (Supavisor) runs on port 6543 at a regional pooler host. Both are on your project's Connect page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_HOST=aws-0-eu-central-1.pooler.supabase.com
DB_PORT=6543
DB_POOLED=true
DB_DIRECT_HOST=db.yourproject.supabase.co
DB_DIRECT_PORT=5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your app traffic flows through Supavisor. Your migrations hit the database directly. No custom connection classes, no session-mode compromises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Neon
&lt;/h3&gt;

&lt;p&gt;Neon exposes the pooler through a hostname suffix: take your endpoint host and add &lt;code&gt;-pooler&lt;/code&gt; to it. Same credentials for both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_HOST=ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech
DB_POOLED=true
DB_DIRECT_HOST=ep-cool-name-123456.eu-central-1.aws.neon.tech
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Neon specifically for the scale-to-zero pricing, this pairing matters more than it looks. Pooled connections are what let a tiny compute instance handle bursty traffic without connection churn waking it into a bigger bill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Hosted PgBouncer
&lt;/h3&gt;

&lt;p&gt;Run PgBouncer with &lt;code&gt;pool_mode = transaction&lt;/code&gt; in &lt;code&gt;pgbouncer.ini&lt;/code&gt;, point &lt;code&gt;DB_HOST&lt;/code&gt; at the PgBouncer port (usually 6432), and point the &lt;code&gt;direct&lt;/code&gt; block at Postgres itself on 5432. That's it. All the &lt;code&gt;ignore_startup_parameters&lt;/code&gt; and prepared-statement tuning advice you'll find in old tutorials was written for the pre-13.17 world. The framework handles the client side now.&lt;/p&gt;

&lt;p&gt;One honest note for Octane users: a pooler doesn't reduce how many connections your workers open. It makes those connections cheap. 32 workers still hold 32 client connections, but they multiplex over a fraction of the real Postgres processes, which is the resource that actually runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Verify It's Actually Working
&lt;/h2&gt;

&lt;p&gt;Don't trust the config, check the behavior. Three quick tests after enabling &lt;code&gt;pooled&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;First, confirm your app traffic goes through the pooler. Run a request, then check active server connections from a direct session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_database'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under load, that number should stay small and stable (your pool size) while your app happily serves far more concurrent requests. If it climbs with concurrency, you're not actually connecting to the pooler endpoint.&lt;/p&gt;

&lt;p&gt;Second, confirm schema routing. Run &lt;code&gt;php artisan db:show&lt;/code&gt;. It should report the direct host, not the pooler host. Then run a throwaway migration on staging and watch it complete without hanging. That's the automatic routing doing its job.&lt;/p&gt;

&lt;p&gt;Third, if you had the boolean problem before, hit a code path that writes a boolean column. On 13.17 with &lt;code&gt;pooled =&amp;gt; true&lt;/code&gt; it just works. No exception about invalid input syntax for type boolean, no patch package in sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Turn This On?
&lt;/h2&gt;

&lt;p&gt;If you're on Supabase, Neon, RDS Proxy, or any managed Postgres with a pooler endpoint: yes, and this feature removes the last good excuse not to. The old objection was that pooler setups in Laravel were fragile hand-rolled things. That objection died in 13.17.&lt;/p&gt;

&lt;p&gt;If you're self-hosting Postgres for a small app with steady traffic, you don't need a pooler yet. A single server with PHP-FPM and modest concurrency lives comfortably inside default connection limits. Add PgBouncer when you see connection pressure, not before. &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;Database indexing&lt;/a&gt; will buy you more performance per hour invested until then.&lt;/p&gt;

&lt;p&gt;The setup where I'd call it non-negotiable: Octane on Postgres, or anything serverless. Persistent workers and scale-to-zero databases are both connection-hungry patterns, and transaction pooling is the standard answer in every ecosystem, ours included.&lt;/p&gt;

&lt;p&gt;One thing to check before enabling it on an existing app: emulated prepares change how query plans are cached server-side, and code that relied on session state between queries (temporary tables, &lt;code&gt;SET&lt;/code&gt; commands, advisory locks held across requests) will behave differently through a pooler. That's pooler physics, not a Laravel limitation. Audit for those patterns first. Multi-tenant apps that switch databases per tenant should test carefully too, since &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;tenant connection strategies&lt;/a&gt; often assume dedicated connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which Laravel version do I need for native pooler support?
&lt;/h3&gt;

&lt;p&gt;Laravel 13.17 or newer. The feature landed via PR #60425 and shipped in v13.17.0 in late June 2026. If you're still on Laravel 12, the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;upgrade to 13&lt;/a&gt; is worth it for this alone if you run Postgres in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with MySQL or MariaDB?
&lt;/h3&gt;

&lt;p&gt;No. The &lt;code&gt;pooled&lt;/code&gt; option is specific to the PostgreSQL driver, because the problem it solves (process-per-connection cost plus prepared statements breaking under transaction pooling) is specific to Postgres. MySQL's threaded connection model doesn't need this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need packages that patch PgBouncer boolean bindings?
&lt;/h3&gt;

&lt;p&gt;Not on 13.17+. Correct boolean binding under emulated prepares is now framework behavior. Remove the package, remove any custom &lt;code&gt;PostgresConnection&lt;/code&gt; class you copied from a gist, and remove &lt;code&gt;PDO::ATTR_EMULATE_PREPARES&lt;/code&gt; from your connection options. The framework sets it for pooled connections automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to migrations if I don't configure a direct block?
&lt;/h3&gt;

&lt;p&gt;Then there's nothing to route to, and schema operations go through your main connection like before. If that connection is a transaction pooler, DDL can fail or hang, exactly as it always did. The direct block is what makes the automatic routing possible, so treat it as required whenever &lt;code&gt;pooled&lt;/code&gt; is true.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does pooling change anything for read/write connection splits?
&lt;/h3&gt;

&lt;p&gt;No. Explicit &lt;code&gt;::read&lt;/code&gt; and &lt;code&gt;::write&lt;/code&gt; suffixes and the &lt;code&gt;--read&lt;/code&gt;/&lt;code&gt;--write&lt;/code&gt; CLI flags keep working exactly as before and aren't rerouted to the direct connection. The &lt;code&gt;::direct&lt;/code&gt; suffix is a separate, additional routing option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This is one of those features where the code change in your app is five lines of config and the actual win is everything you get to delete: the options array hack, the patch package, the second migration config, the deploy-script flags, and the tribal knowledge explaining why they all exist. Laravel apps on Supabase and Neon just got noticeably simpler to run, and Octane on Postgres finally has a first-party answer to connection exhaustion.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Filament Billing: The Missing Stripe Admin Layer for Your Panel</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 06:02:55 +0000</pubDate>
      <link>https://dev.to/hafiz619/filament-billing-the-missing-stripe-admin-layer-for-your-panel-4lfp</link>
      <guid>https://dev.to/hafiz619/filament-billing-the-missing-stripe-admin-layer-for-your-panel-4lfp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/filament-billing-the-missing-stripe-admin-layer-for-your-panel" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You wired up Laravel Cashier, connected Stripe, and your app takes payments. Subscriptions get created, webhooks fire, the &lt;code&gt;subscriptions&lt;/code&gt; table fills up. Everything works.&lt;/p&gt;

&lt;p&gt;Then a customer emails asking to cancel. Or you want to know what your MRR actually is this month. Or someone's payment failed and you need to see who's past due.&lt;/p&gt;

&lt;p&gt;Where do you go? Not your admin panel. Cashier doesn't put anything there. You open the Stripe dashboard, or you write a tinker one-liner, or you build a one-off resource every single time. The billing runs fine, but managing it lives outside the tool you built everything else in.&lt;/p&gt;

&lt;p&gt;That gap is the whole reason I built Filament Billing. It's a commercial plugin that adds the operator side of billing to your Filament panel: plans, subscriptions, and an MRR widget, all reading from the Cashier tables you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cashier gives you, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Cashier is really good at what it does. It handles the Stripe API calls, the subscription lifecycle, the webhook parsing, the proration math. If you've set up billing with it before (and if you haven't, my &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe integration guide&lt;/a&gt; walks through the whole thing), you know it takes care of the hard parts.&lt;/p&gt;

&lt;p&gt;But Cashier is a library, not an interface. It writes &lt;code&gt;stripe_status&lt;/code&gt; to your database when a webhook arrives. It does not give you a screen to see that status. It cancels a subscription when you call &lt;code&gt;-&amp;gt;cancel()&lt;/code&gt; in code. It does not give your support person a button to do it.&lt;/p&gt;

&lt;p&gt;So most teams end up in one of three places. They pop open the Stripe dashboard for everything, which means context-switching out of their own app and giving more people Stripe access than they'd like. They write throwaway tinker commands, which works until you need it at 11pm and can't remember the syntax. Or they build custom Filament resources by hand, which is fine the first time and annoying the fifth.&lt;/p&gt;

&lt;p&gt;I've done all three. The third one is what this plugin is, except built once, properly, instead of half-built on every new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Filament Billing actually adds
&lt;/h2&gt;

&lt;p&gt;Three things, all operator-facing.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Plans resource&lt;/strong&gt; where you define your billing plans inside the panel. Name, price, currency, the policy behind it. This is your reference layer for what you sell.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrffv30b07viuwf8bu2p.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrffv30b07viuwf8bu2p.webp" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Subscriptions resource&lt;/strong&gt; that reads your Cashier subscription tables and shows them as a proper Filament list. Each row has the actions you'd expect: cancel at period end, cancel now, resume, swap. No tinker, no Stripe dashboard. Your support person can handle a cancellation without touching code or getting a Stripe login.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizp5d5e9pfblwwac70yl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizp5d5e9pfblwwac70yl.webp" alt="Filament Billing subscriptions list showing customer plans, statuses, and cancel, resume, and swap row actions" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;MRR widget&lt;/strong&gt; for your dashboard. It shows monthly recurring revenue, active subscriber count, and how many people are in trial. It derives the number from the plans it sums, so the currency matches your data instead of a hardcoded symbol. If you've read my &lt;a href="https://hafiz.dev/blog/building-admin-dashboards-with-filament-a-complete-guide-for-laravel-developers" rel="noopener noreferrer"&gt;admin dashboards guide&lt;/a&gt;, this is the metrics-at-a-glance idea applied to billing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pjyhm5zzzs19yqrfjut.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6pjyhm5zzzs19yqrfjut.webp" alt="Filament Billing MRR dashboard widget showing monthly recurring revenue, active subscribers, and in-trial counts" width="799" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also Stripe webhook status sync that keeps subscription statuses current as Stripe events arrive. That part builds on Cashier's own webhook handling, so it works as long as you've got Stripe webhooks wired to your app the normal way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for, and who it isn't
&lt;/h2&gt;

&lt;p&gt;I'd rather you know before you buy than ask for a refund after, so here's the honest scope.&lt;/p&gt;

&lt;p&gt;This is an &lt;strong&gt;operator-side&lt;/strong&gt; tool. It's for you and your team to manage billing from the admin panel. It is not a customer-facing checkout. Your customers still create subscriptions the way they already do through Cashier. Filament Billing gives you the layer to manage what those flows produce, not a replacement for them.&lt;/p&gt;

&lt;p&gt;It's &lt;strong&gt;Stripe only&lt;/strong&gt;, through Cashier. If you're on Paddle, this isn't for you yet.&lt;/p&gt;

&lt;p&gt;You &lt;strong&gt;define your plans in the panel&lt;/strong&gt;. It doesn't auto-sync from Stripe on install. You enter what you sell once, and manage from there. (Pulling plans straight from the Stripe API is the feature I'm most likely to add next, but I'd rather ship the honest version now than promise sync that isn't there.)&lt;/p&gt;

&lt;p&gt;And it needs &lt;strong&gt;Filament v5 and Laravel 11 or 12&lt;/strong&gt;, with Cashier for Stripe. Filament v5 shipped mainly to support Livewire v4, with no functional changes from v4, so if you're on a recent v4 app the upgrade is quick. If you want to know exactly what changed, I covered that in my &lt;a href="https://hafiz.dev/blog/filament-v5-released-whats-new-what-changed-and-should-you-upgrade" rel="noopener noreferrer"&gt;Filament v5 breakdown&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building a Laravel SaaS on Filament and you've already got Cashier taking payments, you're the exact person I built this for. If you're still deciding between billing providers, my &lt;a href="https://hafiz.dev/blog/laravel-cashier-stripe-vs-paddle-real-cost-comparison" rel="noopener noreferrer"&gt;Cashier Stripe vs Paddle cost comparison&lt;/a&gt; is worth a read first, because this plugin commits you to the Stripe side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I shipped it narrow
&lt;/h2&gt;

&lt;p&gt;I've made the mistake before of building a big, clever thing nobody used. So this one is deliberately small. Operator-side only. Stripe only. No customer checkout, no dunning flows, no churn analytics, no Paddle. Just the three pieces that solve the actual daily pain: seeing subscriptions, acting on them, and knowing your MRR.&lt;/p&gt;

&lt;p&gt;That narrowness is a feature. It means the plugin does what it says, installs cleanly, and doesn't drag half-finished functionality into your app. If it turns out people want plan-sync or Paddle or a customer portal, those come later, driven by what buyers actually ask for, not by what I imagined in advance. This is the same ship-narrow-to-validate approach I wrote about in my &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;Building a SaaS with Laravel and Filament guide&lt;/a&gt;, where I built this billing layer by hand the first time. I've since packaged that admin side into this plugin so you don't have to rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and how to get it
&lt;/h2&gt;

&lt;p&gt;Two one-time tiers: €69 for a single project, €149 for unlimited projects. No subscription, no recurring fee for the plugin itself. You buy it, you get the private Composer repository access, you install it with your license key. You can &lt;a href="https://checkout.anystack.sh/filament-billing" rel="noopener noreferrer"&gt;grab it on Anystack here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Installation is the standard Anystack flow: configure the repository, add your license credentials, &lt;code&gt;composer require hafizdev/filament-billing&lt;/code&gt;, then run the install command. It reads your existing Cashier setup and adds the resources and widget to your panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this replace Laravel Cashier?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. It sits on top of Cashier and reads its tables. You still need Cashier installed and configured for Stripe. Think of this as the admin interface Cashier never shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can my customers use it to subscribe or manage their own plans?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not in this version. It's operator-side only. Your customers create subscriptions through your existing Cashier flows; this is for you and your team to manage them from the panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, Stripe only for now. Paddle support depends on demand, so if that's a blocker for you, let me know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to be on Filament v5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, v5 with Laravel 11 or 12. Since v5 exists mainly for Livewire v4 support with no functional changes from v4, upgrading a recent v4 app is quick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it pull my plans from Stripe automatically?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not yet. You define your plans in the panel once. Automatic sync from the Stripe API is the most likely next addition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it or ask me
&lt;/h2&gt;

&lt;p&gt;If you're running Cashier on a Filament panel and you're tired of opening Stripe to cancel a subscription, this is the layer that fixes it. It's &lt;a href="https://checkout.anystack.sh/filament-billing" rel="noopener noreferrer"&gt;live now on Anystack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Building something with Filament and Stripe and want to talk through whether this fits, or what's missing for your case? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;. I read every message, and buyer questions are what shape where this goes next.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>stripe</category>
      <category>laravelcashier</category>
    </item>
    <item>
      <title>What's New in Laravel 13.15: Typed Translations and More</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:38:15 +0000</pubDate>
      <link>https://dev.to/hafiz619/whats-new-in-laravel-1315-typed-translations-and-more-4764</link>
      <guid>https://dev.to/hafiz619/whats-new-in-laravel-1315-typed-translations-and-more-4764</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/whats-new-laravel-13-15-typed-translations" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most point releases are safe to skim. Laravel 13.15 has one change that's a reason to update today, not eventually, so it's worth two minutes even if you skip the rest.&lt;/p&gt;

&lt;p&gt;That change is a security fix for the &lt;code&gt;date_equals&lt;/code&gt; validation rule, where an invalid date could slip past validation entirely under the right conditions. If you validate dates anywhere near authentication, scheduling, or access windows, read that section. The other three headline changes (typed translation accessors, JSON Schema unions, and a dedicated Cloud queue driver) are quality-of-life improvements you'll appreciate but won't lose sleep over. Here's what each one actually does.&lt;/p&gt;

&lt;p&gt;Quick note on timing: Laravel ships minor releases weekly, so by the time you read this, 13.16 through 13.19 are already out too (13.19 added HTTP query method support). None of that changes the 13.15 features below, and since these are all backward-compatible minor releases, a &lt;code&gt;composer update&lt;/code&gt; gets you everything at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Fix You Should Actually Care About
&lt;/h2&gt;

&lt;p&gt;Start here because it's the one with teeth.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;date_equals&lt;/code&gt; validation rule was using loose comparison under the hood. That sounds harmless until you trace what PHP does with a bad date string. An invalid date parses to &lt;code&gt;null&lt;/code&gt;. And in PHP, &lt;code&gt;null == 0&lt;/code&gt; evaluates to &lt;code&gt;true&lt;/code&gt;. So if your reference date parsed to something falsy, like the Unix epoch &lt;code&gt;1970-01-01 00:00:00&lt;/code&gt;, an invalid date string could satisfy &lt;code&gt;date_equals&lt;/code&gt; and pass validation.&lt;/p&gt;

&lt;p&gt;Picture a rule like this guarding a form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'event_date'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|date_equals:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$referenceDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;$referenceDate&lt;/code&gt; resolves to a falsy timestamp and an attacker submits garbage that parses to null, the loose comparison waves it through. That's a validation bypass, and validation bypasses are exactly the kind of quiet bug that doesn't show up until someone goes looking for it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/laravel/framework/releases/tag/v13.15.0" rel="noopener noreferrer"&gt;fix&lt;/a&gt; uses strict comparison for the equality check while keeping loose comparison for legitimate &lt;code&gt;DateTime&lt;/code&gt; objects, so real dates still compare the way you expect. There's nothing to change in your code. You update the framework and the hole closes. That's the whole reason this release jumps the queue.&lt;/p&gt;

&lt;p&gt;The same release also tightened route unserialization, restricting which classes the router will accept when unserializing during route caching and resolution. It's a smaller surface than the date bug, but it shrinks the room for object-injection tricks during &lt;code&gt;route:cache&lt;/code&gt;. Another reason the update is worth doing sooner rather than later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typed Translation Accessors
&lt;/h2&gt;

&lt;p&gt;Now the headline feature, and it's one that anyone running strict static analysis will like.&lt;/p&gt;

&lt;p&gt;Laravel's translation helpers have always returned broad types. &lt;code&gt;__()&lt;/code&gt; returns &lt;code&gt;array|string|null&lt;/code&gt;, and &lt;code&gt;trans()&lt;/code&gt; returns &lt;code&gt;Translator|array|string&lt;/code&gt;. That's fine in a Blade template where you're just echoing a string. It's friction everywhere else, because a method that's typed to return &lt;code&gt;string&lt;/code&gt; can't just return &lt;code&gt;__('some.key')&lt;/code&gt; without PHPStan or Psalm complaining that it might get an array or null.&lt;/p&gt;

&lt;p&gt;13.15 adds two typed accessors on the &lt;code&gt;Translator&lt;/code&gt; that return a concrete type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;trans&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;trans&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;options_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;trans()-&amp;gt;string()&lt;/code&gt; guarantees a string back. &lt;code&gt;trans()-&amp;gt;array()&lt;/code&gt; guarantees an array. The pattern mirrors the typed accessors Laravel already ships on &lt;code&gt;config()&lt;/code&gt; and &lt;code&gt;request()&lt;/code&gt;, so if you've used &lt;code&gt;config()-&amp;gt;string('app.name')&lt;/code&gt;, this will feel familiar immediately.&lt;/p&gt;

&lt;p&gt;It's a small thing. But if your codebase runs Larastan at a high level (and the starter kits now ship at level 7), these two methods delete a category of annoying casts and &lt;code&gt;assert()&lt;/code&gt; calls you were writing just to satisfy the analyzer. Small friction, removed everywhere at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON Schema Unions for AI Structured Output
&lt;/h2&gt;

&lt;p&gt;This one's narrow but it matters if you're building with the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;JsonSchema&lt;/code&gt; component picked up two related capabilities. First, a &lt;code&gt;fromArray()&lt;/code&gt; deserializer that turns a raw JSON Schema array back into &lt;code&gt;Type&lt;/code&gt; objects, which is the inverse of the serialization that was already there. Second, and more useful day to day, the schema builder now supports multi-type unions through &lt;code&gt;anyOf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why does &lt;code&gt;anyOf&lt;/code&gt; matter? Structured output is where you force a model to return JSON matching a schema you define. Sometimes a field really can be one of several shapes. A support ticket's &lt;code&gt;resolution&lt;/code&gt; might be a string when it's a simple note, or an object when it's a structured escalation. Before &lt;code&gt;anyOf&lt;/code&gt;, you couldn't express "this field is one of these distinct schemas" cleanly. Now you can, and since both OpenAI and Gemini support &lt;code&gt;anyOf&lt;/code&gt; in their structured output APIs, it passes straight through to the provider.&lt;/p&gt;

&lt;p&gt;If you're already returning typed responses from agents, this widens what you can model without dropping back to a loose &lt;code&gt;string&lt;/code&gt; field and parsing it yourself. If you haven't touched the AI SDK yet, this is a detail to file away rather than act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Dedicated Cloud Queue Driver
&lt;/h2&gt;

&lt;p&gt;If you deploy on Laravel Cloud and use its managed queues, several changes in 13.15 add up to a proper first-party queue driver rather than a generic one bent to fit.&lt;/p&gt;

&lt;p&gt;The practical differences: managed queues now boot before service providers, so the queue connection is ready earlier in the lifecycle. A missing configured queue throws a clear &lt;code&gt;ManagedQueueNotFoundException&lt;/code&gt; instead of failing in some vaguer way. FIFO queue name normalization was corrected. And the request ID header was renamed from &lt;code&gt;X-Request-ID&lt;/code&gt; to &lt;code&gt;Cloud-Request-ID&lt;/code&gt;, now surfaced in your logs, which is the kind of thing you'll be grateful for the first time you're tracing a request through a queued job in production.&lt;/p&gt;

&lt;p&gt;There's a related nicety too: &lt;code&gt;Queue::route()&lt;/code&gt; now accepts enum cases for both the queue name and the connection. If you've moved your queue names into a backed enum to stop typing magic strings, you can pass the enum directly instead of calling &lt;code&gt;-&amp;gt;value&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;p&gt;If you're not on Laravel Cloud, none of this affects you. If you are, it's a quiet reliability upgrade to the part of your stack you least want surprises in. The broader queue setup worth knowing is in the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs guide&lt;/a&gt;, and the &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;managed queues vs Horizon comparison&lt;/a&gt; covers the trade-off if you're deciding between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smaller Fixes Worth Knowing
&lt;/h2&gt;

&lt;p&gt;A few more changes that won't headline a release but will save someone an afternoon:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Number&lt;/code&gt; helper got three edge cases fixed. &lt;code&gt;Number::fileSize()&lt;/code&gt; now handles negative byte values instead of producing nonsense, &lt;code&gt;Number::trim()&lt;/code&gt; stops returning null for &lt;code&gt;INF&lt;/code&gt; and &lt;code&gt;NAN&lt;/code&gt;, and &lt;code&gt;Number::pairs()&lt;/code&gt; handles negative step values while throwing a clear exception on a zero step so you don't get an infinite loop. If you've ever fed user-derived numbers into these, the hardening is welcome.&lt;/p&gt;

&lt;p&gt;Two infinite-recursion bugs got squashed: one when a model scope is defined using a private attribute, and one when a middleware group references itself. Both are the kind of mistake that's easy to make in a large app and miserable to debug, so having the framework fail sanely instead of hanging is a real improvement.&lt;/p&gt;

&lt;p&gt;And a small operational win: &lt;code&gt;queue:failed&lt;/code&gt; now shows the real class name of failed jobs instead of an unhelpful placeholder, which makes triaging a failed queue that much faster. The full list of queue and Artisan commands is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt; if you want to see what else is available there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Update?
&lt;/h2&gt;

&lt;p&gt;Yes, and specifically for the &lt;code&gt;date_equals&lt;/code&gt; fix if you validate dates anywhere sensitive. That's not a "nice to have when you get around to it" change, it's a closed security hole, and the update is a backward-compatible &lt;code&gt;composer update&lt;/code&gt; with no code changes required.&lt;/p&gt;

&lt;p&gt;The rest is upside you collect for free on the way. Typed translation accessors clean up strict-typed code, &lt;code&gt;anyOf&lt;/code&gt; widens what you can model with AI structured output, and the Cloud queue driver makes managed queues more predictable. None of it breaks anything, because these are minor releases and Laravel doesn't ship breaking changes in those.&lt;/p&gt;

&lt;p&gt;If you're several minor versions behind, the update also pulls in everything from 13.16 through the latest, so it's worth checking the changelog for anything relevant to your app beyond what's here. If you're on an older major version entirely, the &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers that jump first.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need to change any code to get the date_equals security fix?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The fix is entirely inside the framework's validation logic. Running &lt;code&gt;composer update&lt;/code&gt; to pull in Laravel 13.15 or later closes the bypass, and legitimate &lt;code&gt;DateTime&lt;/code&gt; comparisons keep working exactly as before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between trans()-&amp;gt;string() and __()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;__()&lt;/code&gt; returns &lt;code&gt;array|string|null&lt;/code&gt;, which forces casts or assertions in strictly-typed code. &lt;code&gt;trans()-&amp;gt;string()&lt;/code&gt; guarantees a string return type, and &lt;code&gt;trans()-&amp;gt;array()&lt;/code&gt; guarantees an array. They're for typed method bodies and static analysis, not for replacing &lt;code&gt;__()&lt;/code&gt; in Blade, where the broad type is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the JSON Schema anyOf support work with all AI providers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;anyOf&lt;/code&gt; support is in Laravel's schema builder, and both OpenAI and Gemini accept &lt;code&gt;anyOf&lt;/code&gt; in their structured output APIs, so it passes through to those providers. Support for other providers depends on whether their structured output API accepts unions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the Cloud queue driver useful if I don't use Laravel Cloud?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. These changes specifically target Laravel Cloud's managed queues. If you run your own queue infrastructure with Redis, SQS, or a database driver, they don't apply, though &lt;code&gt;Queue::route()&lt;/code&gt; accepting enums is a general improvement everyone gets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Laravel 13.15 the latest version?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Probably not by the time you read this. Laravel ships minor releases roughly weekly, so 13.16 and beyond are likely already out. Because minor releases are backward-compatible, updating gets you 13.15's features plus everything newer in one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Laravel 13.15 is mostly a quiet release, and that's fine. Not every version needs a marquee feature. The typed translation accessors are a clean quality-of-life win, the &lt;code&gt;anyOf&lt;/code&gt; support quietly expands what the AI SDK can do, and the Cloud queue driver makes managed queues more predictable.&lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;date_equals&lt;/code&gt; fix is the one that changes your calendar. Update for that, collect the rest as a bonus, and move on.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>laravel13</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel Pennant: The Complete Feature Flags Guide</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:31:46 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-pennant-the-complete-feature-flags-guide-210j</link>
      <guid>https://dev.to/hafiz619/laravel-pennant-the-complete-feature-flags-guide-210j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-pennant-complete-feature-flags-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most Laravel apps already have feature flags. They just live in the worst possible place: a &lt;code&gt;config/features.php&lt;/code&gt; file full of booleans, an &lt;code&gt;.env&lt;/code&gt; variable read in three different controllers, or an &lt;code&gt;if ($user-&amp;gt;id === 1)&lt;/code&gt; that someone swore they'd remove before merging. It works until it doesn't, and then you're deploying code to change who sees a feature, which is the exact thing feature flags are supposed to prevent.&lt;/p&gt;

&lt;p&gt;Laravel Pennant is the first-party fix. It's small, it has no external service to pay for, and once the mental model clicks it replaces every one of those hacks with one consistent API. But that mental model has one sharp edge that trips up almost everyone the first time, and most tutorials skip right past it. So this guide covers the whole thing: defining flags, scoping them to users or teams, percentage rollouts, rich values, testing, and the sticky-storage behavior that will silently break your first rollout if you don't know it's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Pennant
&lt;/h2&gt;

&lt;p&gt;Nothing surprising here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/pennant

php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Laravel&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ennant&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ennantServiceProvider"&lt;/span&gt;

php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publish step drops a &lt;code&gt;config/pennant.php&lt;/code&gt; file and a migration that creates a &lt;code&gt;features&lt;/code&gt; table. That table is where Pennant's default driver stores resolved flag values, and understanding what "resolved" means there is the key to the whole package. More on that shortly.&lt;/p&gt;

&lt;p&gt;Pennant ships two storage drivers. The &lt;code&gt;database&lt;/code&gt; driver (the default) persists resolved values in that &lt;code&gt;features&lt;/code&gt; table, so a value survives across requests, deployments, and server restarts. The &lt;code&gt;array&lt;/code&gt; driver keeps values in memory for a single request and forgets them after, which makes it the right choice for tests. You pick the default in &lt;code&gt;config/pennant.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining Your First Flag
&lt;/h2&gt;

&lt;p&gt;Flags are defined with the &lt;code&gt;Feature::define&lt;/code&gt; method, usually in a service provider's &lt;code&gt;boot&lt;/code&gt; method. You give the flag a name and a closure that resolves its value. The closure receives the "scope", which by default is the currently authenticated user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Providers/AppServiceProvider.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This flag is active for users who registered in the last 30 days. Everyone else gets the old checkout. Checking it anywhere in your app is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'checkout.new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'checkout.legacy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Feature::active()&lt;/code&gt; checks against the current user. To check a specific scope, use &lt;code&gt;for()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Blade, there's a directive that reads cleaner than an inline check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@feature('new-checkout')
    &amp;lt;x-checkout.new /&amp;gt;
@else
    &amp;lt;x-checkout.legacy /&amp;gt;
@endfeature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire happy path. If Pennant were only this, it'd be a nicer syntax for booleans. What makes it worth adopting is scope and persistence, which is also where the trap lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing That Trips Everyone Up
&lt;/h2&gt;

&lt;p&gt;Here's the behavior that surprises people, and it's worth stopping on because it will bite you otherwise.&lt;/p&gt;

&lt;p&gt;With the database driver, a flag's value is resolved once per scope and then stored. The closure runs the first time you check the flag for a given user. After that, Pennant reads the saved value from the &lt;code&gt;features&lt;/code&gt; table and never runs your closure again for that user.&lt;/p&gt;

&lt;p&gt;Read that again, because the consequence is not obvious. If you define a flag as a 10% rollout, check it for your users, and then later change the closure to 25%, nothing happens. The users who were already resolved keep whatever value they got the first time. Your edit to the closure is ignored for everyone who's already been evaluated.&lt;/p&gt;

&lt;p&gt;This is a feature, not a bug. It's what gives you a consistent user experience: a user who got the new checkout doesn't randomly lose it on their next request because a lottery re-rolled. But if you don't know it's happening, your first percentage rollout will look completely broken. You'll bump the number, refresh, and see no change, and you'll think Pennant is ignoring you.&lt;/p&gt;

&lt;p&gt;The fix is to purge the stored values so they re-resolve against the new closure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan pennant:purge new-checkout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the next check for each user re-runs the closure at the new percentage. Treat this like a deploy step: whenever you change a flag's resolution logic and want existing users re-evaluated, purge it. I script it into the deploy alongside the migration, the same way you'd run &lt;code&gt;route:cache&lt;/code&gt; (the full command reference is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands guide&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Percentage Rollouts With Lottery
&lt;/h2&gt;

&lt;p&gt;The most common reason to reach for flags is the gradual rollout: ship to 10% of users, watch your error rates, then widen. Pennant leans on Laravel's &lt;code&gt;Illuminate\Support\Lottery&lt;/code&gt; helper for this, and the &lt;a href="https://laravel.com/docs/13.x/pennant" rel="noopener noreferrer"&gt;official Pennant documentation&lt;/a&gt; uses the same pattern.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Lottery&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isInternalTester&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Lottery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;odds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internal testers always get the feature. Everyone else has a 1-in-10 chance, resolved once and then stored. Because the result is sticky, a user who wins the lottery keeps the feature on every subsequent request instead of flickering on and off as the lottery re-rolls. That consistency is the whole point, and it's also why widening to 25% later means editing the odds and running &lt;code&gt;pennant:purge&lt;/code&gt; so unresolved and previously-resolved users alike get evaluated against the new number.&lt;/p&gt;

&lt;p&gt;This is the exact interaction that surprises people, so it's worth saying once more in context: the closure and the storage work together. The closure decides the value the first time; storage remembers it forever after. Rollout percentage changes are storage operations, not code edits alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping Flags to Teams and Accounts
&lt;/h2&gt;

&lt;p&gt;The default scope is the authenticated user, but plenty of rollouts happen per team or per account, not per person. If one member of a company sees the new billing screen and their colleague doesn't, that's a support ticket waiting to happen.&lt;/p&gt;

&lt;p&gt;Scope to the team by type-hinting it in the closure and passing it to &lt;code&gt;for()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Team&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'enterprise'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;currentTeam&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get tired of writing &lt;code&gt;for()&lt;/code&gt; everywhere, implement the &lt;code&gt;HasFeatures&lt;/code&gt; trait's default-scope method or set a global default scope in your service provider so every check resolves against the team automatically. For a SaaS app, setting the default scope to the current team once is usually cleaner than threading &lt;code&gt;for()&lt;/code&gt; through every call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Null Scope Trap in Queues and Commands
&lt;/h2&gt;

&lt;p&gt;This one pairs with the sticky-storage gotcha as the second thing that bites people, and it shows up specifically outside the HTTP request.&lt;/p&gt;

&lt;p&gt;Your flag closure type-hints &lt;code&gt;User&lt;/code&gt;. That's fine in a controller where someone's logged in. But check the same flag inside a queued job, an Artisan command, or an unauthenticated route, and there's no authenticated user, so the scope is &lt;code&gt;null&lt;/code&gt;. When the scope is null and your closure doesn't accept null, Pennant skips the closure entirely and returns &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a flag that's active for a user in the browser silently reads as inactive when the same logic runs in a queued job processing that user's data. If your job's behavior depends on the flag, it quietly takes the wrong branch.&lt;/p&gt;

&lt;p&gt;Two ways to handle it. Either pass the scope explicitly in contexts that have no authenticated user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside a job that knows which user it's working on&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or make the closure null-safe so it fails predictably instead of surprisingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The explicit-scope version is what I reach for, because a flag that depends on a user should always be told which user. Relying on the ambient authenticated user is exactly how the browser-versus-queue mismatch sneaks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rich Values: Flags That Return More Than True or False
&lt;/h2&gt;

&lt;p&gt;Flags don't have to be boolean. A closure can return a string, and &lt;code&gt;Feature::active()&lt;/code&gt; treats anything except literal &lt;code&gt;false&lt;/code&gt; as active. This turns a flag into a lightweight A/B test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Arr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purchase-button'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Arr&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'blue-sapphire'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'seafoam-green'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tart-orange'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieve the resolved value instead of just checking active state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purchase-button'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'seafoam-green'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the value is sticky per scope, each user keeps their assigned variant across requests, which is exactly what you want for a clean A/B test. No separate experiment table, no cookie juggling. And you can pull several at once with &lt;code&gt;Feature::values(['billing-v2', 'purchase-button'])&lt;/code&gt; or grab everything defined for the current scope with &lt;code&gt;Feature::all()&lt;/code&gt;, which is handy for passing the whole flag set to your frontend in one payload.&lt;/p&gt;

&lt;p&gt;One caveat on &lt;code&gt;Feature::all()&lt;/code&gt;: class-based features that haven't been checked yet during the request won't appear, because Pennant only knows about them once they're resolved. If you're building a flag dashboard or hydrating an Inertia page with every flag, be aware some may be missing until they're touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pennant and Route Metadata
&lt;/h2&gt;

&lt;p&gt;Last week's post on &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;route metadata&lt;/a&gt; gated a whole route group behind a feature flag using one middleware that read a &lt;code&gt;feature&lt;/code&gt; key off the route. Pennant is the other half of that pattern, so here's the complete version.&lt;/p&gt;

&lt;p&gt;Tag the routes with metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;BillingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InvoiceController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then one middleware reads the tag and asks Pennant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it on the web group, and every flagged route is gated with zero per-route boilerplate. Deleting the rollout is deleting one metadata line. This is the payoff of the two features together: route metadata declares which flag guards a route, Pennant decides whether it's on, and the middleware is the five lines that connect them. It's a cleaner separation than scattering &lt;code&gt;Feature::active()&lt;/code&gt; checks through your controllers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Feature Flags
&lt;/h2&gt;

&lt;p&gt;Flags that change behavior need tests for both branches, and Pennant makes forcing a flag's state trivial. In a test, redefine or activate the flag before the assertion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shows the new checkout when the flag is on'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/checkout'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertViewIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'checkout.new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shows the legacy checkout when the flag is off'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/checkout'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertViewIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'checkout.legacy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because your test suite should use the &lt;code&gt;array&lt;/code&gt; driver, nothing persists between tests and each one starts clean. Redefining the flag at the top of a test overrides whatever your service provider set, so you control exactly which branch runs. This is also why the sticky-storage behavior never bites you in tests: the array driver forgets everything after each request.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Pennant Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;Honest boundaries, because not every toggle needs a flag package.&lt;/p&gt;

&lt;p&gt;If a value never changes per user and never rolls out gradually, it's config, not a feature flag. &lt;code&gt;config('services.stripe.enabled')&lt;/code&gt; doesn't belong in Pennant. Pennant earns its place when a toggle varies by scope or rolls out incrementally, not when it's a global on/off switch you set once.&lt;/p&gt;

&lt;p&gt;If you need flag changes managed by non-engineers through a dashboard, audit logs, scheduled rollouts, and multi-environment targeting, a hosted service like LaunchDarkly does things Pennant doesn't try to. Pennant is a library, not a product. It has no UI. For a lot of teams that's the point, but if your product managers need to flip flags themselves, Pennant alone won't get you there without building the dashboard yourself.&lt;/p&gt;

&lt;p&gt;And if you're reaching for flags to manage long-lived branches, the flag is treating a symptom. Feature flags pair best with &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;trunk-based development and small, self-contained changes&lt;/a&gt;, where each push is shippable and the flag just controls visibility. A flag wrapped around a six-month branch is a merge conflict with extra steps.&lt;/p&gt;

&lt;p&gt;For the common case, though, incrementally rolling out a feature to real users on your own infrastructure with no monthly bill, Pennant is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does changing a Pennant flag's closure update existing users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, and this catches nearly everyone. With the database driver, a flag resolves once per scope and stores the result. Editing the closure doesn't re-evaluate users who were already resolved. Run &lt;code&gt;php artisan pennant:purge {flag}&lt;/code&gt; to clear stored values and force re-resolution against the new logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my feature flag return false inside a queued job?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the scope is null. Jobs, Artisan commands, and unauthenticated routes have no authenticated user, so a closure that type-hints &lt;code&gt;User&lt;/code&gt; (without allowing null) gets skipped and Pennant returns false. Pass the scope explicitly with &lt;code&gt;Feature::for($user)-&amp;gt;active(...)&lt;/code&gt;, or make the closure accept a nullable scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between Pennant's array and database drivers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;database&lt;/code&gt; driver persists resolved values in the &lt;code&gt;features&lt;/code&gt; table, so they survive across requests and deployments. That's the production default. The &lt;code&gt;array&lt;/code&gt; driver keeps values in memory for one request only, which is what you want in tests so nothing leaks between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Pennant do percentage-based rollouts?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Use &lt;code&gt;Illuminate\Support\Lottery&lt;/code&gt; inside the closure to activate a flag for a percentage of scopes. Remember that values are sticky once resolved, so raising the percentage later requires a &lt;code&gt;pennant:purge&lt;/code&gt; for existing users to be re-evaluated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a separate service like LaunchDarkly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not for most Laravel apps. Pennant covers scoped flags, rich values, and gradual rollouts natively with no external dependency. Reach for a hosted service when you need a non-engineer-facing dashboard, audit trails, or complex multi-environment targeting that Pennant deliberately leaves out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Pennant is small enough to learn in an afternoon and useful enough to keep forever. The API is the easy part. The two things that actually matter are the ones tutorials skip: values are sticky per scope until you purge them, and a null scope in a job or command silently returns false. Get those two into your muscle memory and everything else is just &lt;code&gt;Feature::active()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Start by replacing your ugliest &lt;code&gt;.env&lt;/code&gt; boolean with a real flag. Once the first one's in and you've watched a rollout behave, you'll wonder why the config file ever held that logic.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>pennant</category>
      <category>featureflags</category>
    </item>
    <item>
      <title>Laravel Octane in 2026: FrankenPHP vs Swoole vs RoadRunner</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:07:04 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-octane-in-2026-frankenphp-vs-swoole-vs-roadrunner-3cme</link>
      <guid>https://dev.to/hafiz619/laravel-octane-in-2026-frankenphp-vs-swoole-vs-roadrunner-3cme</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;In April 2026, a carefully controlled benchmark put all three Laravel Octane servers through a real Laravel workload. RoadRunner more than doubled PHP-FPM's throughput. Swoole, the server with a decade-long reputation as PHP's performance monster, finished dead last. Below plain FPM.&lt;/p&gt;

&lt;p&gt;A month later, a hosting company that has migrated dozens of Laravel apps to Octane published their production numbers. Their verdict on Swoole: it's their fastest option for API-heavy workloads, and they deploy it deliberately for exactly those customers.&lt;/p&gt;

&lt;p&gt;Both sources are competent. Both published their methodology. Both are right.&lt;/p&gt;

&lt;p&gt;That contradiction is the most useful thing you can learn about choosing an Octane server, because it tells you the question "which server is fastest?" has no universal answer. The useful question is which server matches your workload, your deployment model, and your team's appetite for operational complexity. This post covers what Octane actually changes, how the three servers differ architecturally, why the benchmarks disagree, and a decision framework you can apply to your own app. I'll also tell you when the honest answer is to not use Octane at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Octane Actually Changes
&lt;/h2&gt;

&lt;p&gt;A standard Laravel request on PHP-FPM boots the entire framework from zero. Service providers register, config loads, the container wires up, and then your code finally runs. For a typical app that boot costs 10 to 30ms per request, more if you're carrying Filament, Nova, or a heavy stack of packages. Then the request ends and everything is thrown away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/13.x/octane" rel="noopener noreferrer"&gt;Octane&lt;/a&gt; inverts that. The application boots once per worker, stays resident in memory, and each incoming request reuses the already-built framework. The boot tax disappears from every request except the first.&lt;/p&gt;

&lt;p&gt;That's the whole trick. Octane doesn't make your database queries faster, doesn't cache your responses, and doesn't parallelize your code (with one Swoole-shaped exception we'll get to). If your endpoint spends 200ms in MySQL, Octane saves you the 20ms boot and leaves the 200ms untouched. This is why I tell people to read their profiler before reading Octane benchmarks: if framework bootstrap isn't a meaningful slice of your response time, start with &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;query optimization&lt;/a&gt; instead. It's usually the bigger win and it carries zero migration risk.&lt;/p&gt;

&lt;p&gt;Installation is the same regardless of server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/octane

php artisan octane:install &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frankenphp
&lt;span class="c"&gt;# or --server=swoole, --server=roadrunner&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Octane supports four servers: FrankenPHP, Swoole, Open Swoole, and RoadRunner. Open Swoole is a community fork with the same Octane feature set as Swoole, so practically the choice is between three architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Servers, Three Architectures
&lt;/h2&gt;

&lt;p&gt;The performance and failure characteristics of each server come directly from how a request physically reaches your PHP code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FrankenPHP&lt;/strong&gt; is a Go binary built on the Caddy web server, with the official PHP interpreter embedded directly into the process via cgo. There's no proxy hop and no inter-process communication: Caddy accepts the request and hands it to a PHP thread inside the same process, where your booted Laravel app is waiting. Because Caddy is the web server, you get HTTP/2, HTTP/3, and automatic HTTPS without putting Nginx in front. Octane downloads the FrankenPHP binary for you when you pick it during install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoadRunner&lt;/strong&gt; is also written in Go, but it takes the opposite approach to integration. It runs as a standalone server binary that manages a pool of ordinary PHP processes, forwarding requests to them over a fast RPC protocol. Your PHP workers are regular processes with real isolation: if one worker corrupts its state or dies, RoadRunner respawns it and the others never notice. It's been doing this job since 2018 and has the most mature plugin ecosystem of the three (queues, gRPC, key-value storage).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swoole&lt;/strong&gt; is different in kind, and the difference goes deeper than plumbing. It's a C extension installed via PECL that replaces PHP's execution model with an event loop and coroutines, closer to Node.js than to classic PHP. When your code hits blocking I/O, Swoole can suspend that coroutine and run another request on the same worker. That's a capability the other two simply don't have, and it's also why Swoole demands the most from your code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-octane-2026-frankenphp-vs-swoole-vs-roadrunner" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The architectural differences cascade into everything else, so let's take each server on its own terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  FrankenPHP: The Deployment Story
&lt;/h2&gt;

&lt;p&gt;FrankenPHP is the youngest of the three, released in late 2023 and hardened through 2024 and 2025. What it changed isn't raw speed. It's how little infrastructure you need.&lt;/p&gt;

&lt;p&gt;One binary is your web server, TLS manager, static file server, and PHP runtime. A production Dockerfile is this small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; dunglas/frankenphp&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /app&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["php", "artisan", "octane:frankenphp"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One container, one port, no Nginx, no supervisor config. If you deploy to Cloud Run, Kubernetes, or any autoscaling container platform, that shape maps perfectly. And on classic VPS setups, HTTPS with automatic certificate renewal comes from Caddy for free. When you need to go beyond the defaults, &lt;code&gt;php artisan octane:start --server=frankenphp --caddyfile=/path/to/Caddyfile&lt;/code&gt; gives you full Caddy configuration.&lt;/p&gt;

&lt;p&gt;The honest trade-offs: it's the youngest option, and youth shows. One hosting provider that runs it in production reports hitting two memory-leak regressions over 18 months, both fixed promptly upstream, but that's a different stability track record than RoadRunner's. Configuration is Caddyfile syntax rather than the Nginx configs most Laravel developers have committed to muscle memory. And in benchmarks driven by HTTP/1.0 tools, FrankenPHP looks unremarkable, for reasons we'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swoole: The Ceiling and the Cliff
&lt;/h2&gt;

&lt;p&gt;Swoole has the highest performance ceiling of the three, and the steepest cliff next to it.&lt;/p&gt;

&lt;p&gt;The ceiling comes from coroutines. While FrankenPHP and RoadRunner workers handle one request at a time, Swoole's runtime can juggle many, suspending whichever one is waiting on the database. For endpoints that fan out to multiple upstream services, Octane exposes this directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Octane\Facades\Octane&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$flags&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Octane&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;concurrently&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard:stats'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three operations, dispatched to Swoole's task workers and executed concurrently while your request waits for the results. You size that pool with --task-workers on octane:start. &lt;code&gt;Octane::concurrently()&lt;/code&gt;, ticks, intervals, and the Swoole-backed Octane cache and tables are exclusive to Swoole and Open Swoole. Neither FrankenPHP nor RoadRunner can offer them because their workers don't have a coroutine scheduler.&lt;/p&gt;

&lt;p&gt;The cliff: Swoole changes PHP's execution semantics, and code written for classic synchronous PHP doesn't always survive the change. Extensions can conflict, some packages misbehave inside coroutines, and Xdebug famously doesn't cooperate, so production profiling means Blackfire or Tideways. It's also an extension you compile via PECL rather than a binary Octane downloads for you, which makes your Docker images and CI more bespoke. The production shops that get the most from Swoole recycle workers aggressively (max_requests around 250 versus 500 on RoadRunner) because memory grows faster.&lt;/p&gt;

&lt;p&gt;My blunt read: Swoole is a specialist's tool. If you're not going to use &lt;code&gt;concurrently()&lt;/code&gt; or your workloads don't have real I/O concurrency, you're paying Swoole's complexity tax for a capability you never invoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  RoadRunner: The Boring Choice, Compliment Intended
&lt;/h2&gt;

&lt;p&gt;RoadRunner wins on exactly one axis, and it happens to be the axis that matters most for a lot of teams: predictability.&lt;/p&gt;

&lt;p&gt;Its worker isolation model means a leak or crash in one PHP process can't poison its siblings. The Go server respawns dead workers and traffic continues. It has seven-plus years of production history, and its configuration lives in a plain YAML file that does what it says. In the April 2026 benchmark that embarrassed Swoole, RoadRunner delivered 111.6% more throughput than PHP-FPM on the same hardware with a 41% lower p99. Not because it's exotic, but because boot-once plus process isolation is a simple model that performs consistently across workload shapes.&lt;/p&gt;

&lt;p&gt;Setup needs two extra packages alongside Octane, and the &lt;code&gt;rr&lt;/code&gt; binary is offered for download on first start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/octane spiral/roadrunner-cli spiral/roadrunner-http

php artisan octane:start &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;roadrunner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you give up: no HTTP/3 or automatic HTTPS (you'll keep Nginx or Caddy in front), no coroutines, and a plugin ecosystem (gRPC, queues) that most Laravel apps never touch because Laravel's own &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue system&lt;/a&gt; already covers that ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Benchmarks Disagree
&lt;/h2&gt;

&lt;p&gt;Back to the contradiction from the intro, because resolving it is what makes you dangerous in this decision.&lt;/p&gt;

&lt;p&gt;The April 2026 benchmark that placed Swoole below PHP-FPM used &lt;code&gt;ab&lt;/code&gt;, a load tool that speaks HTTP/1.0 with short-lived connections, against a light-I/O Laravel route. Two things follow. First, Swoole's entire advantage is concurrent I/O scheduling; on a workload with barely any I/O wait, its coroutine scheduler is pure overhead, so it loses. Second, FrankenPHP's HTTP/2 multiplexing never gets exercised over HTTP/1.0, so it benchmarks like plain FPM while its real-world strength stays invisible.&lt;/p&gt;

&lt;p&gt;Meanwhile, the production shop reporting Swoole as their fastest option runs it on API workloads full of upstream calls and database fan-out, with &lt;code&gt;concurrently()&lt;/code&gt; in active use. That's precisely the workload where coroutines pay. Same server, opposite conclusion, and both measurements are honest.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: every Octane benchmark measures its own workload and tooling as much as it measures the server. A hosting provider's before-and-after on a real authenticated endpoint (4 to 5x throughput moving FPM to FrankenPHP with Octane) is more predictive for a typical app than any synthetic table. And your own app benchmarked for an afternoon beats both. So treat published numbers as bounds, not rankings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision Framework
&lt;/h2&gt;

&lt;p&gt;Here's how I'd actually choose in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick FrankenPHP if you deploy in containers or want the simplest possible stack.&lt;/strong&gt; Cloud Run, Kubernetes, Docker-based platforms, or a VPS where you'd rather not maintain Nginx. It's also the right default if you're new to Octane: the binary auto-installs, and the single-process model has the fewest moving parts to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick RoadRunner if you're migrating a large existing app and stability is the priority.&lt;/strong&gt; The process isolation gives you the softest failure modes while you shake out the state bugs every migration surfaces. It's the conservative choice, and for a revenue-carrying monolith, conservative is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Swoole only if you'll use what makes it Swoole.&lt;/strong&gt; Many concurrent upstream calls per request, real fan-out, a team comfortable auditing packages for coroutine safety. Then the ceiling is real and nothing else reaches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick none of them if your profiler says boot time isn't your problem.&lt;/strong&gt; A 300ms endpoint that spends 260ms in queries gets nothing meaningful from Octane. The &lt;a href="https://hafiz.dev/blog/transform-your-laravel-app-performance-7-quick-wins-that-actually-work" rel="noopener noreferrer"&gt;cheap performance wins&lt;/a&gt; come first; Octane is what you reach for after them, when traffic and infrastructure cost make the boot tax worth eliminating. My own apps still run plain FPM on small droplets, and that's deliberate: at their traffic, Octane would add operational surface without moving anything a user can feel. The &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;infrastructure cost math&lt;/a&gt; only tips toward Octane when you're scaling servers to handle load that's mostly boot overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotchas That Apply to All Three
&lt;/h2&gt;

&lt;p&gt;Whichever server you pick, the migration risk is identical, because it comes from Octane's model rather than the server underneath.&lt;/p&gt;

&lt;p&gt;Your app now lives in memory across requests. Anything static, any singleton, any memoized value persists into the next request. The classic bug is a package caching the authenticated user in a singleton, so request N+1 sees request N's user. Audit your singletons, use Octane's flush hooks for anything request-scoped, and test with two different logged-in users hitting the same worker.&lt;/p&gt;

&lt;p&gt;Memory grows. Every long-running PHP process leaks a little, so set &lt;code&gt;--max-requests&lt;/code&gt; and let workers recycle themselves. Deploys change too: &lt;code&gt;php artisan octane:reload&lt;/code&gt; swaps workers with zero downtime, and it belongs in your deploy script (the full command list is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands reference&lt;/a&gt;). And remember each worker handles one request at a time (Swoole's coroutines aside), so a slow request blocks a worker slot. Anything slow belongs on a queue, same as always.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Laravel Octane faster than PHP-FPM for every application?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Octane eliminates the framework boot cost, roughly 10 to 30ms per request on a typical app. If your response time is dominated by database queries or external APIs, Octane changes almost nothing. Profile first: it pays off when boot time is a meaningful fraction of your responses and traffic is high enough that the saved milliseconds compound into fewer servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which Octane server is easiest to set up?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;FrankenPHP. Octane downloads the binary for you during &lt;code&gt;octane:install&lt;/code&gt;, and because it bundles Caddy you don't need a separate web server for HTTPS, HTTP/2, or HTTP/3. RoadRunner is close behind (two extra Composer packages, auto-downloaded binary). Swoole requires compiling a PECL extension, which makes it the most involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to rewrite my code for Octane?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rewrite, no. Audit, yes. Static properties, singletons, and anything memoized will persist between requests, which is the source of nearly all Octane bugs. Most well-structured apps need a handful of fixes, not an architectural change. Third-party packages are the bigger unknown, so test the critical paths under a single worker with &lt;code&gt;--max-requests=1&lt;/code&gt; to surface state leaks quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Open Swoole?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fully supported by Octane and functionally equivalent for Octane's purposes: you get the same concurrent tasks, ticks, and intervals as Swoole. It's a community fork, so the choice between them usually comes down to which extension your platform and PHP version support more cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Octane work with Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Octane is a first-party package and the Laravel 13 documentation covers all four servers. Install with &lt;code&gt;composer require laravel/octane&lt;/code&gt; and pick your server during &lt;code&gt;octane:install&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The 2026 Octane question isn't "which server wins the benchmark". It's a matching problem. FrankenPHP matches container-native deployments and small teams that want one binary doing everything. RoadRunner matches big migrations where boring is a feature. Swoole matches I/O-heavy APIs run by teams who'll actually use its concurrency. And plain PHP-FPM still matches every app whose profiler says boot time isn't the bottleneck, which is more apps than the benchmark posts admit.&lt;/p&gt;

&lt;p&gt;Pick by workload, verify with an afternoon of load testing on your own endpoints, and be suspicious of any comparison (including this one) that hands you a single winner.&lt;/p&gt;

&lt;p&gt;Planning an Octane migration for a production Laravel app and want a second pair of eyes on the state-leak audit and server choice? &lt;a href="mailto:contact@hafiz.dev"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>performance</category>
      <category>octane</category>
    </item>
    <item>
      <title>Laravel Route Metadata: 5 Real Problems It Finally Solves</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 06 Jul 2026 07:08:27 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-route-metadata-5-real-problems-it-finally-solves-7gn</link>
      <guid>https://dev.to/hafiz619/laravel-route-metadata-5-real-problems-it-finally-solves-7gn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-route-metadata-5-real-problems-it-finally-solves" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When route metadata got merged into Laravel 13.17, the first comment on the &lt;a href="https://github.com/laravel/framework/pull/60530" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; was a long-time framework contributor asking what it's actually for. His follow-up was blunter: "IMO none of this behavior belongs on the Route, but oh well."&lt;/p&gt;

&lt;p&gt;I get the skepticism. On paper, "attach arbitrary data to routes" sounds like a solution looking for a problem. But here's the thing: Laravel developers have been attaching arbitrary data to routes for years. We just did it badly. Config files keyed by route name. Abused &lt;code&gt;-&amp;gt;defaults()&lt;/code&gt; calls that leak into controller parameters. Naming conventions like &lt;code&gt;admin.reports.legacy.v1&lt;/code&gt; that encode three different facts into a string and pray nobody renames the route. A GitHub discussion from 2022 asked for exactly this feature and got zero answers.&lt;/p&gt;

&lt;p&gt;So the question isn't whether attaching data to routes is a good idea. You're already doing it. The question is whether you keep doing it through hacks or use the supported, cache-safe API that ships in every Laravel app from 13.17 onwards.&lt;/p&gt;

&lt;p&gt;This post covers what shipped, the merge rules that make groups powerful, and five concrete problems where route metadata replaces something uglier. Working code for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel 13.17 Actually Shipped
&lt;/h2&gt;

&lt;p&gt;Three methods. That's the whole API.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;metadata()&lt;/code&gt; attaches an array of data to a route or a group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Http\Controllers\UserController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getMetadata()&lt;/code&gt; reads it back from the resolved route, with dot notation and an optional default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'head.title'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// 'Users'&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'head.author'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'Hafiz' (fallback)&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// the full array&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;setMetadata()&lt;/code&gt; replaces everything instead of merging, for the rare case where you want a route to opt out of whatever its group defined.&lt;/p&gt;

&lt;p&gt;The part that makes this more than a glorified array property: metadata is stored on the route action under a dedicated &lt;code&gt;metadata&lt;/code&gt; key. That means it flows through the existing route group logic and gets serialized by &lt;code&gt;route:cache&lt;/code&gt;. Your metadata survives production route caching with zero extra work. Anything you'd find with &lt;code&gt;php artisan route:list&lt;/code&gt; still resolves the same way (the full command reference is in the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;Laravel Artisan Commands guide&lt;/a&gt; if you need it).&lt;/p&gt;

&lt;p&gt;Resources and singletons work too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every route the resource generates carries the metadata. Before 13.17 there was no clean way to do this, because resource routes are registered in a pending state and you couldn't hook data onto them mid-registration.&lt;/p&gt;

&lt;h3&gt;
  
  
  The merge rules worth memorizing
&lt;/h3&gt;

&lt;p&gt;Group metadata cascades down to every route inside the group. The merge follows two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Associative arrays merge recursively. Nested groups layer values on top of each other.&lt;/li&gt;
&lt;li&gt;Lists and scalar values replace what they inherit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's both rules in one example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'robots'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'noindex'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'author'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Hafiz'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// $request-&amp;gt;route()-&amp;gt;getMetadata('head') returns:&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//     'robots' =&amp;gt; ['noindex'],  // inherited from the group&lt;/span&gt;
&lt;span class="c1"&gt;//     'author' =&amp;gt; 'Hafiz',      // inherited from the group&lt;/span&gt;
&lt;span class="c1"&gt;//     'title'  =&amp;gt; 'Users',      // added by the route&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route added &lt;code&gt;title&lt;/code&gt; without losing &lt;code&gt;robots&lt;/code&gt; or &lt;code&gt;author&lt;/code&gt;. But if the route had defined its own &lt;code&gt;robots&lt;/code&gt; list, it would have replaced the group's list entirely, not merged with it. Lists replace. Associative keys merge. Once that clicks, the whole feature is predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Hacked This Before
&lt;/h2&gt;

&lt;p&gt;Every workaround for this problem had a real cost. Quick tour, because recognizing your own code in this list is the fastest way to see why the feature matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom keys in the action array.&lt;/strong&gt; You could always smuggle extra keys into the route action and read them back with &lt;code&gt;getAction()&lt;/code&gt;. It worked for single routes. It fell apart for groups, resources, and singletons, because there was no supported way to carry the data through the route-building pipeline. That's the exact gap the PR closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Abusing &lt;code&gt;-&amp;gt;defaults()&lt;/code&gt;.&lt;/strong&gt; Route defaults exist to provide default values for route parameters. Stuffing page titles or permission strings in there technically works, until a controller method with a matching parameter name silently receives your metadata as an argument. I've debugged that one. Not fun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config maps keyed by route name.&lt;/strong&gt; A &lt;code&gt;config/seo.php&lt;/code&gt; file with a hundred route names mapped to titles and descriptions. Now every route rename is a two-file change, nothing enforces the mapping stays in sync, and the data lives nowhere near the route it describes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encoding facts into route names.&lt;/strong&gt; Names like &lt;code&gt;api.v1.legacy.reports.index&lt;/code&gt; where "v1" and "legacy" are load-bearing. String parsing as architecture.&lt;/p&gt;

&lt;p&gt;Route metadata replaces all four with data that lives on the route definition itself, cascades through groups, and survives caching. Now the five problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Per-Route SEO Tags Without a Config Map
&lt;/h2&gt;

&lt;p&gt;The classic case, and the one the PR uses in its own examples. Marketing pages need distinct titles, meta descriptions, and robots directives. Most Laravel apps solve this with view-level variables passed from every controller method, which means every controller method has to remember to pass them.&lt;/p&gt;

&lt;p&gt;With metadata, the route definition carries the SEO data and one middleware shares it with every view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/web.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'robots'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'index,follow'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages.pricing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Plans from 9 euro a month.'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/about'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pages.about'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'About us'&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/ShareSeoMetadata.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'seo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your layout reads &lt;code&gt;$seo['title']&lt;/code&gt; with a sensible fallback and you're done. Controllers stop caring about SEO entirely. The &lt;code&gt;/about&lt;/code&gt; page didn't define a description, so &lt;code&gt;getMetadata()&lt;/code&gt; just won't return one, and your Blade fallback handles it.&lt;/p&gt;

&lt;p&gt;The win over a config map: renaming a route can't break anything, because nothing is keyed by route name anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Feature Flag Gating for Whole Route Groups
&lt;/h2&gt;

&lt;p&gt;You're rolling out a new billing section behind a flag. The old approach is a middleware that takes the flag name as a parameter, &lt;code&gt;-&amp;gt;middleware('feature:new-billing')&lt;/code&gt;, repeated on every route or group. Workable, but middleware parameters are strings, they don't cascade with any structure, and stacking more than one gets noisy.&lt;/p&gt;

&lt;p&gt;Metadata handles the cascade for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/web.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'new-billing'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;BillingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/invoices'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;BillingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'invoices'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment-methods'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethodController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/EnforceFeatureFlags.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Pennant\Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the middleware globally on the web group. Routes without a &lt;code&gt;feature&lt;/code&gt; key pass straight through. The resource inside the group is covered automatically, which is exactly the pending-registration case the old action-array hack couldn't handle.&lt;/p&gt;

&lt;p&gt;And when the rollout finishes, you delete one metadata line instead of hunting middleware strings across three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Declarative Permission Tags in Middleware
&lt;/h2&gt;

&lt;p&gt;To be clear about scope first: this doesn't replace &lt;a href="https://hafiz.dev/blog/laravel-policies-vs-gates-authorization-guide" rel="noopener noreferrer"&gt;policies and gates&lt;/a&gt;. Model-level authorization belongs in policies. But plenty of apps also have coarse route-level access rules ("everything under /admin/reports needs the reports.view permission") that end up scattered across &lt;code&gt;can:&lt;/code&gt; middleware strings.&lt;/p&gt;

&lt;p&gt;Metadata gives you a single declarative layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'admin.access'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;DashboardController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'admin.access'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reports.view'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReportController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/export'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ReportController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'export'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the nested group repeats &lt;code&gt;admin.access&lt;/code&gt; in its list. That's deliberate: &lt;code&gt;permissions&lt;/code&gt; is a list, and lists replace inherited values rather than merging. If you want additive permissions, either repeat the inherited ones like I did here, or structure them as an associative array (&lt;code&gt;['admin.access' =&amp;gt; true, 'reports.view' =&amp;gt; true]&lt;/code&gt;) so the recursive merge works in your favor. This is the sharpest edge in the whole feature, so it's worth internalizing early.&lt;/p&gt;

&lt;p&gt;The enforcement middleware is five lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$permission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$permission&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One place to audit route-level access. &lt;code&gt;php artisan route:list&lt;/code&gt; shows you the routes, the metadata shows you the rules, and nothing is hidden in controller constructors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Breadcrumbs That Build Themselves
&lt;/h2&gt;

&lt;p&gt;This is the use case Ben Bjurstrom, the PR's author, gave when asked what the feature is for: package authors need a place to attach data to routes while group and resource registration is still pending. Breadcrumbs are the perfect example because they're hierarchical, exactly like nested route groups.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin/users'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/{user}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'breadcrumb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Admin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/admin/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Detail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Blade component reads &lt;code&gt;$request-&amp;gt;route()-&amp;gt;getMetadata('breadcrumb', [])&lt;/code&gt; and renders the trail. No third-party breadcrumb package, no separate breadcrumb definition file that drifts out of sync with your routes, no reflection tricks.&lt;/p&gt;

&lt;p&gt;Because breadcrumb maps are associative arrays, nested groups merge cleanly and you could even skip repeating the parent crumbs. I repeat them anyway for explicitness, since key order in merged arrays is something I'd rather not depend on for UI. Expect packages to build nicer APIs on top of this primitive soon; error trackers are already reading it, and Flare 3.1.0 displays route metadata next to stack traces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: API Deprecation Headers From One Middleware
&lt;/h2&gt;

&lt;p&gt;If you version APIs, you eventually deprecate endpoints. Communicating that properly means &lt;code&gt;Deprecation&lt;/code&gt; and &lt;code&gt;Sunset&lt;/code&gt; headers on every affected response, and most teams either skip it or hardcode headers in a dozen controllers.&lt;/p&gt;

&lt;p&gt;Tag the routes instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api/v1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'api'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Api\V1\OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'api'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'deprecated_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-01T00:00:00Z'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sunset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-12-31T23:59:59Z'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'successor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/api/v2/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]]);&lt;/span&gt;

        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/customers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Api\V1\CustomerController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Middleware/AddDeprecationHeaders.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Carbon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deprecatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.deprecated_at'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deprecation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deprecatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sunset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.sunset'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Sunset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sunset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toRfc7231String&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$successor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api.successor'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Link'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$successor&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'&amp;gt;; rel="successor-version"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header formats matter here. RFC 9745, published March 2025, standardized the Deprecation header as a structured field date, meaning &lt;code&gt;@&lt;/code&gt; followed by a unix timestamp, and the older Sunset header from RFC 8594 takes a classic HTTP-date. Storing ISO strings in metadata and converting with Carbon at the edge keeps the route file readable while the wire format stays spec-compliant. Plenty of APIs still ship &lt;code&gt;Deprecation: true&lt;/code&gt; from the pre-RFC draft days, but new code has no excuse.&lt;/p&gt;

&lt;p&gt;Notice the dot notation doing real work too: &lt;code&gt;api.deprecated_at&lt;/code&gt; reaches into the merged array, and the group's &lt;code&gt;version&lt;/code&gt; key merged with the route's keys because both live under the associative &lt;code&gt;api&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;The same metadata doubles as input for documentation tooling. Iterate the route collection in a command, read &lt;code&gt;getMetadata('api')&lt;/code&gt;, and generate a deprecation report for your changelog. The data has one home and two consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Draw the Line
&lt;/h2&gt;

&lt;p&gt;The contributor who questioned this feature on the PR wasn't entirely wrong. There's a version of route metadata that makes your app worse, and it looks like this: a junk drawer of loosely related keys that grows forever because "just add it to metadata" became the answer to everything.&lt;/p&gt;

&lt;p&gt;My rules so far:&lt;/p&gt;

&lt;p&gt;Metadata should describe the route, not configure behavior that belongs elsewhere. "This route is deprecated" describes. "Retry this route's queue jobs three times" configures, and belongs where queue configuration lives.&lt;/p&gt;

&lt;p&gt;Keep values serializable. Everything goes through &lt;code&gt;route:cache&lt;/code&gt; in production, so closures and objects are out. Strings, numbers, booleans, arrays. If you're tempted to put a closure in metadata, you actually want middleware.&lt;/p&gt;

&lt;p&gt;Prefer associative structures over lists when groups are involved. You saw why in the permissions example. Lists replace on inheritance, and that surprises people.&lt;/p&gt;

&lt;p&gt;Test the metadata directly. Since metadata is now load-bearing (a missing &lt;code&gt;feature&lt;/code&gt; key means an unguarded route), a cheap test that iterates the route collection and asserts every route under a prefix carries the expected keys catches drift before production does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'guards every billing-v2 route with a feature flag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'router'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRoutes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'billing-v2'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$routes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$routes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'feature'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new-billing'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five lines of Pest, and nobody can add a route to that group that silently skips the gate. This kind of route-collection assertion was always possible, but metadata finally gives it something structured to assert against.&lt;/p&gt;

&lt;p&gt;And know when the old tools are still right. A one-off route with one middleware doesn't need a metadata layer plus a global middleware reading it. Metadata earns its place when the same kind of data applies across many routes, especially through groups and resources. For single-route concerns, &lt;a href="https://hafiz.dev/blog/laravel-route-model-binding-best-practices-for-cleaner-code" rel="noopener noreferrer"&gt;route model binding&lt;/a&gt; and plain middleware parameters are still the simpler answer.&lt;/p&gt;

&lt;p&gt;If you're not on Laravel 13 yet, this feature alone won't justify the jump, but combined with everything else in the release it's another reason to schedule it. The &lt;a href="https://hafiz.dev/blog/laravel-12-to-13-upgrade-guide" rel="noopener noreferrer"&gt;Laravel 12 to 13 upgrade guide&lt;/a&gt; covers the path, and the upgrade itself is one of the smallest major-version jumps Laravel has shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Which Laravel version do I need for route metadata?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laravel 13.17.0 or later. The PR was merged on June 19, 2026 and shipped in the 13.17.0 release. It's a minor release, so if you're on any 13.x version, &lt;code&gt;composer update&lt;/code&gt; gets you there with no breaking changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does route metadata work with route:cache?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, and this is the main advantage over rolling your own. Metadata is stored on the route action under a dedicated key, so &lt;code&gt;php artisan route:cache&lt;/code&gt; serializes it and &lt;code&gt;getMetadata()&lt;/code&gt; reads it back identically in cached and uncached environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this in Laravel 12 or 11?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The feature landed in 13.x only and there's no official backport. On older versions you're back to config maps or action-array workarounds, which is a decent nudge toward upgrading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is metadata() different from defaults()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;defaults()&lt;/code&gt; provides default values for route parameters, which means the values can be injected into controller method arguments. &lt;code&gt;metadata()&lt;/code&gt; is inert data that never touches parameter binding. If you've been using &lt;code&gt;defaults()&lt;/code&gt; to store non-parameter data, metadata is the correct home for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between metadata() and setMetadata()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;metadata()&lt;/code&gt; merges with whatever the route already has, including values inherited from groups. &lt;code&gt;setMetadata()&lt;/code&gt; replaces the entire metadata array, which is how a route opts out of its group's metadata completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Route metadata is a small feature with a long tail. Nothing here was impossible before. All of it was awkward before. The difference between "possible with hacks" and "supported with clean cascade semantics and cache safety" is the difference between a pattern you avoid and a pattern you reach for.&lt;/p&gt;

&lt;p&gt;Start with one use case. The SEO middleware is the lowest-risk entry point, and once the &lt;code&gt;getMetadata()&lt;/code&gt; pattern is in your muscle memory, you'll spot the other four in your own codebase within a week.&lt;/p&gt;

&lt;p&gt;Running a Laravel app that's accumulated years of route-name conventions and config maps, and want help untangling it during a 13.x upgrade? &lt;a href="mailto:contact@hafiz.dev"&gt;Let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>routing</category>
      <category>laravel13</category>
    </item>
    <item>
      <title>Laravel Reverb vs Pusher vs Soketi: Which WebSocket Server in 2026?</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 01 Jul 2026 05:56:05 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-reverb-vs-pusher-vs-soketi-which-websocket-server-in-2026-4h7</link>
      <guid>https://dev.to/hafiz619/laravel-reverb-vs-pusher-vs-soketi-which-websocket-server-in-2026-4h7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-reverb-vs-pusher-vs-soketi-websocket-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You're adding real-time features to a Laravel app. Live notifications, a chat panel, a collaborative cursor, a dashboard that updates without a refresh. The broadcasting code is the easy part because Laravel's API is the same no matter what's underneath. The actual decision is which WebSocket server sits behind it.&lt;/p&gt;

&lt;p&gt;Three options dominate the Laravel world: Reverb (first-party, self-hosted), Pusher (hosted, pay per connection), and Soketi (open-source, self-hosted, Pusher-compatible). They all speak the Pusher protocol, so your &lt;a href="https://hafiz.dev/blog/implementing-real-time-notifications-with-laravel-a-complete-guide" rel="noopener noreferrer"&gt;broadcasting and Echo code&lt;/a&gt; barely changes between them. What changes is cost, scaling ceiling, and how much operational work lands on you.&lt;/p&gt;

&lt;p&gt;I'll compare all three on the things that actually decide it, then give you a clear pick for each stage. There's also one fact about Soketi that should change how you think about it in 2026, and most comparisons skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Quick Version
&lt;/h2&gt;

&lt;p&gt;Pusher is the hosted option: zero servers to run, you pay per concurrent connection, and it gets expensive as you grow. Reverb is Laravel's first-party server: free software, you host it (or let Laravel Cloud host it), and it scales with Redis. Soketi is the open-source Pusher-compatible server: also free and self-hosted, historically the budget favorite, but its maintenance situation in 2026 is the catch.&lt;/p&gt;

&lt;p&gt;All three use the Pusher protocol. That's the thing that makes this a real choice rather than a lock-in: you can move between them with config changes, not rewrites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pusher: Pay to Not Think About It
&lt;/h2&gt;

&lt;p&gt;Pusher is the original hosted WebSocket service, and the pitch hasn't changed. You never run a server. You never tune file descriptors or restart a daemon. You create an app, get credentials, and broadcast.&lt;/p&gt;

&lt;p&gt;The pricing is connection-based and it's the whole story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free Sandbox: 100 concurrent connections, 200,000 messages/day&lt;/li&gt;
&lt;li&gt;Startup: $49/month, 500 concurrent connections&lt;/li&gt;
&lt;li&gt;Business: $299/month, 2,000 concurrent connections&lt;/li&gt;
&lt;li&gt;Higher tiers climb past $499 to $1,199/month for 15,000 to 30,000 connections&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A message counts both ways. Publish one event to 50 subscribers and that's 51 messages against your quota. For a chatty app (presence channels, typing indicators, live cursors) the message count climbs faster than you'd expect, and the daily cap on the free tier makes it unsuitable for production testing.&lt;/p&gt;

&lt;p&gt;Pusher's real value is the zero-ops promise and the SDK breadth. If you want real-time working in 15 minutes and you'd rather pay than provision, it delivers. The problem is purely cost at scale: 2,000 connections for $299/month is the kind of bill that makes self-hosting look attractive the moment you have the ops capacity to handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverb: The First-Party Default
&lt;/h2&gt;

&lt;p&gt;Reverb is Laravel's own WebSocket server, built on ReactPHP, shipped in 2024, and by 2026 it's the default choice for most Laravel teams. It speaks the Pusher protocol, so it's a drop-in for Laravel broadcasting and Echo. A single server handles thousands of concurrent connections, and it's free software: you only pay for the box it runs on.&lt;/p&gt;

&lt;p&gt;That last point is the headline. A Reverb instance on a $10 DigitalOcean droplet or a &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;cheap Hetzner VPS&lt;/a&gt; handles early-stage real-time features for the cost of the server, not per connection. Compared to Pusher's $49 to $299 tiers, the savings start immediately and widen as you grow.&lt;/p&gt;

&lt;p&gt;Scaling past one server uses Redis. You set &lt;code&gt;REVERB_SCALING_ENABLED=true&lt;/code&gt;, point your Reverb instances at a shared Redis server, and put them behind a load balancer. When a message hits one server, Redis publishes it to the others via pub/sub, so connections spread across machines stay in sync. It's the same horizontal-scaling pattern you'd use for any stateful service.&lt;/p&gt;

&lt;p&gt;Two things make Reverb in 2026 stronger than it was at launch.&lt;/p&gt;

&lt;p&gt;First, the database driver. You no longer need Redis for small-to-medium apps. Reverb can use your existing MySQL or Postgres database to coordinate, which means you can ship live notifications, dashboards, and collaborative features without provisioning, securing, and paying for a Redis cluster. For early-stage products that's a real infrastructure cost cut. For high-throughput apps (10,000+ concurrent connections), Redis is still the recommended backend.&lt;/p&gt;

&lt;p&gt;Second, managed Reverb on Laravel Cloud. If you want Reverb's economics without running the server yourself, Laravel Cloud offers fully managed Reverb clusters. You pick the number of concurrent connections, and Laravel claims up to 50% less than third-party tools like Pusher. It became the single most requested Cloud feature for a reason: it's the middle ground between self-hosting and paying Pusher's per-connection rates. It's the same managed-versus-self-hosted trade I walked through for &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;Cloud's managed queues versus Horizon&lt;/a&gt;, just applied to WebSockets.&lt;/p&gt;

&lt;p&gt;The cost of Reverb shows up as operations, not dollars. A self-hosted Reverb server is a long-running process you have to keep alive with Supervisor, tune for open file limits, restart on deploy with &lt;code&gt;reverb:restart&lt;/code&gt;, and monitor. If your site is on Forge, the Application panel has a Reverb integration that handles the server tuning for you. But it's still infrastructure you own. That's the trade against Pusher: you swap a monthly bill for operational responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Soketi: The Catch in 2026
&lt;/h2&gt;

&lt;p&gt;Soketi is an open-source, Pusher-compatible WebSocket server built on uWebSockets.js (a C library ported to Node.js, which makes it very fast). For years it was the go-to budget pick: run it on a $5 to $10 instance and get effectively unlimited connections and messages, where the equivalent Pusher plan would cost $49 for just 500 connections. On raw price-per-connection, Soketi has always won.&lt;/p&gt;

&lt;p&gt;Here's the part most comparisons won't tell you, and it's the most important fact in this whole post. As of 2026, Soketi's maintenance is a concern, and that's not speculation. The maintainers say so directly. Their own documentation describes release and maintenance frequency as based on available time that's "tight as hell," and openly notes that maintenance issues have caused infrequent updates and infrequent support.&lt;/p&gt;

&lt;p&gt;That changes the calculation. A Pusher-compatible server that's blazing fast and free but slowly maintained is fine for a side project where you can absorb the risk. For something you're betting a business on, leaning on a server whose maintainers have flagged their own limited bandwidth is a different proposition. And the kicker: Reverb now does almost everything Soketi did (Pusher protocol, self-hosted, free, fast), but with the entire Laravel core team behind it and first-party integration. The main reason to pick Soketi over Reverb in 2026 is if you specifically want a Node-based server in a non-Laravel-centric stack. Inside a Laravel app, Reverb has quietly taken Soketi's lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head to Head
&lt;/h2&gt;

&lt;p&gt;Cost at a small scale (say 500 concurrent connections): Pusher is $49/month. Reverb is the price of a $10 VPS. Soketi is the price of a $5 to $10 VPS. Self-hosting wins by a wide margin the moment you can run a server.&lt;/p&gt;

&lt;p&gt;Cost at a larger scale (5,000+ connections): Pusher runs into the hundreds per month and keeps climbing. Reverb and Soketi cost the same VPS plus a Redis instance, a fraction of the hosted price, but now you're managing horizontal scaling yourself. Managed Reverb on Laravel Cloud sits in between: more than a raw VPS, less than Pusher, and if you're already eyeing Cloud, the &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;$5 plan with spend caps and scale-to-zero&lt;/a&gt; keeps the floor low.&lt;/p&gt;

&lt;p&gt;Operational burden: Pusher is zero. Managed Reverb (Cloud) is near zero. Self-hosted Reverb and Soketi both require you to run, tune, monitor, and scale a long-running process.&lt;/p&gt;

&lt;p&gt;Maintenance confidence: Reverb and Pusher are actively, professionally maintained. Soketi has the open maintenance concern above.&lt;/p&gt;

&lt;p&gt;Laravel integration: Reverb is first-party and tightest. Pusher and Soketi are Pusher-protocol compatible, which is close, but Reverb is the one the framework ships with and the docs assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Recommendation
&lt;/h2&gt;

&lt;p&gt;For most Laravel apps in 2026, start with Reverb. It's first-party, it's free, the database driver means you don't even need Redis early on, and a single instance handles thousands of connections on a cheap server. You'd have to have a specific reason not to use it.&lt;/p&gt;

&lt;p&gt;If you don't want to run any infrastructure and you'd rather pay for it, you've got two clean options. Managed Reverb on Laravel Cloud keeps you in the Reverb ecosystem with no ops. Pusher makes sense if you want a battle-tested hosted service with the broadest SDK support and you're early enough that the connection count keeps you in the cheaper tiers.&lt;/p&gt;

&lt;p&gt;Pick Pusher over managed Reverb when you need the SDK breadth for non-web clients, or you want a vendor with a long track record and enterprise SLAs. Pick managed Reverb when you're a Laravel shop that wants the lowest hosted cost and tightest framework fit.&lt;/p&gt;

&lt;p&gt;I'd be cautious about starting a new production project on Soketi in 2026. The technology is solid and fast, but the maintainers' own note about limited upkeep, combined with Reverb now covering the same ground with first-party backing, makes it hard to recommend over Reverb for new Laravel work. If you're already running Soketi happily, there's no fire drill. But for a fresh build, Reverb is the safer version of the same idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I switch between these later without rewriting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mostly yes, and that's the upside of all three speaking the Pusher protocol. Your Laravel broadcasting code and Echo config stay the same; you change connection credentials and host settings. Moving from Pusher to Reverb, or Soketi to Reverb, is a config and infrastructure task, not an application rewrite. That makes "start simple, move later" a viable strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis for Reverb?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not anymore, for small-to-medium apps. Reverb's database driver lets it coordinate through your existing MySQL or Postgres, so you can skip Redis entirely early on. You'll want Redis once you scale horizontally across multiple Reverb servers or push into very high connection counts (10,000+), where it's the recommended backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many connections can one Reverb server handle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single Reverb instance handles thousands of concurrent connections, with the exact ceiling depending on your server's resources and tuning (open file limits matter a lot here). Past what one box can hold, you scale horizontally with Redis pub/sub and a load balancer. For context, Soketi reports holding 500 active high-traffic connections on a 1GB, 1-CPU machine, and Reverb is in the same class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Pusher still worth paying for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you value zero operations and broad SDK support over cost, yes. Pusher's advantage is that you never run anything and it works across many platforms and languages. The downside is purely price: connection-based tiers get expensive fast, and a chatty app burns message quota quickly. If you have the ops capacity to run Reverb, the math usually favors self-hosting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Ably or other alternatives?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ably is another hosted option with stronger delivery guarantees (exactly-once, message history, state recovery) and is worth a look for mission-critical messaging where dropped messages aren't acceptable. But it's not Pusher-protocol native in the way these three are, so it's a bigger integration lift inside Laravel. For most Laravel real-time features, the Reverb-Pusher-Soketi triangle covers the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The honest 2026 answer is that Reverb won the default-choice slot, and for good reasons: first-party, free, fast, and no longer even requiring Redis for smaller apps. Pusher remains the right call when you'll pay to never touch infrastructure, especially via managed Reverb if you want to stay in the Laravel ecosystem. And Soketi, once the obvious budget pick, now carries a maintenance question that Reverb sidesteps entirely. Start with Reverb, reach for a hosted option when ops isn't where you want to spend time, and treat the Pusher protocol compatibility as your insurance policy to change your mind later.&lt;/p&gt;

&lt;p&gt;If you're planning real-time features for a Laravel app and want help choosing and setting up the right WebSocket layer, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>reverb</category>
      <category>websockets</category>
      <category>realtime</category>
    </item>
    <item>
      <title>Connect a Laravel AI Agent to Any MCP Server: A Hands-On Guide</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 29 Jun 2026 07:01:09 +0000</pubDate>
      <link>https://dev.to/hafiz619/connect-a-laravel-ai-agent-to-any-mcp-server-a-hands-on-guide-128h</link>
      <guid>https://dev.to/hafiz619/connect-a-laravel-ai-agent-to-any-mcp-server-a-hands-on-guide-128h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/connect-laravel-ai-agent-to-mcp-server" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most Laravel MCP tutorials teach you to build a server. You expose your app's tools, an AI client connects, and Claude or Cursor can query your orders table. Useful, and I've covered &lt;a href="https://hafiz.dev/blog/how-to-build-a-laravel-mcp-server-with-filament" rel="noopener noreferrer"&gt;building an MCP server with Filament&lt;/a&gt; before.&lt;/p&gt;

&lt;p&gt;This post goes the other direction. Your Laravel AI agent becomes the client. It connects out to any MCP server, GitHub, Notion, Laravel Nightwatch, or one you run locally, and uses that server's tools as if you'd written them by hand. If you've built &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;an agent with the Laravel AI SDK&lt;/a&gt; already, this slots straight into the agent you have. Ask your agent to "look into the latest exception in my app" and it browses your Nightwatch issues, pulls the stack trace, and reports back. You wrote none of that integration code.&lt;/p&gt;

&lt;p&gt;Laravel shipped this on June 9, 2026. It's the piece that turns an agent from something that only knows your code into something that can reach into every tool your team already uses. If you've been giving your agent context with &lt;a href="https://hafiz.dev/blog/laravel-boost-and-mcp-servers-the-context-your-ai-agent-is-missing" rel="noopener noreferrer"&gt;Laravel Boost and MCP&lt;/a&gt;, this is the reverse: your agent reaching out instead of a tool reaching in. Here's how to wire it up, from a no-auth local server to a full OAuth flow, plus the gotchas that'll bite you in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Ships in the Box
&lt;/h2&gt;

&lt;p&gt;The feature is two packages working together, and the split matters.&lt;/p&gt;

&lt;p&gt;The MCP client lives in &lt;code&gt;laravel/mcp&lt;/code&gt;. It knows how to connect to a server, negotiate the handshake, authenticate, and call tools. It works on its own, from a queued job or a console command, with no agent anywhere in sight.&lt;/p&gt;

&lt;p&gt;The thin integration lives in &lt;code&gt;laravel/ai&lt;/code&gt;. It lets an agent consume that client without changing how agents already work. You drop MCP tools into the same &lt;code&gt;tools()&lt;/code&gt; array you already use, and the SDK wraps each one to fit the agent's tool contract.&lt;/p&gt;

&lt;p&gt;Before any code, pin your versions. As of late June 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;laravel/mcp&lt;/code&gt; v0.8.1 (the 0.8 line introduced the client; earlier versions were server-only)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;laravel/ai&lt;/code&gt; v0.8.1&lt;/li&gt;
&lt;li&gt;Laravel 12 or 13&lt;/li&gt;
&lt;li&gt;PHP 8.3 or higher&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That PHP floor matters. &lt;code&gt;laravel/mcp&lt;/code&gt; itself tolerates PHP 8.2, but &lt;code&gt;laravel/ai&lt;/code&gt; requires 8.3, and you need both for this. If you're on 8.2, Composer will refuse to resolve. Check what you've got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer show laravel/mcp laravel/ai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Client Surface
&lt;/h2&gt;

&lt;p&gt;The whole client is three verbs: connect, list tools, call a tool. Everything else stays out of your way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A remote server over HTTP&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://mcp.example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// A local server you run as a process (STDIO)&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'artisan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp:start'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two constructors are the two transports. &lt;code&gt;web()&lt;/code&gt; uses streamable HTTP for remote servers you reach over the network. &lt;code&gt;local()&lt;/code&gt; uses STDIO (standard input/output) for servers you run as a child process on the same machine. You pick by where the server lives, not by preference.&lt;/p&gt;

&lt;p&gt;Listing and calling tools looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search-issues'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'payment failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;             &lt;span class="c1"&gt;// the text content&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// did the tool report a failure&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// structured payload, if the tool returned one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection happens lazily on the first real call, so you don't have to manage it manually. But you can if you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During that connection, the client and server negotiate a protocol version and exchange capabilities, so both sides agree on the rules before any work happens. You don't write any of that. It's the handshake, and when it fails (more on that later) it's usually an auth or version mismatch underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: A Local Server With No Auth
&lt;/h2&gt;

&lt;p&gt;Start with the simplest possible case so you see the wiring before the complexity. Point an agent at a local MCP server (yours, or any STDIO server) with no authentication at all.&lt;/p&gt;

&lt;p&gt;Inside your agent, the MCP tools go straight into the &lt;code&gt;tools()&lt;/code&gt; method, right next to your hand-written tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Ai\Tools\SendSlackMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'artisan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mcp:start'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendSlackMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire integration. The spread operator drops every tool from the MCP server into the array. The SDK notices they're MCP tools and wraps each one: it translates the MCP input schema into Laravel's JSON schema, calls the remote tool when the model asks for it, and normalizes whatever comes back into a result the model can read. The model has no idea which tools came from MCP and which you wrote. They all look the same to it.&lt;/p&gt;

&lt;p&gt;This is the mental model for the whole feature. If you can get one local server connected, every other server is just a different transport and an auth layer on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Bearer Token Auth
&lt;/h2&gt;

&lt;p&gt;Most useful servers want to know who you are. The simpler auth path is a bearer token, a string you already hold. A GitHub personal access token is the classic example.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can pass a closure instead of a raw string, which is what you want when the token belongs to the currently authenticated user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;github_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop it into the agent the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.githubcopilot.com/mcp/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;github_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your agent can search repositories, read CI logs, triage issues, and check Dependabot alerts. GitHub's server even supports a read-only mode through a request header if you'd rather the agent not change anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Full OAuth (The Real-World Case)
&lt;/h2&gt;

&lt;p&gt;Bearer tokens are fine when you already have a token. But hosted servers like Laravel Nightwatch use OAuth, where the user clicks a button, approves access on the provider's site, and comes back with a token your app stores. (If you're weighing Nightwatch against the alternatives, I compared &lt;a href="https://hafiz.dev/blog/laravel-telescope-vs-pulse-vs-nightwatch" rel="noopener noreferrer"&gt;Telescope vs Pulse vs Nightwatch&lt;/a&gt; separately.) This is the part the official announcement glosses over, so here's the whole flow.&lt;/p&gt;

&lt;p&gt;Register a named client, usually in a service provider's &lt;code&gt;boot()&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;registerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://nightwatch.laravel.com/mcp'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withOAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.nightwatch_mcp.client_id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.nightwatch_mcp.client_secret'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server supports dynamic client registration, you can omit the ID and secret entirely and the client registers itself. Whether you can do that depends on the server, which becomes important when things break.&lt;/p&gt;

&lt;p&gt;Next, wire the connect and callback routes in &lt;code&gt;routes/ai.php&lt;/code&gt;. The package gives you a helper that registers both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Client\OAuth\TokenSet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Mcp\Facades\Mcp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;oAuthRoutesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenSet&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'nightwatch_mcp_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This registers two named routes: &lt;code&gt;mcp.oauth.nightwatch.connect&lt;/code&gt; and &lt;code&gt;mcp.oauth.nightwatch.callback&lt;/code&gt;. The connect route redirects the user to the authorization server. The callback route exchanges the authorization code and runs your closure with a fresh token. You never touch the redirect URLs or the PKCE details.&lt;/p&gt;

&lt;p&gt;Then a button in your Blade view kicks it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ route('mcp.oauth.nightwatch.connect') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Connect Nightwatch
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow runs in order: the user clicks the button, the package redirects them to Nightwatch, they sign in and approve, Nightwatch sends them back to your callback route, your closure runs with the token, and you store it however you like. The package owns the OAuth choreography. Your app owns where the token lives.&lt;/p&gt;

&lt;p&gt;One thing to be clear about: there's no built-in token store. You persist &lt;code&gt;$token-&amp;gt;accessToken&lt;/code&gt; yourself, and you read it back when you build the client. The package handles refreshing expired tokens, but storage is on you.&lt;/p&gt;

&lt;p&gt;Once the token's stored, the agent uses the named client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now ask the agent: "What are the most recent exceptions in production? Help me prioritize which to fix first." It browses your issues, reads stack traces, and answers. Tell it "mark issue 123 as resolved with a comment summarizing the fix," and it does that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Agent Sees MCP Tools
&lt;/h2&gt;

&lt;p&gt;Here's the flow from prompt to remote tool call and back, so you know what's happening when the model decides to use a Nightwatch tool.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/connect-laravel-ai-agent-to-mcp-server" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key insight is in the middle. By the time the model is choosing, native and MCP tools sit in the same list and look identical. The wrapping, the schema translation, the network call, the result normalization, all of it happens below the model's awareness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching the Tool List
&lt;/h2&gt;

&lt;p&gt;Listing tools is a network round trip on a remote server, and the tool list rarely changes. So cache it. Because tools come back as plain data, you wrap the call in Laravel's cache and they rehydrate cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp.nightwatch.tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addHour&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nightwatch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing: this isn't a dedicated client feature, despite what some descriptions imply. It's standard Laravel caching wrapped around the &lt;code&gt;tools()&lt;/code&gt; call. The win is real though: you skip a round trip on every single prompt, which matters most for OAuth-secured remote servers where each call carries auth overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Without a Live Server
&lt;/h2&gt;

&lt;p&gt;You don't want your test suite hitting a real MCP server over the network. Laravel AI's faking layer lets you script the model's responses while the tool call still flows through the MCP layer.&lt;/p&gt;

&lt;p&gt;MCP tool names follow a &lt;code&gt;mcp_tools_&amp;lt;name&amp;gt;&lt;/code&gt; pattern. A server tool called &lt;code&gt;search&lt;/code&gt; shows up to the agent as &lt;code&gt;mcp_tools_search&lt;/code&gt;. So you can fake a model response that calls the tool, then a final answer, and assert on the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Facades\Ai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'investigates exceptions through the nightwatch agent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mcp_tools_search-issues'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'payment'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Found 3 payment exceptions. The most frequent is a Stripe timeout.'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Any payment errors lately?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Stripe timeout'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You test the production path (the agent calling a tool, reading the result, responding) without a single network call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotchas That'll Actually Bite You
&lt;/h2&gt;

&lt;p&gt;The happy path is clean. Here's what isn't, drawn from real issues developers have hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth handshake failures are the big one.&lt;/strong&gt; There's a documented case (laravel/boost issue #584) where connecting Nightwatch through certain agents threw &lt;code&gt;403 Forbidden ... when send initialize request&lt;/code&gt; before auth, and handshake decoding errors after. The reported cause: the client didn't detect that auth was required and never triggered the login flow. Two fixes came out of that thread. Run an explicit login for your agent if it supports one. Or route through the &lt;code&gt;mcp-remote&lt;/code&gt; bridge instead of pointing directly at the URL, which is what Nightwatch's own docs recommend for several clients. If a direct connection fails the handshake, try the bridge before assuming your code is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic client registration isn't universal.&lt;/strong&gt; Remember the OAuth step where you could omit the client ID and secret? That only works if the server supports dynamic client registration. Several servers require a pre-registered OAuth client, and trying to auto-register against them fails, often as a 403 that looks like an auth bug. Know which mode your target expects: if it doesn't do dynamic registration, you must supply &lt;code&gt;clientId&lt;/code&gt; and &lt;code&gt;clientSecret&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token storage is your job, and it's easy to get wrong.&lt;/strong&gt; The package refreshes tokens but doesn't store them. The common mistake is persisting the token somewhere your &lt;code&gt;withToken()&lt;/code&gt; closure can't read it back, or not persisting it at all and wondering why every request re-prompts for auth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protocol version mismatches surface as handshake failures.&lt;/strong&gt; Servers advertise different MCP spec versions. If a server only speaks a version the client doesn't negotiate, the connection fails at the handshake. The error won't always say "version," so keep it on your list of suspects when a connection won't establish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote tool errors don't throw into your code.&lt;/strong&gt; When a remote tool fails, the result carries &lt;code&gt;isError&lt;/code&gt; rather than raising an exception in your agent loop. The wrapper normalizes the failure into something the model can read and react to. Check &lt;code&gt;isError&lt;/code&gt; if you're handling results manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use This (and When Not To)
&lt;/h2&gt;

&lt;p&gt;Reach for an MCP client connection when the tool already exists as an MCP server and you'd otherwise be writing an API integration by hand. Nightwatch, GitHub, Notion, and a growing list of services expose MCP servers. Consuming one is a few lines; rebuilding its integration is a few days.&lt;/p&gt;

&lt;p&gt;Skip it when a plain API call or a tool class you write yourself is simpler. If you only need one endpoint from a service, a small hand-written tool beats pulling in an entire MCP connection and its auth flow. MCP earns its place when you want many of a server's capabilities, or when the server is something like Nightwatch where the tools are rich enough to be worth the wiring.&lt;/p&gt;

&lt;p&gt;And if you're building tools for your own app, remember you can reuse your &lt;code&gt;laravel/mcp&lt;/code&gt; server tool classes directly in an agent with no client at all. Write the tool once, use it as a server tool for external clients and as a native agent tool internally. It's the same instinct behind making &lt;a href="https://hafiz.dev/blog/how-to-make-your-laravel-app-ai-agent-friendly-the-complete-2026-guide" rel="noopener noreferrer"&gt;your whole Laravel app AI-agent friendly&lt;/a&gt;: build the capability once, expose it everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need to build an MCP server to use this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. This is the opposite direction. You're consuming external servers as a client. You don't expose anything. If you also want to expose your own app's tools to outside AI clients, that's the server side, which is a separate setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which transports does the client support?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two: STDIO for local servers you run as a process (&lt;code&gt;Client::local(...)&lt;/code&gt;), and streamable HTTP for remote servers you reach over the network (&lt;code&gt;Client::web(...)&lt;/code&gt;). You choose based on where the server runs, not preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I connect to multiple MCP servers in one agent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Spread multiple clients into the same &lt;code&gt;tools()&lt;/code&gt; array, mixing transports and auth freely. A local STDIO server and a remote OAuth server can both feed one agent, alongside your hand-written tools. The model treats them all identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle a server that needs OAuth but my client runs headless?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OAuth assumes a human approves access in a browser. For background or headless work, some servers support a client-credentials grant (no user in the loop), which the package handles. Others, like Notion's remote server, explicitly aren't built for headless agents, and you'd need a different auth path or a token issued ahead of time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with Laravel 11?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;laravel/mcp&lt;/code&gt; client supports Laravel 11, 12, and 13, but &lt;code&gt;laravel/ai&lt;/code&gt; requires Laravel 12 or 13 and PHP 8.3+. Since the agent integration needs both, you effectively need Laravel 12 or 13 to use an agent as an MCP client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The agent-as-client direction is the quieter half of Laravel's MCP story, and the more useful one for most apps. Building a server exposes your app to AI tools. Consuming servers gives your agent reach into every tool your team already runs, with a few lines instead of a custom integration per service. Start with a local no-auth server to internalize the &lt;code&gt;tools()&lt;/code&gt; pattern, graduate to a bearer token, then wire the full OAuth flow when you connect something like Nightwatch. And keep the troubleshooting section handy, because the handshake and OAuth edge cases are where the real time goes.&lt;/p&gt;

&lt;p&gt;If you're building agent features into a Laravel app and want help getting the MCP layer right, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>mcp</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>PHP 8.5's Pipe Operator vs Laravel Collections: Where Piper Fits (And Where It Doesn't)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:55:49 +0000</pubDate>
      <link>https://dev.to/hafiz619/php-85s-pipe-operator-vs-laravel-collections-where-piper-fits-and-where-it-doesnt-1bkl</link>
      <guid>https://dev.to/hafiz619/php-85s-pipe-operator-vs-laravel-collections-where-piper-fits-and-where-it-doesnt-1bkl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/php-pipe-operator-vs-laravel-collections-piper" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;PHP 8.5 shipped the pipe operator in November, and most Laravel developers shrugged. Why would we care about &lt;code&gt;|&amp;gt;&lt;/code&gt; when &lt;code&gt;collect()-&amp;gt;filter()-&amp;gt;map()&lt;/code&gt; has read top-to-bottom for a decade? Chained transformations are a solved problem in Laravel.&lt;/p&gt;

&lt;p&gt;Then Spatie released Piper last month: a library that ports Laravel's collection and string helpers to standalone functions built specifically for the pipe operator. When the team that maintains 300+ Laravel packages decides the pipe operator needs a Laravel-flavored toolkit, the question stops being academic. Is there an actual reason to write &lt;code&gt;$array |&amp;gt; filter(...) |&amp;gt; map(...)&lt;/code&gt; instead of reaching for Collections?&lt;/p&gt;

&lt;p&gt;I dug into the package, the design decisions behind it, and where each approach actually wins. Short answer: Piper isn't a Collections replacement and doesn't try to be. But there are two specific situations where it's the better tool, and one hard constraint that decides everything before style ever enters the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 60-Second Pipe Operator Refresher
&lt;/h2&gt;

&lt;p&gt;The pipe operator takes the result of the left expression and passes it as the single argument to the callable on the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'  laravel  '&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "LARAVEL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step reads in execution order, top to bottom, no nesting, no intermediate variables. The &lt;code&gt;(...)&lt;/code&gt; syntax is PHP 8.1's first-class callable notation.&lt;/p&gt;

&lt;p&gt;The catch: the right side must be a callable that accepts exactly one argument. And PHP's standard library was not designed for that. Argument orders are famously inconsistent (&lt;code&gt;array_map($callback, $array)&lt;/code&gt; but &lt;code&gt;array_filter($array, $callback)&lt;/code&gt;), so half the stdlib needs wrapping in arrow functions before it pipes. That friction is exactly the gap Piper fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Piper Actually Does
&lt;/h2&gt;

&lt;p&gt;Piper (v1.0, requires PHP 8.5) ports Laravel's array and string helpers to namespaced functions under &lt;code&gt;Spatie\Piper\Arr&lt;/code&gt; and &lt;code&gt;Spatie\Piper\Str&lt;/code&gt;. The design trick: every function is a higher-order function. Calling &lt;code&gt;filter(fn ($i) =&amp;gt; $i % 2 === 0)&lt;/code&gt; doesn't filter anything. It returns a closure waiting for the one value the pipe will feed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Arr\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Str\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', and '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The winning numbers are '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "The winning numbers are 4, 16, and 36."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's absent: no &lt;code&gt;collect()&lt;/code&gt; going in, no &lt;code&gt;-&amp;gt;all()&lt;/code&gt; or &lt;code&gt;-&amp;gt;toString()&lt;/code&gt; coming out. Plain arrays and strings flow through plain functions. That's the entire philosophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Piper Beats Collections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Native values in, native values out.&lt;/strong&gt; Collections are a wrapper object. You pay a conversion at each boundary: &lt;code&gt;collect($array)&lt;/code&gt; to enter, &lt;code&gt;-&amp;gt;all()&lt;/code&gt; to leave, and every method returns a new Collection instance along the way. In application code you barely notice. In code that interfaces with anything expecting plain arrays (third-party SDKs, array-typed signatures, JSON boundaries), the wrapping and unwrapping is pure ceremony. Piper skips it entirely: each function takes an array, returns an array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixing your own functions into the chain.&lt;/strong&gt; This is the pipe operator's structural advantage, and it's something Collections can't do cleanly. A Collection chain can only call methods that exist on the Collection class (or macros you register globally). A pipe chain accepts &lt;em&gt;any&lt;/em&gt; callable: Piper helpers, native functions, your own domain functions, all in one flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nn"&gt;Spatie\Piper\Arr\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Collections, &lt;code&gt;applyDiscount()&lt;/code&gt; either becomes a macro, a &lt;code&gt;-&amp;gt;pipe()&lt;/code&gt; call, or a break out of the chain into a variable. The pipe operator treats your functions as first-class chain citizens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No framework dependency.&lt;/strong&gt; Piper's functions are standalone with zero Illuminate packages required. For package authors who want Laravel-style ergonomics without pulling &lt;code&gt;illuminate/collections&lt;/code&gt; into their dependency tree, that's a real selling point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Collections Still Win
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;API surface.&lt;/strong&gt; Piper v1 ports the most-used helpers. Collections have 100+ methods refined over a decade: &lt;code&gt;groupBy()&lt;/code&gt;, &lt;code&gt;pluck()&lt;/code&gt; with dot notation, &lt;code&gt;chunk()&lt;/code&gt;, &lt;code&gt;zip()&lt;/code&gt;, &lt;code&gt;mapWithKeys()&lt;/code&gt;, higher-order messages like &lt;code&gt;-&amp;gt;map-&amp;gt;title&lt;/code&gt;, and on and on. The moment your transformation needs one of the deeper methods, you're back in &lt;code&gt;collect()&lt;/code&gt; territory, and splitting one pipeline across both styles is worse than picking one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy evaluation.&lt;/strong&gt; &lt;code&gt;LazyCollection&lt;/code&gt; processes large datasets (cursor results, big files) one item at a time with constant memory. Piper has no lazy story: each function in the chain materializes a full new array. For &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;query results you've already optimized&lt;/a&gt;, chaining five Piper functions over 100,000 rows allocates five intermediate arrays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eloquent integration.&lt;/strong&gt; Query builders return Collections. Relations return Collections. Every Laravel API hands you a Collection already. Converting to a plain array just to pipe it through Piper means swimming upstream against the entire framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discoverability.&lt;/strong&gt; Type &lt;code&gt;-&amp;gt;&lt;/code&gt; after a Collection and your IDE lists every available method. With Piper you need to know the function exists, import it, and keep your &lt;code&gt;use function&lt;/code&gt; block tidy. It's a small tax, but it's paid on every file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint That Decides Everything
&lt;/h2&gt;

&lt;p&gt;Piper requires PHP 8.5. That's not a style consideration, it's a hard gate. PHP 8.5 shipped in November 2025, and most production fleets I see are still on 8.2 through 8.4. If your servers aren't on 8.5, this entire discussion is theoretical, and I'd guess that covers the majority of Laravel apps in production today. I wrote about this lag pattern before: &lt;a href="https://hafiz.dev/blog/php-8-4-features-not-using-yet-laravel-app" rel="noopener noreferrer"&gt;PHP 8.4 features were still "new" to most codebases&lt;/a&gt; a full year after release.&lt;/p&gt;

&lt;p&gt;There's a second adoption headwind worth naming: AI coding assistants. Sebastian De Deyne (who built Piper) observed that coding agents essentially never produce pipe operator code unless explicitly asked. Training data has a decade of &lt;code&gt;collect()&lt;/code&gt; chains and almost no &lt;code&gt;|&amp;gt;&lt;/code&gt;. In 2026, syntax that your tooling doesn't reach for by default spreads slower, regardless of merit.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Take
&lt;/h2&gt;

&lt;p&gt;Use Piper when you're writing framework-agnostic code (packages, shared libraries) or transformation-heavy code on native arrays and strings where pulling in Collections feels like overkill. The function-mixing capability is the one place the pipe operator is structurally better than method chaining, not just different. It's the same kind of &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;decision-tree thinking I apply to services versus actions&lt;/a&gt;: pick by code context, not by what's newest.&lt;/p&gt;

&lt;p&gt;Inside a Laravel app, keep using Collections. They're everywhere, your team knows them, Eloquent hands them to you, and the lazy variant handles scale. Piper isn't trying to dethrone them; it's filling the gap for the code that lives &lt;em&gt;between&lt;/em&gt; Laravel projects. Spatie has a habit of shipping packages a few years before the ecosystem catches up (&lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty did this for deployment&lt;/a&gt;), and Piper reads like a bet on where PHP code style lands by 2028, not a tool you must adopt this sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does Piper replace Laravel Collections?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, and Spatie doesn't position it that way. Piper covers array and string transformations on native values. Collections remain the richer, framework-integrated tool. Think of Piper as a companion for the places Collections are awkward, not a successor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the pipe operator with Collections directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not meaningfully. Collections are objects with chained methods, and the pipe operator wants single-argument callables. You could pipe into &lt;code&gt;collect(...)&lt;/code&gt; to enter Collection-land, but at that point just use the Collection chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the pipe operator perform better than Collection chaining?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Marginally, in theory: plain function calls skip the object method dispatch and per-step Collection instantiation. In practice the difference is noise compared to your database queries. Don't pick either approach for performance; pick for readability and context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Piper in Laravel 13?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, as long as your runtime is PHP 8.5 or higher. The package has no framework dependency, so it works in any PHP 8.5 project, Laravel or not. The version check that matters is your PHP binary, not your Laravel version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Stringable for string chains?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laravel's &lt;code&gt;Str::of()&lt;/code&gt; fluent strings cover the same ground as &lt;code&gt;Spatie\Piper\Str&lt;/code&gt; inside a Laravel app, with the same trade-offs as Collections: wrapper object, framework dependency, huge API. The same logic applies: Stringable in app code, Piper in framework-agnostic code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The pipe operator is the most interesting thing to happen to PHP syntax in years, and Piper is the first serious attempt to make it ergonomic for the Laravel crowd. But interesting and necessary aren't the same thing. If your production PHP is 8.5 and you write package code or work with native arrays at framework boundaries, Piper earns a spot in your composer.json. For everything else, &lt;code&gt;collect()&lt;/code&gt; isn't going anywhere.&lt;/p&gt;

&lt;p&gt;If you're modernizing a Laravel codebase and weighing which PHP 8.5 features are actually worth adopting, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>spatie</category>
      <category>php85</category>
    </item>
  </channel>
</rss>
