<?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: Eduardo Lázaro</title>
    <description>The latest articles on DEV Community by Eduardo Lázaro (@edulazaro).</description>
    <link>https://dev.to/edulazaro</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%2F540695%2F639d4891-8285-493b-8426-9ecec00f527f.png</url>
      <title>DEV Community: Eduardo Lázaro</title>
      <link>https://dev.to/edulazaro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edulazaro"/>
    <language>en</language>
    <item>
      <title>True Randomness in Laravel with Lararand</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Mon, 31 Aug 2026 02:19:10 +0000</pubDate>
      <link>https://dev.to/edulazaro/randomness-in-laravel-with-lararand-46n6</link>
      <guid>https://dev.to/edulazaro/randomness-in-laravel-with-lararand-46n6</guid>
      <description>&lt;p&gt;Here is a line of PHP that deals a card from a tarot deck. It is wrong.&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;$card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&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="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not wrong in the way a linter finds. It returns a number between 0 and 77 every single time. It never throws. Every test you would think to write about it passes: the range is right, the values are distinct, the distribution &lt;em&gt;looks&lt;/em&gt; fine if you eyeball a hundred draws.&lt;/p&gt;

&lt;p&gt;It is wrong in the way that only shows up if you count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;A byte holds 256 values. A deck holds 78. 256 is not a multiple of 78.&lt;/p&gt;

&lt;p&gt;The largest multiple that fits is 234, which is 78 × 3. So bytes 0 to 233 cover the deck three times, evenly, no argument. And then there are 22 bytes left over: 234 to 255. Those wrap around and land on cards 0 to 21 &lt;strong&gt;a fourth time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Count it up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;22 cards appear 4 times per 256 bytes&lt;/li&gt;
&lt;li&gt;56 cards appear 3 times per 256 bytes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is 22 × 4 + 56 × 3 = 256. The books balance, and 22 of your 78 cards come up &lt;strong&gt;33% more often than the other 56&lt;/strong&gt;. Forever. In production. Silently.&lt;/p&gt;

&lt;p&gt;If you are dealing tarot, The Fool through The Devil are favoured over everything after them. If you are running a raffle, the first 22 ticket holders are. If you are picking a shard, one in three of them gets a third more traffic than the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no test catches it
&lt;/h2&gt;

&lt;p&gt;Because the tests you naturally write are about the &lt;em&gt;shape&lt;/em&gt; of the answer, and the shape is perfect.&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$card&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;assertLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$card&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passes. Always will.&lt;/p&gt;

&lt;p&gt;The test that catches it has to be about the &lt;em&gt;distribution&lt;/em&gt;, and it has to be exhaustive rather than statistical. A statistical test of a 33% skew on 22 of 78 outcomes needs a lot of samples before it fires, and it will be flaky when it does.&lt;/p&gt;

&lt;p&gt;The exhaustive one is easy once you see it. Feed every byte value through and count:&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;$counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&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;$byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$byte&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;78&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// [4, 4, 4, ... 3, 3, 3]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-two fours and fifty-six threes, staring at you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why it belongs in one place
&lt;/h2&gt;

&lt;p&gt;Rejection sampling. Throw away the bytes that land in the uneven tail and draw again:&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;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;intdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 234&lt;/span&gt;

&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$byte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$byte&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$byte&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every one of the 78 outcomes gets exactly three of the 234 surviving bytes. The cost is a redraw on 22 of 256 bytes, 8.6% of the time, and the answer is right.&lt;/p&gt;

&lt;p&gt;The important part is not the four lines. It is that they should exist &lt;strong&gt;once&lt;/strong&gt; in a codebase, not in every place that turns bytes into a number. The bug is not hard. The bug is that it is invisible, so every function that rediscovers it has an even chance of rediscovering it wrongly, and none of them will tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same mistake, wearing a different hat
&lt;/h2&gt;

&lt;p&gt;Shuffling has an identical trap.&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;// Wrong&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;$i&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="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&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="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&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 swaps each position with &lt;em&gt;any&lt;/em&gt; position. It looks more random than the correct version, which is exactly why people write it. It produces n&lt;sup&gt;n&lt;/sup&gt; execution paths spread over n! possible orderings, and for every n above 2 those do not divide. Some permutations come out more often than others.&lt;/p&gt;

&lt;p&gt;The correct version swaps with a position at or below the current one, walking down:&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;below&lt;/span&gt;&lt;span class="p"&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;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 0..$i, unbiased&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&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="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&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;Same class of bug. Same silence. n&lt;sup&gt;n&lt;/sup&gt; over n! is the modulo bias again with more steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I ended up writing a package
&lt;/h2&gt;

&lt;p&gt;I was building a tarot site where the whole claim is that nobody arranged the outcome. That put two problems on the table at once.&lt;/p&gt;

&lt;p&gt;The first is the one above: the arithmetic has to be right, and it has to be right in one place.&lt;/p&gt;

&lt;p&gt;The second is stranger, and it is the actual reason the package exists. For most software, &lt;code&gt;random_int&lt;/code&gt; is the correct and final answer. It is the system CSPRNG, it is what every session id on the box already rests on, it costs nothing, and it cannot be down. If you are choosing a shard or picking a placeholder avatar, stop reading, you are done.&lt;/p&gt;

&lt;p&gt;But sometimes the &lt;strong&gt;origin&lt;/strong&gt; of the randomness is part of what you are selling. A draw someone could contest. A shuffle a regulator asks about. And there:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Our lottery runs on quantum vacuum noise measured at the Australian National University" is a sentence you can put in front of a customer.&lt;/p&gt;

&lt;p&gt;"We called &lt;code&gt;random_int&lt;/code&gt;" is not, even though it is a better answer technically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The ANU publishes a free endpoint that measures vacuum fluctuations. random.org sells atmospheric noise. Both are real, both are lovely, and both introduce a problem you did not have five minutes ago: &lt;strong&gt;a draw that fails in front of a customer is worse than one drawn locally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the source became a config line:&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;'chain'&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;'anu_public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'anu_quantum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'random_org'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tried left to right, first one that answers serves the bytes. The last entry is the one with nothing underneath it: put &lt;code&gt;system&lt;/code&gt; there and a draw can never fail, leave it out and a draw fails loudly when every provider does. Which of those you want is a real decision, and it should be visible in a config file rather than hidden behind a boolean called &lt;code&gt;fallback&lt;/code&gt; that secretly means "the CSPRNG".&lt;/p&gt;

&lt;h2&gt;
  
  
  The package
&lt;/h2&gt;

&lt;p&gt;It is &lt;a href="https://github.com/edulazaro/lararand" rel="noopener noreferrer"&gt;lararand&lt;/a&gt;, MIT, PHP 8.2 and Laravel 12.&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 edulazaro/lararand
&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EduLazaro\Lararand\Facades\Rand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Rand&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;below&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 0 to 77, no modulo bias&lt;/span&gt;
&lt;span class="nc"&gt;Rand&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;int&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;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// both ends included&lt;/span&gt;
&lt;span class="nc"&gt;Rand&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;distinct&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="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// six of 49, no repeats&lt;/span&gt;
&lt;span class="nc"&gt;Rand&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deck&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// an actual permutation&lt;/span&gt;
&lt;span class="nc"&gt;Rand&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One contract, and it is bytes. Everything else is derived by the package, because the derivation is where the bug lives.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/lararand" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/lararand&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/lararand" rel="noopener noreferrer"&gt;https://github.com/edulazaro/lararand&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>AI agents in Laravel with Laragents</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Thu, 27 Aug 2026 16:56:54 +0000</pubDate>
      <link>https://dev.to/edulazaro/ai-agents-in-laravel-with-laragents-20j4</link>
      <guid>https://dev.to/edulazaro/ai-agents-in-laravel-with-laragents-20j4</guid>
      <description>&lt;p&gt;Creating a call to an AI model is the easy part. A request, a JSON body, a &lt;code&gt;content&lt;/code&gt; field on the way back. If that were the work, nobody would need a package.&lt;/p&gt;

&lt;p&gt;The work is everything around it, and it is the same work in every product that ships an assistant: a loop that runs your tools and knows when to stop, conversations that survive their own context window, memory that outlives a session, rules somebody wrote that outrank whatever the model feels like doing, and agents that fire on your events instead of waiting to be typed at.&lt;/p&gt;

&lt;p&gt;I wrote all of that twice, in two applications, and the second time I extracted it. It is called &lt;a href="https://github.com/edulazaro/laragents" rel="noopener noreferrer"&gt;laragents&lt;/a&gt;.&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 edulazaro/laragents
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;laragents-config
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;laragents-migrations
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;A model that can call tools does not answer in one go. It asks for a tool, you run it, you hand back the result, it asks for another, and eventually it writes prose. That cycle is the thing.&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$loop&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$compressor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buildHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$systemPrompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4.1-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ToolContext&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'organization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user'&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="p"&gt;]),&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;definitions&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;Two things in there are not obvious until they cost you something.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The iteration cap.&lt;/strong&gt; A model that keeps reformulating the same failing search will loop until something times out, and every turn is billed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The empty-streak guard&lt;/strong&gt;, which is the more interesting one. The cap stops the spiral, but it stops it in the worst way: several calls spent and no answer. So after two consecutive tool results that found nothing, the loop appends a hint telling the model to say so rather than search again.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How it decides a result is empty is the part I like. A package cannot know what your tools return, so it reads the shape: a result carrying &lt;code&gt;items&lt;/code&gt;, &lt;code&gt;results&lt;/code&gt;, &lt;code&gt;matches&lt;/code&gt; or whatever you configure is a search and gets judged. A result carrying none of those says nothing either way, which matters more than it sounds. A tool that files a document neither found nor failed to find anything, so it must not reset the streak. One bookkeeping call between two dead searches would otherwise hide the spiral completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools are yours
&lt;/h2&gt;

&lt;p&gt;A tool talks about your domain, so the package does not bring any. It brings the class:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListProperties&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&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;name&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="s1"&gt;'list_properties'&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;description&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="s1"&gt;'List the properties on the market for this office. '&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'Does not book viewings: use schedule_viewing for that.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&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;'max_price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;JsonSchema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;integer&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;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Upper bound in euros.'&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;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ToolContext&lt;/span&gt; &lt;span class="nv"&gt;$context&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="nv"&gt;$office&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'office'&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;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$office&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;properties&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max_price'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&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 second sentence in the description is worth more than the rest of it. Models pick neighbouring tools when the boundary is unstated.&lt;/p&gt;

&lt;p&gt;The two parameters are two different trust levels, and this is the one thing I would want a reader to take away. &lt;code&gt;$args&lt;/code&gt; is what the model asked for: text a language model produced, possibly steered by whatever the user pasted into the chat. &lt;code&gt;$context&lt;/code&gt; is what your application put there. Take identity and scope from the second, never the first.&lt;/p&gt;

&lt;p&gt;Why a bag and not typed parameters? Because the application this came from declared its tools as &lt;code&gt;execute(array $args, ?CaseModel $case, Organization $org)&lt;/code&gt;, which is two application models nailed into an interface implemented by 95 tools. Adding a third piece of context meant editing all 95, and no package can ever host that signature: a package cannot know what a case is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversations that do not grow forever
&lt;/h2&gt;

&lt;p&gt;Every turn replays the whole conversation, so a chat that goes well gets more expensive per turn until it hits the context limit and stops working entirely.&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;$messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$compressor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buildHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$systemPrompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the token budget, everything goes verbatim. Over it, the older turns are summarised once by a cheap model and the summary is reused from then on.&lt;/p&gt;

&lt;p&gt;Two deliberate refusals in there. It never compresses the most recent turns, whatever the budget says, because that is where the thread of the exchange lives and a summary of "what we just said" is where these systems get vague. And it never touches tool calls or their results: those belong to the turn that produced them, and replaying them across turns is how a loop starts re-reading its own plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory, which is the part that gets skipped
&lt;/h2&gt;

&lt;p&gt;A conversation ends and everything it established goes with it. The user explained their situation on Tuesday and explains it again on Thursday.&lt;/p&gt;

&lt;p&gt;Memories hang off three axes, and keeping them apart is what makes the table reusable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant      whose it is: the firm, the workspace, the account
memorable   what it is about: a case, a project. NULL = the tenant as a whole
memory      who it applies to. NULL = everyone in that scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one reads like access control and is not. Showing María's preference to Juan is not a leak, it is worse: the model starts behaving with Juan as if he were María. "María prefers bullet points" is not confidential, it simply does not apply to anybody else.&lt;/p&gt;

&lt;p&gt;Writing them is a queued job, because it is another model call and charging the user's reply for the privilege of remembering it is how a chat gets slow. It fires when history compression happens, which is when a substantial amount has been said, and on a scheduled sweep for conversations that stopped before ever getting that far. Without the second one you remember long conversations and forget short ones, and short ones are most of them.&lt;/p&gt;

&lt;p&gt;Reading them is one line, and it is the line people forget:&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;$prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&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="nc"&gt;Rule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;promptBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'You are the assistant of an estate agency.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Memory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;promptBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$session&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;Without it the table fills up and is never read, which looks exactly like the feature being disappointing rather than absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules
&lt;/h2&gt;

&lt;p&gt;Same three axes, opposite treatment. A memory is distilled and weighed. A rule is written by a person and sent verbatim, at the top, worded as an order.&lt;/p&gt;

&lt;p&gt;Rules answer a question that comes up in every product with an assistant in it: where does "our firm always does X" live? Not in the system prompt, because that is one string for everybody and this varies per tenant. Not in memory, because memory is weighed and might not surface. Not in a tool, because it is not an action.&lt;/p&gt;

&lt;p&gt;They go &lt;strong&gt;above&lt;/strong&gt; your own prompt, and memories below it. A directive buried under a wall of remembered context stops reading as a directive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents that nobody opens
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'tenant_type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'organization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tenant_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'name'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Document triage'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'system_prompt'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Read the document, classify it, flag what is missing.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'event_triggers'&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;'document.uploaded'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'allowed_tools'&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;'read_document'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'classify_document'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'config'&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;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4.1-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max_tasks_per_day'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&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="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentTrigger&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;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'document.uploaded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or on a cron expression, checked from the last run rather than against the current minute, so a tick lost to a deploy is picked up next time instead of skipped for ever.&lt;/p&gt;

&lt;p&gt;Two guards that exist because of two specific bad afternoons. A &lt;strong&gt;debounce&lt;/strong&gt;, because observers fire on every save and one logical change written in three statements is three events, three answers and three invoices. And a &lt;strong&gt;daily ceiling&lt;/strong&gt;, because an agent wired to an event its own tools cause is a loop, and the debounce does not save you there since each iteration is a genuinely new subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately is not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Not a provider wrapper.&lt;/strong&gt; There are two clients, OpenAI and Anthropic, and they exist because the loop needs something to call. If you want fifteen providers behind one interface, &lt;a href="https://github.com/laravel/ai" rel="noopener noreferrer"&gt;laravel/ai&lt;/a&gt; is the framework's own answer and is better at it than this will be. What laravel/ai does not have is any of the above: no memory, no rules, no schedules, no compression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not a metering package.&lt;/strong&gt; There is a &lt;code&gt;UsageRecorder&lt;/code&gt; contract and an adapter for &lt;a href="https://github.com/edulazaro/larameter" rel="noopener noreferrer"&gt;larameter&lt;/a&gt;, and out of the box it records nothing, because a package should not start refusing calls because you have not wired your billing yet. Metering usually arrives bundled inside an AI package and that is the wrong place for it: you should not have to install an LLM client to meter a form submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not opinionated about your models.&lt;/strong&gt; Everything polymorphic is your morph alias, never the package's word. A scope you call &lt;code&gt;matter&lt;/code&gt; is addressed as &lt;code&gt;"matter"&lt;/code&gt; everywhere, including in the prompt that asks the model which scope a memory belongs to. Nothing has to be translated into our vocabulary, and nothing gets dropped for failing to translate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redaction, and the part everybody forgets
&lt;/h2&gt;

&lt;p&gt;Turn on PII anonymisation and the conversation is tokenised on the way out and restored on the way back, tool arguments included, so your tools query the database with real values rather than searching for &lt;code&gt;«AP_1»&lt;/code&gt; and finding nothing.&lt;/p&gt;

&lt;p&gt;Most of these features are not providad by Laravel AI, so it's the main reason for this package.&lt;/p&gt;

&lt;p&gt;👉 Source: &lt;a href="https://github.com/edulazaro/laragents" rel="noopener noreferrer"&gt;github.com/edulazaro/laragents&lt;/a&gt;&lt;br&gt;
👉 Documentation: &lt;a href="https://edulazaro.com/portfolio/laragents" rel="noopener noreferrer"&gt;edulazaro.com/portfolio/laragents&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Instant search engine indexing in Laravel with LaraIndexNow</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Wed, 26 Aug 2026 06:03:49 +0000</pubDate>
      <link>https://dev.to/edulazaro/instant-search-engine-indexing-in-laravel-with-laraindexnow-40h7</link>
      <guid>https://dev.to/edulazaro/instant-search-engine-indexing-in-laravel-with-laraindexnow-40h7</guid>
      <description>&lt;p&gt;You publish a post and then you wait. Your sitemap says here is everything I have, come back whenever, and the crawler comes back whenever. The page might there invisible for days.&lt;/p&gt;

&lt;p&gt;IndexNow flips that around. It is a small protocol: you publish a key file on your domain, then POST the URLs that changed. Bing, Yandex, Seznam, Naver and Yep consume it, and one submission to the shared endpoint reaches all of them. &lt;strong&gt;Google does not participate&lt;/strong&gt;, so this complements your sitemap, it does not replace it. If that alone rules it out for you, stop reading here.&lt;/p&gt;

&lt;p&gt;Laraindexnow wires the protocol into Eloquent. You describe which records deserve a URL and under what conditions, and the package works out when to submit. This post walks through the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require edulazaro/laraindexnow
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;indexnow-config
php artisan indexnow:key &lt;span class="nt"&gt;--file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last command generates a key, writes &lt;code&gt;INDEXNOW_KEY&lt;/code&gt; to your &lt;code&gt;.env&lt;/code&gt; the way &lt;code&gt;key:generate&lt;/code&gt; writes &lt;code&gt;APP_KEY&lt;/code&gt;, and drops the key file into &lt;code&gt;public/&lt;/code&gt;. That file is the whole authentication story: if you can publish at &lt;code&gt;https://yourdomain.com/{key}.txt&lt;/code&gt;, the domain is yours. There is no account to open.&lt;/p&gt;

&lt;p&gt;The package also serves that URL through a route, so the file is optional. Generate it anyway: the web server hands a real file over without booting the framework, so it keeps answering while &lt;code&gt;php artisan down&lt;/code&gt; returns a 503 for every route.&lt;/p&gt;

&lt;p&gt;Then confirm it is reachable before you rely on it:&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 indexnow:verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unreachable key file is behind almost every rejection from the endpoint, and the endpoint itself tells you nothing useful about which half failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking a model
&lt;/h2&gt;

&lt;p&gt;Register what you want tracked in a service provider:&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;EduLazaro\IndexNow\Facades\IndexNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;IndexNow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole integration. No trait, no interface, nothing added to the model.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;url()&lt;/code&gt; takes a route name and resolves it with the record bound to it, which covers most models. Anything that already looks like a URL is left alone, and a closure handles the rest:&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.show'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// route('blog.show', $post)&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&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="c1"&gt;// taken literally&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&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;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also &lt;code&gt;affects()&lt;/code&gt;, for the other URLs a write leaves out of date. Publishing a post changes the post page, but it also changes the index and the category listing:&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;affects&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'blog.index'&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;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&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;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conditions are the whole API
&lt;/h2&gt;

&lt;p&gt;An attribute named on its own has to hold something true, and &lt;code&gt;whenNot()&lt;/code&gt; is the opposite. Truth is read the way PHP reads it, so &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;"1"&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt; all pass while &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;"0"&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;""&lt;/code&gt; do not. A &lt;code&gt;tinyint(1)&lt;/code&gt; column with no &lt;code&gt;boolean&lt;/code&gt; cast comes back from MySQL as &lt;code&gt;1&lt;/code&gt;, and the same attribute is &lt;code&gt;true&lt;/code&gt; when you just assigned it in PHP. Both have to read the same way, or your conditions work on a fresh record and not on a reloaded one.&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// holds something true&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenNot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spam'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// holds something false&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// holds that value&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&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="c1"&gt;// compared&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'featured'&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;when&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;Post&lt;/span&gt; &lt;span class="nv"&gt;$post&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;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPast&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A counter reads nicely as a flag, so &lt;code&gt;when('comment_count')&lt;/code&gt; is "has at least one".&lt;/p&gt;

&lt;p&gt;Conditions on one registration combine with &lt;strong&gt;AND&lt;/strong&gt;. Every one of them has to hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually triggers a submission
&lt;/h2&gt;

&lt;p&gt;The package asks one question on every write. Did this record belong in the index before it, and does it belong after?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Submitted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes, it entered the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;only if a content attribute changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;yes&lt;/strong&gt;, it left the index&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one people forget. A record leaving the index matters as much as one entering it: the URL now 404s or turns &lt;code&gt;noindex&lt;/code&gt;, and the engines should be told to go and look. The same goes for a deleted record, and for a changed slug, where both the old URL and the new one go out.&lt;/p&gt;

&lt;p&gt;The third row is where you keep the noise down. Not every write is a content change, and some applications rewrite half a table on a schedule:&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ignoring&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'view_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'stats_cache'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'search_index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'last_seen_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scheduled command that recomputes cached counters across a large table is the case that bites: without that list every row it touches counts as a content change, and one run becomes thousands of submissions. With it, the run is silent and a real edit still goes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transitions, when the state is not enough
&lt;/h2&gt;

&lt;p&gt;Sometimes the interesting thing is not what the record holds but what just happened to 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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenBecame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// it just took that value&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// it stopped holding zero&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenIncrements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// a number went up&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whenChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cover'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// these changed, whatever the values&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;whenChanged()&lt;/code&gt; is the precise way to say what counts as content, and it is the inverse of &lt;code&gt;ignoring()&lt;/code&gt;: a whitelist instead of a blacklist. If forgetting one column would flood the endpoint, prefer the whitelist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Several ways in
&lt;/h2&gt;

&lt;p&gt;Here is the case that breaks a single condition set. Say a page is worth indexing once it is approved and has at least one comment. Either half can be the one that arrives last: approval can come first and the comment later, or the other way round, and no single AND describes both.&lt;/p&gt;

&lt;p&gt;Call &lt;code&gt;track()&lt;/code&gt; again for the same model and you get a second registration. Registrations combine with &lt;strong&gt;OR&lt;/strong&gt;: a write is submitted when any of them wants 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="nc"&gt;IndexNow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.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;whenLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// a comment arrived, already approved&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;IndexNow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.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;whenBecame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'approved'&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="c1"&gt;// approval arrived, comments already there&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comment_count'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing that as one registration does not work: the two transitions would have to happen in the same write, which never does. The model is observed once however many registrations it has, and a URL two of them resolve is submitted once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submitting once, and remembering it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sets()&lt;/code&gt; writes an attribute on the record after its URL has been handed over. Put the same attribute in the conditions and the record drops out of the registration the moment it is written. Submitted once, ever, however many times you save it afterwards:&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;IndexNow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&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;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.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;whenNot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index_now_sent'&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'published'&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;sets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index_now_sent'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know. The write is quiet, because a normal save would fire another event and the record, having just left the conditions, would go out a second time. And it means handed over, not accepted by a search engine: the buffer carries URLs and nothing else, so by the time a submission succeeds or fails there is no record left to point back at. Listen for &lt;code&gt;SubmissionFailed&lt;/code&gt; if a failure has to undo it.&lt;/p&gt;

&lt;p&gt;A flag like this is the wrong tool when a page should be resubmitted after an edit, since a written record never comes back. For that, declare the edit as its own registration with &lt;code&gt;whenChanged()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap you need to know about
&lt;/h2&gt;

&lt;p&gt;Eloquent events do not fire for query builder writes. This publishes a hundred posts and submits nothing:&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;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'scheduled_for'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'published'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No model is instantiated, so no observer runs. That is Eloquent, not this package, and the answer is &lt;code&gt;indexnow:sync&lt;/code&gt;:&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 indexnow:sync Post &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks the table, evaluates the same conditions the observer uses, and submits what matches. There is no second copy of the rule to keep in step. Pair it with &lt;code&gt;sets()&lt;/code&gt; and each pass only picks up what is left, which makes a backfill of a large table resumable. A dry run never writes.&lt;/p&gt;

&lt;p&gt;The other direction is seeding and importing, where you want the writes but not the noise:&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;IndexNow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withoutSubmissions&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;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50_000&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;create&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;h2&gt;
  
  
  Buffering, queueing and deduplication
&lt;/h2&gt;

&lt;p&gt;Model events do not send HTTP requests. They push URLs into a cache backed buffer and schedule one delayed job to drain it, so saving a hundred rows produces one request rather than a hundred.&lt;/p&gt;

&lt;p&gt;The same URL is not submitted twice within the deduplication window. Editing a post five times in ten minutes is one submission. Keep that window short, an hour by default: it is an anti-bounce measure, not a record of what is already indexed, and a long window swallows real edits. Publish at 09:00 with a 24 hour window and the correction you make at 15:00 never goes out.&lt;/p&gt;

&lt;p&gt;Retries live on the job. A &lt;code&gt;429&lt;/code&gt; is released with the endpoint's own &lt;code&gt;Retry-After&lt;/code&gt;, and a &lt;code&gt;5xx&lt;/code&gt; backs off at 1, 5 and 15 minutes. A rejected key or a malformed URL is never retried, because retrying will not fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IndexNow&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="nv"&gt;$post&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$fake&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSubmitted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blog.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$fake&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSubmittedCount&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="nv"&gt;$fake&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNothingSubmitted&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;fake()&lt;/code&gt; swaps the transport and bypasses the queue, so assertions work without a worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not from your laptop
&lt;/h2&gt;

&lt;p&gt;Submissions only happen in the environments listed in the config, &lt;code&gt;production&lt;/code&gt; by default. That guard is what stops a development machine from asking search engines to crawl URLs that resolve on &lt;code&gt;localhost&lt;/code&gt;, and it is the first thing to check when nothing is going out.&lt;/p&gt;

&lt;p&gt;For a staging machine that should exercise the whole path without talking to the endpoint, set &lt;code&gt;driver&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;log&lt;/code&gt; to &lt;code&gt;log&lt;/code&gt;. To stop submissions in production without deploying, set &lt;code&gt;INDEXNOW_ENABLED=false&lt;/code&gt; and clear the config cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That is the surface. &lt;code&gt;track()&lt;/code&gt; to register, &lt;code&gt;url()&lt;/code&gt; to name the page, conditions to say when it counts, a second &lt;code&gt;track()&lt;/code&gt; when there is more than one way in, and &lt;code&gt;indexnow:sync&lt;/code&gt; for everything Eloquent never saw.&lt;/p&gt;

&lt;p&gt;👉 Package on &lt;a href="https://packagist.org/packages/edulazaro/laraindexnow" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;.&lt;br&gt;
👉 Source &lt;a href="https://github.com/edulazaro/laraindexnow" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Written against version 1.4. If you try it and something is missing, open an issue.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>opensource</category>
      <category>seo</category>
    </item>
    <item>
      <title>Credits, plans and quotas in Laravel with Larameter</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Mon, 24 Aug 2026 03:21:48 +0000</pubDate>
      <link>https://dev.to/edulazaro/credits-plans-and-quotas-in-laravel-with-larameter-534c</link>
      <guid>https://dev.to/edulazaro/credits-plans-and-quotas-in-laravel-with-larameter-534c</guid>
      <description>&lt;p&gt;If your app sells an allowance, a number of credits a month, or a number of documents, or a number of anything. Whatever it is, you end up writing a balance somewhere, a reset when the period rolls over, a check before the expensive call, and a usage screen that has to agree with all of it.&lt;/p&gt;

&lt;p&gt;None of that is hard on its own. What gets you is that the pieces drift. The plan says a thousand a month, the reset runs on the first of the month, the subscription renews on the 18th, and the screen sums a table that the charging code stopped writing to two features ago. And then somebody adds a weekly cap and now there are two numbers per plan to keep consistent, times seven plans.&lt;/p&gt;

&lt;p&gt;So after repeating the same on many apps, just created Larameter. It's basically what you see in Claude or OpenAI subscription plans. It meters credits against a plan, enforces ceilings on things that exist rather than things that are spent, and works out which plan an account is on instead of storing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;Just install the package via composer as usually:&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 edulazaro/larameter
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;larameter-config
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;larameter-migrations
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the trait to whatever you bill. An organisation, a user, a workspace: the package does not care, and it does not need a column on your table.&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;EduLazaro\Larameter\Concerns\HasCredits&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;Organization&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;HasCredits&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 account row appears the first time you touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are allowances
&lt;/h2&gt;

&lt;p&gt;One period is rarely enough. A monthly figure alone lets a bad afternoon eat the month, so you want a weekly cap on top, and maybe a per-session one. Declare those windows once:&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;'windows'&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;'session'&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;'minutes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'anchor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'rolling'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'share'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'weekly'&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;'days'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'anchor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'fixed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'share'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'monthly'&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;'months'&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="s1"&gt;'anchor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'fixed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'share'&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="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then a plan grants &lt;strong&gt;1 figure&lt;/strong&gt;, which every window takes a share of:&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;'plans'&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;'free'&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;'credits_monthly'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'pro'&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;'credits_monthly'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'limits'&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;'members'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&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;50,000 a month is 12,500 a week and 2,000 a sitting, and raising the plan raises all 3 at once. The alternative, a figure per window per plan, is 7 plans times 3 numbers to keep consistent: the day somebody doubles the monthly and forgets the weekly, the weekly quietly becomes the binding constraint and nothing tells you.&lt;/p&gt;

&lt;p&gt;The tightest window is the one that binds. A window with no &lt;code&gt;share&lt;/code&gt; narrows nothing. Declare no windows at all and you have opted out of allowance metering entirely: usage is still recorded, nothing is refused, and only purchased credits mean anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling and fixed are not interchangeable
&lt;/h2&gt;

&lt;p&gt;Here is a description of the basic usage window terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;anchor&lt;/code&gt;&lt;/strong&gt; decides when the next window starts, and picking the wrong one is the kind of bug users write in to complain about.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;rolling&lt;/code&gt;&lt;/strong&gt; starts the moment credits are next spent after the old one expired, so the full length is always available. That is what a session wants: on a fixed grid, starting 10 minutes before a boundary hands somebody 10 minutes, and it reads as the product having robbed them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;fixed&lt;/code&gt;&lt;/strong&gt; sits on a grid laid down from the first window and moves on whether it is used or not. That is what a week wants, because &lt;em&gt;when does my week reset&lt;/em&gt; needs an answer that is not &lt;em&gt;depends when you last stopped&lt;/em&gt;. A dormant account gets 1 allowance back on its return, not 4.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule that falls out of this and matters more than it looks: &lt;strong&gt;asking never opens a window&lt;/strong&gt;. For a rolling window the row is the clock, so an expired one is reported as full without being restarted. Otherwise opening the app to check your balance would burn the session before a word was typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing to synchronise
&lt;/h2&gt;

&lt;p&gt;There is no webhook here, and nothing to call when a subscription renews. A billing period and a credit window are separate clocks and neither has to know about the other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The window resets on its own grid, lazily, the next time credits are charged. An account whose month began on the 28th resets on the 28th, for ever, without anybody telling it to.&lt;/li&gt;
&lt;li&gt;The plan is worked out on every request. Stop paying and the provider stops finding a subscription, so the allowance drops to whatever the next provider says. Pay 4 days late and it comes back mid-window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tying them together would break the ordinary case rather than improve it: an annual subscription usually grants a monthly allowance, and a window anchored to the billing period would reset it once a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Charging: a price by name, or a rate per unit
&lt;/h2&gt;

&lt;p&gt;2 doors. An action has a fixed price:&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;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create_form'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And consumption is priced per unit in and out:&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;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;meter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$inputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$outputTokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is expressed in credits, rates included:&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;'prices'&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;'create_form'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'send_email'&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="s1"&gt;'rates'&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;'gpt-4o'&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;'input'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'output'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100_000&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 reads as 25,000 credits per million input tokens. What a credit is worth in money is your business and the package never asks.&lt;/p&gt;

&lt;p&gt;2 behaviours that read in opposite directions, on purpose. &lt;strong&gt;An action you never priced is free&lt;/strong&gt;, because a package should not invent a price for something you did not write down. &lt;strong&gt;A metered unit you never priced still costs something&lt;/strong&gt;, because the alternative is that metering an unknown model is free and the gap only shows up on your provider's invoice.&lt;/p&gt;

&lt;p&gt;Before you spend, you can ask:&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;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;allows&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// 1 credit?&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// 250?&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send_email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// enough for what that costs?&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send_email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// what it costs&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;meterPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// the same, uncharged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Charging itself does not refuse. An account with nothing left records the overdraft rather than leaving a turn half done, and the difference stays visible: every usage row stores the split between &lt;code&gt;credits_from_plan&lt;/code&gt; and &lt;code&gt;credits_from_purchased&lt;/code&gt;, and when they add up to less than &lt;code&gt;credits&lt;/code&gt;, that gap is the overdraft.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you sell sits outside every window
&lt;/h2&gt;

&lt;p&gt;Top-ups are not consumption, so they cannot be expressed as a sum of what was spent. They are their own table:&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;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;deposit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'purchase'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;deposit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'gift'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'launch promo'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;deposit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'adjustment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'duplicate charge'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Purchased credits survive every reset, and — this is the part that matters — what they pay for is &lt;strong&gt;not counted against the windows&lt;/strong&gt;. You run out of session, you buy more, you carry on, and your week has not moved meanwhile.&lt;/p&gt;

&lt;p&gt;The balance is stored rather than summed, which once you sell credits is not an optimisation but the model itself. It stays checkable anyway, because the deposits table is the ledger, and that is what you want the first time somebody asks why an account has 5,000 credits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plans are dynamically resolved
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;HasPlans&lt;/code&gt; and the plan is resolved by a list of providers, tried in order, first answer wins.&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;EduLazaro\Larameter\Concerns\HasCredits&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;EduLazaro\Larameter\Concerns\HasPlans&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;Organization&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;HasCredits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasPlans&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="s1"&gt;'plan_providers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;PlanProviders\ForcedPlanProvider&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="c1"&gt;// a column of yours, set by hand&lt;/span&gt;
    &lt;span class="nc"&gt;PlanProviders\CashierPlanProvider&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="c1"&gt;// the subscription, by price id&lt;/span&gt;
    &lt;span class="nc"&gt;PlanProviders\StoredPlanProvider&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="c1"&gt;// credits()-&amp;gt;setPlan(), then the default&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is the policy. Forced before Cashier means a plan somebody set by hand for a partner or a demo beats what Stripe thinks, because a person decided it deliberately. &lt;code&gt;CashierPlanProvider&lt;/code&gt; is inert without Cashier installed, so an app that only sells bundles pays nothing for leaving it there. For Paddle or anything else, implement the contract and add it to the list.&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;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;plan&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;handle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// 'pro'&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;plan&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;exists&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// false when no provider answered&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;plan&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api_access'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;plan&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;is&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also point the package at your own plans file, so the commercial half of a plan lives next to the metered half:&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;'plans_from'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'plans.tiers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using meters to set ceilings
&lt;/h2&gt;

&lt;p&gt;Credits are spent and come back. Seats and projects are different: a standing count of what exists, and the plan says how many at once.&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 make:meter MemberMeter Organization
&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MemberMeter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Meter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'members'&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;count&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;meterable&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;members&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;count&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;List it on the model, and ask:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Organization&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;HasCredits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasMeters&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$meters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;MemberMeter&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="nc"&gt;ProjectMeter&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="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;quota&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'members'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// room for one more?&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;quota&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'members'&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="c1"&gt;// inviting 4 at once&lt;/span&gt;
&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;quota&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;summary&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// the usage screen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A meter is a class and not a number you pass in&lt;/strong&gt;, and that is the whole point. The app this came from had a 1-seat plan, showed it on the usage screen, and never checked it when inviting: the cap was enforced for projects and forgotten for members, because enforcing it meant every caller had to remember to count first. Once counting lives in a class, no caller can forget.&lt;/p&gt;

&lt;p&gt;A resource with no meter is unlimited, and so is a &lt;code&gt;limits&lt;/code&gt; key you never listed. These are restrictions, and a package you just installed should not start refusing to create users on its own opinion. &lt;code&gt;-1&lt;/code&gt; is unlimited explicitly, which is not the same as &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usage screen
&lt;/h2&gt;

&lt;p&gt;Reading a window never opens one, so a screen is free to ask for all of them:&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credits&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;windows&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;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 'weekly'&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allowance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// 12_500&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;used&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// 12_000&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// 500&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;percentUsed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// 96.0&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// what to count from, per person or per feature&lt;/span&gt;
    &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;endsAt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the Monday coming, or null if none is running&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;endsAt()&lt;/code&gt; is not what the row says once a fixed window has expired. The grid went on without it, so a row claiming a Monday 3 weeks back answers with the Monday ahead, which is the only answer a screen can show.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;An allowance per window with 1 figure per plan, top-ups that outlive every reset, ceilings enforced by classes that know how to count, and a plan resolved on every request instead of stored and synchronised. Your billing keeps the parts that are yours (what a credit costs, what a plan is worth, who gets a courtesy account... etc) and stops re-implementing the parts that are the same in every project.&lt;/p&gt;

&lt;p&gt;👉 The package: &lt;a href="https://packagist.org/packages/edulazaro/larameter" rel="noopener noreferrer"&gt;packagist.org/packages/edulazaro/larameter&lt;/a&gt;&lt;br&gt;
👉 The source: &lt;a href="https://github.com/edulazaro/larameter" rel="noopener noreferrer"&gt;github.com/edulazaro/larameter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>larave</category>
      <category>php</category>
      <category>sass</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Move business logic out of your Laravel controllers with Laractions</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:09:59 +0000</pubDate>
      <link>https://dev.to/edulazaro/move-business-logic-out-of-your-laravel-controllers-with-laractions-5a1o</link>
      <guid>https://dev.to/edulazaro/move-business-logic-out-of-your-laravel-controllers-with-laractions-5a1o</guid>
      <description>&lt;p&gt;Sometimes you have a controller method that started small and did not stay that way. You validate the request, create a record, then fired a couple of side effects, return a response, ect.&lt;/p&gt;

&lt;p&gt;Every one of those steps is real work, and none of it belongs in the controller, but that is where it lands because the controller is where the request arrives.&lt;/p&gt;

&lt;p&gt;Laractions is a small package for moving that work into single-purpose classes. This post is a straight refactor: one fat controller method pulled apart into an action you can call from anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;Install via Composer. The service provider auto-registers and there is nothing to publish to get going:&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 edulazaro/laractions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The controller we are cleaning up
&lt;/h2&gt;

&lt;p&gt;Take this &lt;code&gt;store&lt;/code&gt; method. It works, and it is the kind of method that grows a new responsibility every sprint.&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;store&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&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;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'plan'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;send&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;SubscriptionStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nf"&gt;activity&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"subscription &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; created"&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="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="s1"&gt;'subscriptions.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;p&gt;The controller's job is to turn a request into a response. Creating the subscription and firing the side effects is business logic, and three other places will eventually want to reuse it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract it into an action
&lt;/h2&gt;

&lt;p&gt;Generate an action. The generator drops the &lt;code&gt;Action&lt;/code&gt; suffix, since &lt;code&gt;App\Actions&lt;/code&gt; already says what the class is.&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 make:action CreateSubscription
&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Actions&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;EduLazaro\Laractions\Action&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;CreateSubscription&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Action&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;array&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Subscription&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;send&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;SubscriptionStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nf"&gt;activity&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"subscription &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; created"&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;$subscription&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 the controller shrinks back to its actual job.&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;store&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&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;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'plan'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;CreateSubscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&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="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="s1"&gt;'subscriptions.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;p&gt;&lt;code&gt;create()&lt;/code&gt; resolves the action through the container, so if it needed a mailer or a billing client you would type-hint it in the constructor and get it injected. &lt;code&gt;run()&lt;/code&gt; calls &lt;code&gt;handle()&lt;/code&gt;. Because &lt;code&gt;handle()&lt;/code&gt; here takes a single &lt;code&gt;array&lt;/code&gt; parameter, the validated &lt;code&gt;$data&lt;/code&gt; array is passed through whole as that argument, which is why &lt;code&gt;run($data)&lt;/code&gt; just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing arguments however you have them
&lt;/h2&gt;

&lt;p&gt;That single-array call is one of three ways &lt;code&gt;run()&lt;/code&gt; forwards to &lt;code&gt;handle()&lt;/code&gt;. If &lt;code&gt;handle()&lt;/code&gt; took discrete parameters instead, all of these would land 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="nv"&gt;$action&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a@b.com'&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="c1"&gt;// positional&lt;/span&gt;
&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'a@b.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// named&lt;/span&gt;
&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'a@b.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'plan'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// keyed by parameter name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one rule to remember: a single &lt;code&gt;array&lt;/code&gt; parameter receives the array whole, the attribute-bag call from the refactor above, while a concrete typed parameter such as &lt;code&gt;handle(File $file)&lt;/code&gt; receives the value, not the array around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind an action to a model
&lt;/h2&gt;

&lt;p&gt;When an action is about a specific record, generate it against the model and it gets a typed property for that record.&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 make:action CancelSubscription &lt;span class="nt"&gt;--model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Subscription
&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Actions\Subscription&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;CancelSubscription&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Action&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&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;string&lt;/span&gt; &lt;span class="nv"&gt;$reason&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subscription&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reason'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$reason&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;Add the &lt;code&gt;HasActions&lt;/code&gt; trait to the model and register the actions it owns in an &lt;code&gt;$actions&lt;/code&gt; array, keyed by a short name. That key is how you call them.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Subscription&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;HasActions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'cancel'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CancelSubscription&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you run the action straight off the model instance by its key, which is the form I use everywhere:&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;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancel'&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer request'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model injects itself into the action's &lt;code&gt;$subscription&lt;/code&gt; property, so &lt;code&gt;handle()&lt;/code&gt; reads &lt;code&gt;$this-&amp;gt;subscription&lt;/code&gt; directly, and &lt;code&gt;run()&lt;/code&gt; executes it synchronously right there, with no queue involved. If you would rather not register a key, pass the class name instead and it resolves 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="nv"&gt;$subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CancelSubscription&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer request'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Let the action validate its own input
&lt;/h2&gt;

&lt;p&gt;An action can carry its own rules in a &lt;code&gt;$rules&lt;/code&gt; array, checked against the resolved arguments before &lt;code&gt;handle()&lt;/code&gt; runs. On failure it throws the same &lt;code&gt;ValidationException&lt;/code&gt; a form request throws, so the action guards itself even when it is called from a console command or a test that never went through HTTP validation.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateSubscription&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Action&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'plan'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|string'&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Subscription&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When you want it in the background
&lt;/h2&gt;

&lt;p&gt;The same action can queue itself. Swap &lt;code&gt;run()&lt;/code&gt; for &lt;code&gt;dispatch()&lt;/code&gt; and it goes onto the queue as a job, with &lt;code&gt;queue()&lt;/code&gt;, &lt;code&gt;delay()&lt;/code&gt; and &lt;code&gt;retry()&lt;/code&gt; to tune 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="nc"&gt;CreateSubscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&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;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'default'&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;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice I run everything synchronously with &lt;code&gt;run()&lt;/code&gt; and reach for a dedicated queued Job when I want background work, but if a given action is a clean unit to defer, &lt;code&gt;dispatch()&lt;/code&gt; is right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;If you liked this way of working aand want to collaborate with the development of Laractions, PRs are welcome 😊&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/laractions" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/laractions&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/laractions" rel="noopener noreferrer"&gt;https://github.com/edulazaro/laractions&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Model field keepers in Laravel with Larakeep</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:09:39 +0000</pubDate>
      <link>https://dev.to/edulazaro/model-field-keepers-in-laravel-with-larakeep-4ikp</link>
      <guid>https://dev.to/edulazaro/model-field-keepers-in-laravel-with-larakeep-4ikp</guid>
      <description>&lt;p&gt;Most Laravel models have a few fields nobody types in. A &lt;code&gt;total&lt;/code&gt; summed from line items, a &lt;code&gt;slug&lt;/code&gt;, a &lt;code&gt;search_text&lt;/code&gt; blob, a cached count. Something has to compute them, and that something usually ends up wedged into an observer or spread across the model until both are hard to read.&lt;/p&gt;

&lt;p&gt;Larakeep gives that computation a home. A Keeper is a small class that knows how to produce one or more of a model's fields, and the model runs it with a single call. This is the whole package, feature by feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;One Composer package, and the service provider auto-registers.&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 edulazaro/larakeep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing to publish. A keeper is a plain class, and the wiring is a trait plus an attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a keeper is
&lt;/h2&gt;

&lt;p&gt;A keeper holds the formulas for a model's derived fields, one method per field, and nothing else. It sits deliberately close to an action but aims at a different job: an action performs an operation, a keeper fills a field. Where an action might send an email, a keeper computes the &lt;code&gt;total&lt;/code&gt; that gets stored on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a keeper
&lt;/h2&gt;

&lt;p&gt;Generate one with the artisan command. The keeper takes the model in its constructor, and by convention it is created for the model whose name it carries, so &lt;code&gt;InvoiceKeeper&lt;/code&gt; targets &lt;code&gt;Invoice&lt;/code&gt;.&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 make:keeper InvoiceKeeper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each field maps to a method named &lt;code&gt;get&lt;/code&gt; followed by the PascalCase of the column, so a &lt;code&gt;total&lt;/code&gt; column is filled by &lt;code&gt;getTotal()&lt;/code&gt; and &lt;code&gt;amount_due&lt;/code&gt; by &lt;code&gt;getAmountDue()&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Keepers&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;App\Models\Invoice&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;InvoiceKeeper&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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;getTotal&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'amount'&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;getAmountDue&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTotal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&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;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;paid&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 method just returns the value. It does not assign it and it never touches the database; it is only the formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Binding a keeper to the model
&lt;/h2&gt;

&lt;p&gt;Add the &lt;code&gt;HasKeepers&lt;/code&gt; trait to the model and attach the keeper with the &lt;code&gt;#[KeptBy]&lt;/code&gt; attribute.&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;EduLazaro\Larakeep\Concerns\HasKeepers&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;EduLazaro\Larakeep\Attributes\KeptBy&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;App\Keepers\InvoiceKeeper&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[KeptBy(InvoiceKeeper::class)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invoice&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;HasKeepers&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 attribute is repeatable, so a model can carry several keepers, each owning different fields. If you prefer to keep the model clean, register the same binding in a service provider's &lt;code&gt;boot()&lt;/code&gt; 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;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvoiceKeeper&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Filling the fields
&lt;/h2&gt;

&lt;p&gt;Call &lt;code&gt;process()&lt;/code&gt; with a field name and the keeper's matching method runs, assigning its return value to the model attribute. Pass an array to fill several at once, and because &lt;code&gt;process()&lt;/code&gt; returns the model, you chain the save.&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;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'amount_due'&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;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing to keep in mind is that closing &lt;code&gt;save()&lt;/code&gt;. &lt;code&gt;process()&lt;/code&gt; sets the attributes in memory and hands the model back, but it does not persist anything on its own. The natural place to call it is the model's &lt;code&gt;saving&lt;/code&gt; observer, so the derived fields are always current right before a write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields that take arguments
&lt;/h2&gt;

&lt;p&gt;A method can take arguments. Suffix it with &lt;code&gt;With&lt;/code&gt; and pass the arguments as an array to &lt;code&gt;processWith()&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTotalWith&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;$currency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&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;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'EUR'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arguments go in as an array, one element per parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other verbs, not just get
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;get&lt;/code&gt; is only the default prefix. Name a method with any verb, say &lt;code&gt;refreshTotal()&lt;/code&gt;, and run it through &lt;code&gt;processTask()&lt;/code&gt; with that verb.&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;refreshTotal&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&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;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refresh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refresh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'amount_due'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its argument-taking cousin is &lt;code&gt;processTaskWith('refresh', 'total', ['EUR'])&lt;/code&gt;, the same as &lt;code&gt;processWith()&lt;/code&gt; with an explicit verb. This is handy when one keeper computes the same field in more than one way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That is Larakeep end to end: a keeper class holding the formulas, &lt;code&gt;#[KeptBy]&lt;/code&gt; to bind it, and &lt;code&gt;process()&lt;/code&gt; to run those formulas into the model's attributes before you save.&lt;/p&gt;

&lt;p&gt;The derived-field logic lives in one place per model instead of living on random unwanted places through an observer.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/larakeep" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/larakeep&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/larakeep" rel="noopener noreferrer"&gt;https://github.com/edulazaro/larakeep&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>opensource</category>
      <category>eloquent</category>
    </item>
    <item>
      <title>Book meetings in Laravel with Wireschedule</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sat, 08 Aug 2026 09:03:00 +0000</pubDate>
      <link>https://dev.to/edulazaro/book-meetings-in-laravel-with-wireschedule-3lmh</link>
      <guid>https://dev.to/edulazaro/book-meetings-in-laravel-with-wireschedule-3lmh</guid>
      <description>&lt;p&gt;Sometimes you want your visitors to be able to book a call or a demo with you, and you might not want to pay for Calendy or to use external widgets. &lt;/p&gt;

&lt;p&gt;You might also want the booking data in your own database, not leaving for someone else's dashboard, and you want the booking to actually plug into your app's pipeline instead of being a dead-end iframe.&lt;/p&gt;

&lt;p&gt;Wireschedule is a self-hosted meeting scheduler for Laravel that does exactly that. Availability lives in config, bookings land in your own table, and the UI is a Livewire component you drop in. Here is the fast path to getting it running and wired into your own logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;It is a normal Composer package, and the service provider registers its migration for you, so there is nothing to publish just to run it.&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 edulazaro/wireschedule
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only manual step is the stylesheet. Import it into your CSS bundle or the widget shows up unstyled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'../../vendor/edulazaro/wireschedule/resources/css/wireschedule.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no separate JS build. The Livewire view ships its own inline Alpine, so it uses the Livewire and Alpine you already have. Requirements are PHP 8.2+, Laravel 11+ and Livewire 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;Drop the Blade component wherever people should book. The &lt;code&gt;event&lt;/code&gt; you pass is a key you will define in config in a second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-wireschedule event="demo" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the look once on the root element. Wireschedule shares one theme attribute with the rest of the &lt;code&gt;wire*&lt;/code&gt; family, and the CSS ships eleven themes including &lt;code&gt;studio&lt;/code&gt;, &lt;code&gt;minimal&lt;/code&gt;, &lt;code&gt;brutalist&lt;/code&gt;, &lt;code&gt;glass&lt;/code&gt; and &lt;code&gt;neon&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;data-wire-theme=&lt;/span&gt;&lt;span class="s"&gt;"studio"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now shape the &lt;code&gt;demo&lt;/code&gt; event. Publish the config and open it up.&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 vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wireschedule-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event is a weekly availability map plus a few booking rules. The map is keyed by ISO weekday, &lt;code&gt;1&lt;/code&gt; for Monday through &lt;code&gt;7&lt;/code&gt; for Sunday, and each day is a list of &lt;code&gt;HH:MM&lt;/code&gt; ranges. &lt;code&gt;duration&lt;/code&gt; and &lt;code&gt;buffer&lt;/code&gt; are minutes, &lt;code&gt;days_ahead&lt;/code&gt; is the booking horizon, and &lt;code&gt;min_notice&lt;/code&gt; is the minimum minutes ahead someone can book.&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;'timezone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Europe/Madrid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// organiser zone; availability is read here, stored UTC&lt;/span&gt;
&lt;span class="s1"&gt;'events'&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;'demo'&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;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Demo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'duration'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'buffer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'days_ahead'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'min_notice'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'availability'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="mi"&gt;1&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;'10:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'14:00'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'16:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'18:00'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
            &lt;span class="mi"&gt;5&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;'10:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'14:00'&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;The component renders the available dates, the free slots for the selected date, and a short name and email form, saving a booking on confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the booking into your pipeline
&lt;/h2&gt;

&lt;p&gt;This is the part that makes it more than a form. After a successful save, the Scheduler dispatches a &lt;code&gt;wireschedule-booked&lt;/code&gt; Livewire event carrying the new booking &lt;code&gt;id&lt;/code&gt;. Listen for it in a parent Livewire component and fire whatever follow-up you need, like pinging your team channel or syncing a CRM.&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;Livewire\Attributes\On&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;BookingPage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[On('wireschedule-booked')]&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;onBooked&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;$id&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="c1"&gt;// notify your team, push to a CRM, dispatch a job...&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 the booking is a row in your database, you read it back with plain Eloquent. The model ships scopes so the common query stays short.&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;EduLazaro\WireSchedule\Models\Booking&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ofType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'demo'&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;confirmed&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;upcoming&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;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'starts_at'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the person was logged in when they booked, Wireschedule captures them automatically through a polymorphic &lt;code&gt;booker&lt;/code&gt; relation, so you can attribute the booking to an account with no extra work. For a guest it is just null.&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;$booking&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;booker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the authenticated user who booked, or null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Timezones and double-booking are handled
&lt;/h2&gt;

&lt;p&gt;You do not have to think about either of these, which is half the reason to use it. The visitor's browser timezone is detected client side and used to display the slots, then everything is persisted in UTC with the visitor's zone stored on the row, so your availability config stays in your own timezone and the maths just works.&lt;/p&gt;

&lt;p&gt;Double-booking is blocked at two layers: a unique &lt;code&gt;(event_type, starts_at)&lt;/code&gt; index on the table, plus a re-check that the slot is still free at confirm time, so two concurrent requests cannot grab the same slot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Good to know
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;event&lt;/code&gt; prop is locked, and on mount the component runs &lt;code&gt;abort_unless&lt;/code&gt; against the event config. If you pass an &lt;code&gt;event&lt;/code&gt; key that is not defined under &lt;code&gt;wireschedule.events&lt;/code&gt;, the component 404s instead of rendering an empty widget. Just define the event in  the config before you point the component at it and you are fine.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/wireschedule" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/wireschedule&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/wireschedule" rel="noopener noreferrer"&gt;https://github.com/edulazaro/wireschedule&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>opensource</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Store and search chunks in Laravel with Meilisearch and Larameili</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Fri, 07 Aug 2026 01:52:49 +0000</pubDate>
      <link>https://dev.to/edulazaro/store-and-search-chunks-in-laravel-with-meilisearch-and-larameili-1e3m</link>
      <guid>https://dev.to/edulazaro/store-and-search-chunks-in-laravel-with-meilisearch-and-larameili-1e3m</guid>
      <description>&lt;p&gt;Many times, you have to split documents into chunks, embed them, and push them into Meilisearch. Now the chunks live in the index and only in the index. There is no reason to keep a copy in your database: nothing joins a chunk, nothing edits one by hand, and the only question you ever ask is which chunks are relevant to this query, within this filter.&lt;/p&gt;

&lt;p&gt;The awkward part is querying them from Laravel. Scout wants to mirror an Eloquent model into the index, which is backwards here. So you end up talking to the raw Meilisearch client, building filter strings by hand and mapping arrays back into something usable.&lt;/p&gt;

&lt;p&gt;Larameili is for exactly this. It gives a Meilisearch index an Active Record model, so the chunks read like Eloquent even though they never touch your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;As usually, use composer, and the service provider auto-registers:&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 edulazaro/larameili
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the same &lt;code&gt;MEILISEARCH_HOST&lt;/code&gt; and &lt;code&gt;MEILISEARCH_KEY&lt;/code&gt; your app already uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is the index
&lt;/h2&gt;

&lt;p&gt;A model maps to one index. Declare the index name, the fields to filter on, and an embedder if you want hybrid search.&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;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Meili&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;EduLazaro\Larameili\Meili&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * @property string $id
 * @property int    $document_id
 * @property string $content
 * @property string $type
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Chunk&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Meili&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'chunks'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$searchable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$filterable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'document_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$embedders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'default'&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;'source'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'openAi'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text-embedding-3-small'&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;h2&gt;
  
  
  Push the settings
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;$filterable&lt;/code&gt; and &lt;code&gt;$embedders&lt;/code&gt; you declared live in code until you sync them to the engine. List the model in &lt;code&gt;config/larameili.php&lt;/code&gt; and run the command, which creates the index and applies the settings.&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 meili:sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Import the chunks in bulk
&lt;/h2&gt;

&lt;p&gt;An importer sends thousands of chunks. &lt;code&gt;import()&lt;/code&gt; streams them in batches and waits for each one, so a long import never outruns the engine, and it is memory-safe over a generator.&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;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$documentChunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are also &lt;code&gt;insert()&lt;/code&gt;, &lt;code&gt;updateMany()&lt;/code&gt; for partial updates, and &lt;code&gt;deleteWhere()&lt;/code&gt; to drop a document's chunks by filter.&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;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleteWhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"document_id = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Search, keyword then hybrid
&lt;/h2&gt;

&lt;p&gt;The query builder compiles to Meilisearch parameters and hydrates the hits back into &lt;code&gt;Chunk&lt;/code&gt; models. Start with a keyword search inside a filter.&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;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&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;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancellation policy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the index has an embedder, &lt;code&gt;semantic()&lt;/code&gt; turns it into a hybrid search: Meilisearch runs the keyword and the vector search together and fuses the rankings. &lt;code&gt;0&lt;/code&gt; is keyword only, &lt;code&gt;1&lt;/code&gt; is vector only.&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;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&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;semantic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&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;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'how do I get my money back'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Filter and paginate
&lt;/h2&gt;

&lt;p&gt;Filters map to Meilisearch's syntax, and &lt;code&gt;paginate()&lt;/code&gt; returns a Laravel &lt;code&gt;LengthAwarePaginator&lt;/code&gt; with an exact total, so it drops straight into a Blade 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="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'document_id'&lt;/span&gt;&lt;span class="p"&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&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;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// exact&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;links&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Back to the Eloquent record
&lt;/h2&gt;

&lt;p&gt;A chunk belongs to a &lt;code&gt;Document&lt;/code&gt; that does live in your database. Meilisearch has no joins, so this is a resolver: it looks the Eloquent model up by the foreign key stored on the chunk.&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;EduLazaro\Larameili\Relations\BelongsToEloquent&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;Chunk&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Meili&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;document&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsToEloquent&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsToEloquent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Document&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="n"&gt;foreignKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'document_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ownerKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'id'&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;Read it as a property and it resolves lazily. Eager-load it on a search with &lt;code&gt;with()&lt;/code&gt;, so every hit is resolved in one query instead of one per hit.&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;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Chunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;semantic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'document'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;// one whereIn for every hit&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'how do I get my money back'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&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;document&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="c1"&gt;// a normal Eloquent model&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And that's it
&lt;/h2&gt;

&lt;p&gt;The chunks stay in Meilisearch, where they belong, and you query them with a model instead of a raw client: filters, hybrid search, pagination, and a link back to the records that do live in your database. Your relational schema keeps the entities that earn a table, and the search documents stop pretending to be one of them.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/larameili" rel="noopener noreferrer"&gt;packagist.org/packages/edulazaro/larameili&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/larameili" rel="noopener noreferrer"&gt;github.com/edulazaro/larameili&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>meilisearch</category>
      <category>rag</category>
      <category>php</category>
    </item>
    <item>
      <title>Add toast messages in Laravel with Wiretoast</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:53:00 +0000</pubDate>
      <link>https://dev.to/edulazaro/add-toast-messages-in-laravel-with-wiretoast-nhk</link>
      <guid>https://dev.to/edulazaro/add-toast-messages-in-laravel-with-wiretoast-nhk</guid>
      <description>&lt;p&gt;Fire toast notifications in Laravel from PHP, Alpine and plain JavaScript with one notify call, plus positioning, auto-dismiss and grouping, and no CSS framework in your bundle&lt;/p&gt;

&lt;p&gt;Here is a problem I hit on every project. A Livewire action finishes and I need to tell the user it worked, but the toast library I grabbed assumes Tailwind, or ships its own huge runtime, or only works from JavaScript when half my triggers actually live in PHP. Wiretoast is my answer to that, and this post is the fast path to using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You want to fire a toast from PHP, from Alpine, and from plain JavaScript with the same call, and you do not want to drag a CSS framework into your bundle to get it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;Start with Composer, then wire up the assets. I bundle with Vite, so I import the package CSS and JS into my entry files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/app.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@wiretoast/js/wiretoast.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@wiretoast/css/wiretoast.css&lt;/span&gt;&lt;span class="dl"&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 &lt;code&gt;@wiretoast&lt;/code&gt; alias is optional, and you set it up by pointing Vite at the vendor resources folder so the imports stay short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@wiretoast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendor/edulazaro/wiretoast/resources&lt;/span&gt;&lt;span class="dl"&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;Then the component goes once into your layout, and on the Vite path it injects no tags of its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-wiretoast /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;The fastest possible win is a one-liner in a Livewire component right after something succeeds. The helper is a component macro named &lt;code&gt;notify&lt;/code&gt;, registered for you when Livewire is present.&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Profile updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood that dispatches a &lt;code&gt;notify&lt;/code&gt; browser event, which is exactly what Alpine fires too. So the same toast from a purely front-end button looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"$dispatch('notify', { message: 'Copied', type: 'info' })"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Copy link
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five types you can pass are &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt; and &lt;code&gt;neutral&lt;/code&gt;, and a message can be a plain string or an object with a &lt;code&gt;title&lt;/code&gt; and a &lt;code&gt;message&lt;/code&gt; when you want a heading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positioning and auto-dismiss
&lt;/h2&gt;

&lt;p&gt;Every toast takes an options object as the third argument, and the keys I reach for most are &lt;code&gt;position&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt; and &lt;code&gt;progress&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Heads up'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'position'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'bottom-center'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'progress'&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="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are seven positions available: &lt;code&gt;top-left&lt;/code&gt;, &lt;code&gt;top-center&lt;/code&gt;, &lt;code&gt;top-right&lt;/code&gt;, &lt;code&gt;bottom-left&lt;/code&gt;, &lt;code&gt;bottom-center&lt;/code&gt;, &lt;code&gt;bottom-right&lt;/code&gt; and &lt;code&gt;center&lt;/code&gt;. A &lt;code&gt;timeout&lt;/code&gt; of &lt;code&gt;0&lt;/code&gt; makes the toast persist until it is closed by hand, and &lt;code&gt;progress&lt;/code&gt; set to true draws a countdown bar that pauses while the pointer is over the toast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collapsing repeated toasts
&lt;/h2&gt;

&lt;p&gt;If an action can fire many times in a row, stacking ten identical toasts is just noise. Set &lt;code&gt;group&lt;/code&gt; to true and same-type messages fold into one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Item added&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;group&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a legacy shorthand, where passing &lt;code&gt;true&lt;/code&gt; as the third argument on the Livewire helper means the same thing.&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Item added'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'success'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Switching themes and dark mode
&lt;/h2&gt;

&lt;p&gt;The visual style is chosen with the &lt;code&gt;theme&lt;/code&gt; prop, and eleven themes are bundled, running from &lt;code&gt;minimal&lt;/code&gt; to &lt;code&gt;synthwave&lt;/code&gt;. You set it on the component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-wiretoast theme="soft" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dark mode follows the operating system by default through &lt;code&gt;prefers-color-scheme&lt;/code&gt;, and you can force a mode with the &lt;code&gt;mode&lt;/code&gt; prop when you want deterministic output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-wiretoast mode="dark" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Do not mix the two install paths
&lt;/h2&gt;

&lt;p&gt;This is the one thing that will bite you, so it is worth saying plainly. When you bundle with Vite the component injects no tags by design, which is why you import the CSS and JS yourself. If you instead publish the assets with &lt;code&gt;vendor:publish --tag=wiretoast-assets&lt;/code&gt;, that is the moment you add &lt;code&gt;:assets="true"&lt;/code&gt; to the component so it emits the link and script for you. Doing both, importing through Vite and passing &lt;code&gt;:assets="true"&lt;/code&gt;, loads everything twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;p&gt;Wiretoast is MIT licensed and open source.&lt;/p&gt;

&lt;p&gt;📌 You can see it in action &lt;a href="https://edulazaro.github.io/wiretoast/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;👉 Packagist: &lt;a href="https://packagist.org/packages/edulazaro/wiretoast" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/wiretoast&lt;/a&gt;&lt;br&gt;
👉 GitHub: &lt;a href="https://github.com/edulazaro/wiretoast" rel="noopener noreferrer"&gt;https://github.com/edulazaro/wiretoast&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Create multilanguage apps in Laravel with Laratext</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Thu, 06 Aug 2026 08:52:28 +0000</pubDate>
      <link>https://dev.to/edulazaro/create-multilanguage-to-a-laravel-app-with-laratext-52ig</link>
      <guid>https://dev.to/edulazaro/create-multilanguage-to-a-laravel-app-with-laratext-52ig</guid>
      <description>&lt;p&gt;You have a Laravel app in English and you need it in Spanish too. The built-in &lt;code&gt;__()&lt;/code&gt; helper works, but it forces an awkward trade. Either your templates are full of &lt;code&gt;__('some.dotted.key')&lt;/code&gt; you cannot read, or they hold the full sentence and every reworded string breaks its own translation. And either way you still write the Spanish by hand.&lt;/p&gt;

&lt;p&gt;Laratext fixes both. You name each string with a key and a readable default in one call, and a scan command translates the missing ones into every language you support. Here it is, simplest first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;One Composer package, then publish the config file.&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 edulazaro/laratext
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"texts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: name your strings
&lt;/h2&gt;

&lt;p&gt;Replace your hardcoded strings, or your &lt;code&gt;__()&lt;/code&gt; calls, with &lt;code&gt;text()&lt;/code&gt; in PHP and &lt;code&gt;@text&lt;/code&gt; in Blade. You pass a key and the English text as the default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;h1&amp;gt;@text('home.hero_title', 'Find your next home')&amp;lt;/h1&amp;gt;
&amp;lt;button&amp;gt;@text('common.save', 'Save changes')&amp;lt;/button&amp;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="nv"&gt;$message&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;'billing.trial_over'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Your trial has ended.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is what the translation is stored under, so you reword the English later without breaking anything, and the English stays right there in the template so it reads cleanly. Dynamic values use Laravel's &lt;code&gt;:name&lt;/code&gt; placeholders and survive translation intact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@text('cart.summary', 'You have :count items.', ['count' =&amp;gt; $cart-&amp;gt;count()])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: point it at a translator
&lt;/h2&gt;

&lt;p&gt;Open the &lt;code&gt;config/texts.php&lt;/code&gt; the publish step created. List the languages you support and pick the service that translates them. OpenAI, Google and Claude are built in.&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;'default_translator'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'openai'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="s1"&gt;'languages'&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;'en'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'English'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'es'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Spanish'&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 OpenAI translator reads its key from that config, which pulls &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; from your env by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;OPENAI_API_KEY&lt;/span&gt;=&lt;span class="n"&gt;sk&lt;/span&gt;-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other two work the same way with their own keys. To translate through Claude instead, set &lt;code&gt;default_translator&lt;/code&gt; to &lt;code&gt;claude&lt;/code&gt;, which reads &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; and runs on the Messages API with prompt caching, so repeated batches in a single scan reuse the cached instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: scan and translate
&lt;/h2&gt;

&lt;p&gt;Now run the scan. It reads every &lt;code&gt;text()&lt;/code&gt; and &lt;code&gt;@text&lt;/code&gt; call across your PHP and Blade files, finds the keys missing from Spanish, translates just those through the configured service, and writes them to &lt;code&gt;lang/es.json&lt;/code&gt;.&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 laratext:scan &lt;span class="nt"&gt;--write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your English is the source, taken from &lt;code&gt;app.locale&lt;/code&gt;, and every language in the config is a target. Before a real run, &lt;code&gt;--dry&lt;/code&gt; lists what it would add and &lt;code&gt;--diff&lt;/code&gt; shows the changes. You can also override the service for one run with &lt;code&gt;--translator=claude&lt;/code&gt;. Later, when you reword an English string, &lt;code&gt;--resync&lt;/code&gt; retranslates the keys whose source actually changed instead of only the brand-new ones.&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 laratext:scan &lt;span class="nt"&gt;--dry&lt;/span&gt;
php artisan laratext:scan &lt;span class="nt"&gt;--write&lt;/span&gt; &lt;span class="nt"&gt;--translator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;claude
php artisan laratext:scan &lt;span class="nt"&gt;--write&lt;/span&gt; &lt;span class="nt"&gt;--resync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest note: the OpenAI, Google and Claude translators all make real API calls, so a scan of a large app costs money on the provider side. Run &lt;code&gt;--dry&lt;/code&gt; first, and translate one language at a time with &lt;code&gt;--lang=es&lt;/code&gt;, if you want to keep it tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing your own translator
&lt;/h2&gt;

&lt;p&gt;If none of the three built-in services fits, the translator is just an interface. Generate one.&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 make:translator DeepLTranslator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a class extending the package's &lt;code&gt;Translator&lt;/code&gt; base. Implement &lt;code&gt;translate()&lt;/code&gt;, which takes one string and the target languages and returns the translations keyed by language code.&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;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Translators&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;EduLazaro\Laratext\Contracts\TranslatorInterface&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;EduLazaro\Laratext\Translator&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;DeepLTranslator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Translator&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;TranslatorInterface&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;translate&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;$text&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;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$to&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="nv"&gt;$results&lt;/span&gt; &lt;span class="o"&gt;=&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;$to&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$language&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$language&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;deepl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$language&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;$results&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;Because it extends &lt;code&gt;Translator&lt;/code&gt;, you also get &lt;code&gt;batchTranslate&lt;/code&gt;, which chunks a big set of strings into safe batches before sending. If your API takes many strings at once, override &lt;code&gt;translateMany&lt;/code&gt; to translate a batch in one request rather than one call per string. Then add the class to the &lt;code&gt;translators&lt;/code&gt; list in &lt;code&gt;config/texts.php&lt;/code&gt; and select it with &lt;code&gt;--translator=deepl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That is a whole second language: name each string once with &lt;code&gt;@text('key', 'English')&lt;/code&gt;, configure a translator, and run one scan to fill the rest. Swap the translator when you outgrow the defaults.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/laratext" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/laratext&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/laratext" rel="noopener noreferrer"&gt;https://github.com/edulazaro/laratext&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>laravel</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Turn Claude Code into a Laravel expert with LaraClaude</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Thu, 06 Aug 2026 00:29:37 +0000</pubDate>
      <link>https://dev.to/edulazaro/turn-claude-code-into-a-laravel-expert-with-laraclaude-4l42</link>
      <guid>https://dev.to/edulazaro/turn-claude-code-into-a-laravel-expert-with-laraclaude-4l42</guid>
      <description>&lt;p&gt;Claude Code writes PHP in Laravel quite well, but it starts every session as a generalist. It does not know your project has three hundred migrations that should be thirty, that a &lt;code&gt;@foreach&lt;/code&gt; two files over is firing an N+1, or that your modals follow one specific pattern. You end up re-explaining the same context constantly.&lt;/p&gt;

&lt;p&gt;LaraClaude packages that context as slash commands. It is a Claude Code plugin with over thirty Laravel skills, each a &lt;code&gt;/lc:&lt;/code&gt; command. Install it once and you have audits, scaffolders and cleanup tools that already know Laravel. Here are the ones I run most.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;LaraClaude installs through Claude Code's plugin system. Add the marketplace once, then install, so you get updates later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add edulazaro/laraclaude
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;laraclaude@edulazaro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab it directly from GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin &lt;span class="nb"&gt;install &lt;/span&gt;github:edulazaro/laraclaude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need Claude Code and a Laravel project. That is it for most skills; a couple that hit a live database also want Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit before you change anything
&lt;/h2&gt;

&lt;p&gt;Most skills default to a read-only report and only touch files when you add &lt;code&gt;fix&lt;/code&gt;, so start by looking. &lt;code&gt;/lc:find-n-plus-one&lt;/code&gt; scans your Blade views, Livewire components and controllers for a relationship accessed inside a loop, traces it back to the query that built the collection, and tells you the exact &lt;code&gt;with()&lt;/code&gt; to add.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/lc:find-n-plus-one
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/lc:security-audit&lt;/code&gt; is the other one I run on any project I inherit. It looks for SQL injection, XSS, mass-assignment and secrets committed to the repo, and like most fixable skills it takes a preview flag before it changes anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/lc:security-audit                 &lt;span class="c"&gt;# report&lt;/span&gt;
/lc:security-audit fix &lt;span class="nt"&gt;--dry-run&lt;/span&gt;   &lt;span class="c"&gt;# preview the fixes&lt;/span&gt;
/lc:security-audit fix             &lt;span class="c"&gt;# apply, with confirmation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Clean up what has piled up
&lt;/h2&gt;

&lt;p&gt;Every long-lived Laravel app accumulates migration cruft: a &lt;code&gt;create&lt;/code&gt; followed by twenty &lt;code&gt;add_column&lt;/code&gt; and &lt;code&gt;change_column&lt;/code&gt; files. &lt;code&gt;/lc:consolidate-migrations&lt;/code&gt; groups them by table, classifies each table as safe to merge or not, and folds the ALTERs back into the original &lt;code&gt;create&lt;/code&gt; while leaving data migrations alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/lc:consolidate-migrations              &lt;span class="c"&gt;# analyze&lt;/span&gt;
/lc:consolidate-migrations fix &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It refuses to run against a production database and asks before deleting anything, which is the behavior you want from something rewriting your schema history. &lt;code&gt;/lc:dead-code&lt;/code&gt; is its companion for the code side: unused classes, methods, routes and views.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaffold to your conventions, not a generic stub
&lt;/h2&gt;

&lt;p&gt;The generators read how your project already does things instead of emitting a boilerplate stub. &lt;code&gt;/lc:volt-component&lt;/code&gt; writes a Livewire Volt single-file component with the PHP block, validation and &lt;code&gt;@text()&lt;/code&gt; strings in place and a single root element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/lc:volt-component UserProfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/lc:generate-crud&lt;/code&gt; goes wider and produces the whole stack for a model, migration through views, and &lt;code&gt;/lc:generate-action&lt;/code&gt; creates a &lt;a href="https://github.com/edulazaro/laractions" rel="noopener noreferrer"&gt;Laractions&lt;/a&gt; action and registers it on the model. There is even &lt;code&gt;/lc:generate-scraper&lt;/code&gt;, which reads a real page and fills a &lt;a href="https://github.com/edulazaro/larascraper" rel="noopener noreferrer"&gt;Larascraper&lt;/a&gt; class from its actual markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand an unfamiliar app
&lt;/h2&gt;

&lt;p&gt;Two I reach for on a codebase I do not know yet. &lt;code&gt;/lc:model-diagram&lt;/code&gt; renders a Mermaid ER diagram of your Eloquent models and their relationships, which is the fastest way to see how everything connects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/lc:model-diagram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;/lc:analyze-model Property&lt;/code&gt; dumps one model completely: its relationships, casts, scopes, observers and the problems it notices. When something is already broken at runtime, &lt;code&gt;/lc:analyze-error&lt;/code&gt; takes the stacktrace and finds the root cause rather than the symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;LaraClaude is over thirty of these, from &lt;code&gt;/lc:deploy-checklist&lt;/code&gt; to &lt;code&gt;/lc:api-docs&lt;/code&gt;, all following the Agent Skills standard so they are just Markdown you can read. It turns the Laravel context you keep re-explaining into commands Claude Code runs on demand.&lt;/p&gt;

&lt;p&gt;👉 On GitHub at &lt;a href="https://github.com/edulazaro/laraclaude" rel="noopener noreferrer"&gt;https://github.com/edulazaro/laraclaude&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ai</category>
      <category>claude</category>
    </item>
    <item>
      <title>Add tags and categories to any model with Laraterms</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Thu, 06 Aug 2026 00:28:42 +0000</pubDate>
      <link>https://dev.to/edulazaro/add-tags-and-categories-to-any-model-with-laraterms-4ed7</link>
      <guid>https://dev.to/edulazaro/add-tags-and-categories-to-any-model-with-laraterms-4ed7</guid>
      <description>&lt;p&gt;Sometimes you need tags on a model. The usual answer is a &lt;code&gt;tags&lt;/code&gt; table, a pivot, a slug and a &lt;code&gt;belongsToMany&lt;/code&gt;, and you write it again in the next project with slightly different columns. Laraterms replaces that with a config entry and a trait, and it comes with the parts you normally bolt on later: hierarchy, per-tenant isolation and translations.&lt;/p&gt;

&lt;p&gt;This is the simple path first, then the two features you reach for next.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to install
&lt;/h2&gt;

&lt;p&gt;One package, its config and two migrations.&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 edulazaro/laraterms
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;laraterms-config
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;laraterms-migrations
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: define a taxonomy
&lt;/h2&gt;

&lt;p&gt;A taxonomy is a kind of label, declared in &lt;code&gt;config/laraterms.php&lt;/code&gt;. Start with a flat &lt;code&gt;tags&lt;/code&gt; taxonomy; the file already ships one you can keep.&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;'taxonomies'&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;'tags'&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;'hierarchical'&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="s1"&gt;'max_terms_per_model'&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="s1"&gt;'scope'&lt;/span&gt;               &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant'&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;h2&gt;
  
  
  Step 2: tag a model
&lt;/h2&gt;

&lt;p&gt;Add the &lt;code&gt;HasTerms&lt;/code&gt; trait and the model can hold terms. Attaching is find-or-create: pass a label, and the term is created the first time and reused afterwards.&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;EduLazaro\Laraterms\Concerns\HasTerms&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;HasTerms&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="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attachTerm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;attachTerms&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'Laravel'&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="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;syncTerms&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'Laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Vue'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// replace the tag set&lt;/span&gt;
&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;termsIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                          &lt;span class="c1"&gt;// read them back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering by tag is a query scope, so it composes with the rest of your query.&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;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereHasTerm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tags'&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="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereHasAllTerms&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tutorial'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tags'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hierarchical categories
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;hierarchical =&amp;gt; true&lt;/code&gt; on a taxonomy and its terms form a tree. Read the whole tree in one query, and walk a term's ancestry.&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;'categories'&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;'hierarchical'&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="s1"&gt;'max_terms_per_model'&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="s1"&gt;'scope'&lt;/span&gt;               &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant'&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EduLazaro\Laraterms\Support\TermTree&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TermTree&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="s1"&gt;'categories'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// roots with children, one query&lt;/span&gt;
&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;breadcrumb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' &amp;gt; '&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// "Tech &amp;gt; Web &amp;gt; Laravel"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;max_terms_per_model =&amp;gt; 1&lt;/code&gt; above is enforced: attaching a second category throws a &lt;code&gt;TooManyTermsException&lt;/code&gt; instead of quietly allowing two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping tenants apart
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;scope =&amp;gt; 'tenant'&lt;/code&gt; in each taxonomy means terms are isolated per tenant, which is the feature you would otherwise hand-roll with an &lt;code&gt;organization_id&lt;/code&gt; on every query. Tell Laraterms how to find a model's tenant once, in a service provider.&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;EduLazaro\Laraterms\Facades\Laraterms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Laraterms&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;resolveScopeUsing&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;$model&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;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a tag created in one organization is invisible to another, and the same label in two organizations is two separate terms. For taxonomies that really are shared across everyone, like languages, set &lt;code&gt;scope =&amp;gt; 'global'&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That is tags, categories and tenant isolation without a single pivot table of your own: a config entry per taxonomy, the &lt;code&gt;HasTerms&lt;/code&gt; trait, and attach and query calls. It also does translations and cross-locale search on the same two tables when you need them.&lt;/p&gt;

&lt;p&gt;👉 Package on Packagist: &lt;a href="https://packagist.org/packages/edulazaro/laraterms" rel="noopener noreferrer"&gt;https://packagist.org/packages/edulazaro/laraterms&lt;/a&gt;&lt;br&gt;
👉 Source on GitHub: &lt;a href="https://github.com/edulazaro/laraterms" rel="noopener noreferrer"&gt;https://github.com/edulazaro/laraterms&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>opensource</category>
      <category>eloquent</category>
      <category>php</category>
    </item>
  </channel>
</rss>
