<?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>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>
    <item>
      <title>Add Livewire modals in Laravel with Wiremodal</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Wed, 05 Aug 2026 09:53:00 +0000</pubDate>
      <link>https://dev.to/edulazaro/add-livewire-modals-in-laravel-with-wiremodal-1ka9</link>
      <guid>https://dev.to/edulazaro/add-livewire-modals-in-laravel-with-wiremodal-1ka9</guid>
      <description>&lt;p&gt;Wiremodal is a framework-agnostic modal package for Laravel, which allows to handle modals, so you don't have co configure them in all your projects.&lt;/p&gt;

&lt;p&gt;It ships a few Livewire-side helpers that make exactly this pleasant. This post is the Livewire integration end to end: opening and closing from PHP, delivering a payload on open, the one trap to avoid, and the optional form panel for when a modal happens to be a form.&lt;/p&gt;

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

&lt;p&gt;Pull the package in and get the assets onto the page.&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/wiremodal
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wiremodal-assets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service provider auto-registers and there is no config file. Point your layout at the published files:&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;link rel="stylesheet" href="{{ asset('vendor/wiremodal/css/wiremodal.css') }}"&amp;gt;
&amp;lt;script src="{{ asset('vendor/wiremodal/js/wiremodal.js') }}" defer&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you bundle with Vite, skip the publish and import straight from the vendor directory instead, so a package update flows through without re-publishing anything:&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="c"&gt;/* resources/css/app.css */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"../../vendor/edulazaro/wiremodal/resources/css/wiremodal.css"&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 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;../../vendor/edulazaro/wiremodal/resources/js/wiremodal.js&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;h2&gt;
  
  
  Opening and closing from Livewire
&lt;/h2&gt;

&lt;p&gt;Define the modal once with the &lt;code&gt;&amp;lt;x-wiremodal&amp;gt;&lt;/code&gt; component, give it a &lt;code&gt;name&lt;/code&gt;, and fill the &lt;code&gt;body&lt;/code&gt; and &lt;code&gt;footer&lt;/code&gt; slots. Here is a delete confirmation:&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-wiremodal name="confirm-delete" title="Delete record?" size="sm"&amp;gt;
    &amp;lt;x-slot:body&amp;gt;
        &amp;lt;p&amp;gt;This action cannot be undone.&amp;lt;/p&amp;gt;
    &amp;lt;/x-slot:body&amp;gt;
    &amp;lt;x-slot:footer&amp;gt;
        &amp;lt;button type="button" data-wm-dismiss&amp;gt;Cancel&amp;lt;/button&amp;gt;
        &amp;lt;button type="button" wire:click="destroy"&amp;gt;Delete&amp;lt;/button&amp;gt;
    &amp;lt;/x-slot:footer&amp;gt;
&amp;lt;/x-wiremodal&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Cancel button carries &lt;code&gt;data-wm-dismiss&lt;/code&gt;, and any element with that attribute closes the modal it sits in, so you never write a cancel handler. To open and close from the component itself, use the macros the package registers on every Livewire component:&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;confirmDelete&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="nf"&gt;openModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'confirm-delete'&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;destroy&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;// delete the record...&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;closeModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'confirm-delete'&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 is the whole open and close cycle from the server, and there is nothing to import: &lt;code&gt;openModal&lt;/code&gt; and &lt;code&gt;closeModal&lt;/code&gt; are macros wiremodal adds for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handing the modal a payload
&lt;/h2&gt;

&lt;p&gt;A confirmation needs no data, but most modals do: the row you clicked. &lt;code&gt;openModal()&lt;/code&gt; takes a second argument, and Wiremodal delivers it to the modal through a &lt;code&gt;wiremodal:opened&lt;/code&gt; event whose &lt;code&gt;detail&lt;/code&gt; is &lt;code&gt;{ name, data }&lt;/code&gt;, fired on both the modal element and &lt;code&gt;window&lt;/code&gt;. You read it with a one-line Alpine handler.&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;view&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="nv"&gt;$task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&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;openModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'task-detail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$task&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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$task&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="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 plaintext"&gt;&lt;code&gt;&amp;lt;x-wiremodal name="task-detail" title="Task" size="md"
    x-data="{ task: {} }"
    @wiremodal:opened.window="if ($event.detail.name === 'task-detail') task = $event.detail.data || {}"&amp;gt;
    &amp;lt;x-slot:body&amp;gt;
        &amp;lt;p x-text="task.title"&amp;gt;&amp;lt;/p&amp;gt;
    &amp;lt;/x-slot:body&amp;gt;
    &amp;lt;x-slot:footer&amp;gt;
        &amp;lt;button type="button" data-wm-dismiss&amp;gt;Close&amp;lt;/button&amp;gt;
    &amp;lt;/x-slot:footer&amp;gt;
&amp;lt;/x-wiremodal&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if ($event.detail.name === ...)&lt;/code&gt; guard matters because the event fires on &lt;code&gt;window&lt;/code&gt; for every modal, so each one only reacts to its own payload. The panel fills with the right row the instant it opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The positional-dispatch trap
&lt;/h2&gt;

&lt;p&gt;There is one mistake worth calling out, because it fails silently. Do not skip the macro and dispatch the browser event yourself with a positional string:&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;// Silently never opens the modal&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;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'open-wiremodal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'task-detail'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Livewire wraps that positional string into an array, so the browser receives &lt;code&gt;e.detail = ['task-detail']&lt;/code&gt;. Wiremodal's parser accepts a bare string or an object with a &lt;code&gt;name&lt;/code&gt; key, never an array, so the modal just never opens and no error is thrown. The &lt;code&gt;openModal()&lt;/code&gt; and &lt;code&gt;closeModal()&lt;/code&gt; macros exist precisely to avoid this, since they dispatch with named arguments under the hood. If you ever dispatch by hand, use the named form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&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="s1"&gt;'open-wiremodal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'task-detail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When the modal is a form
&lt;/h2&gt;

&lt;p&gt;Everything above works for any modal. When the modal happens to be a form and you want native submission, the Enter key and a real submit button, add &lt;code&gt;as="form"&lt;/code&gt;. It renders the panel as a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; instead of a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;, so &lt;code&gt;wire:submit&lt;/code&gt; fires on submit and the &lt;code&gt;autofocus&lt;/code&gt; attribute is honored.&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-wiremodal name="edit-task" title="Edit task" size="md"
    as="form" wire:submit="save"
    x-data
    @wiremodal:opened.window="
        if ($event.detail.name === 'edit-task') {
            $wire.set('editingId', $event.detail.data.id);
            $wire.set('title', $event.detail.data.title);
        }
    "&amp;gt;
    &amp;lt;x-slot:body&amp;gt;
        &amp;lt;label&amp;gt;
            Title
            &amp;lt;input type="text" wire:model="title" autofocus&amp;gt;
        &amp;lt;/label&amp;gt;
    &amp;lt;/x-slot:body&amp;gt;
    &amp;lt;x-slot:footer&amp;gt;
        &amp;lt;button type="button" data-wm-dismiss&amp;gt;Cancel&amp;lt;/button&amp;gt;
        &amp;lt;button type="submit"&amp;gt;Save&amp;lt;/button&amp;gt;
    &amp;lt;/x-slot:footer&amp;gt;
&amp;lt;/x-wiremodal&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$editingId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&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;save&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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;editingId&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;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;closeModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'edit-task'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You open it exactly as before with &lt;code&gt;openModal('edit-task', [...])&lt;/code&gt;; the only difference is &lt;code&gt;as="form"&lt;/code&gt; turning the panel into a real form so Enter and the submit button drive &lt;code&gt;wire:submit&lt;/code&gt;. Without it you would hang a &lt;code&gt;wire:click&lt;/code&gt; on the Save button instead. That is the point: the form is one prop, not the price of entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizes and persistent modals
&lt;/h2&gt;

&lt;p&gt;Two props round it out. &lt;code&gt;size&lt;/code&gt; takes one of eleven named widths and defaults to &lt;code&gt;2xl&lt;/code&gt; (42rem), running from &lt;code&gt;xs&lt;/code&gt; (20rem) to &lt;code&gt;7xl&lt;/code&gt; (80rem); a value outside the catalog throws an &lt;code&gt;InvalidArgumentException&lt;/code&gt; at render, so a typo fails loudly instead of producing a wrong-sized box.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;fullscreen&lt;/code&gt; to fill the viewport. And &lt;code&gt;persistent&lt;/code&gt; makes a modal ignore overlay clicks and the ESC key, so a wizard step or a &lt;em&gt;you must choose&lt;/em&gt; prompt only closes through an explicit &lt;code&gt;data-wm-dismiss&lt;/code&gt; button or a programmatic &lt;code&gt;closeModal()&lt;/code&gt;.&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-wiremodal name="preview" size="4xl"&amp;gt;
&amp;lt;x-wiremodal name="finish-checkout" title="Confirm order" persistent&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

</description>
      <category>laravel</category>
      <category>php</category>
      <category>livewire</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GDPR cookie consent in Laravel with Wirecookies</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sun, 02 Aug 2026 12:17:00 +0000</pubDate>
      <link>https://dev.to/edulazaro/gdpr-cookie-consent-in-laravel-with-wirecookies-5d3j</link>
      <guid>https://dev.to/edulazaro/gdpr-cookie-consent-in-laravel-with-wirecookies-5d3j</guid>
      <description>&lt;p&gt;Ship a compliant cookie banner in Laravel and actually gate analytics and marketing scripts on the user's choice, using the wirecookies-saved event and a plain localStorage object as the consent gate.&lt;/p&gt;

&lt;p&gt;Wirecookies is a Laravel package which handles the cookies consent for you. It gives you a consent banner and a preferences modal from a single Blade tag, and, more usefully, it hands you a plain &lt;code&gt;localStorage&lt;/code&gt; object and a browser event you can use as the gate for your analytics and marketing scripts. This article is built around that gate, not around how the banner looks.&lt;/p&gt;

&lt;p&gt;One thing to get out of the way first, because it will bite you otherwise: Wirecookies ships no JavaScript of its own and uses wiremodal's JS to open the preferences modal. If you skip the &lt;a href="https://github.com/edulazaro/wiremodal" rel="noopener noreferrer"&gt;wiremodal&lt;/a&gt; import in the install steps, the banner still shows and Accept all / Reject all still work, but the Configure button and the floating re-open button silently do nothing, with no error in the console. Do the JS step.&lt;/p&gt;

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

&lt;p&gt;Pull the package in with Composer. The service provider is auto-discovered, so there is nothing to register.&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/wirecookies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wirecookies depends on &lt;code&gt;edulazaro/wiremodal&lt;/code&gt;, which Composer pulls in for you. Now import the stylesheet in &lt;code&gt;resources/css/app.css&lt;/code&gt;, after a &lt;code&gt;wire*&lt;/code&gt; base (wiremodal or wiretoast) that defines the theme tokens.&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="c"&gt;/* resources/css/app.css */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'../../vendor/edulazaro/wiremodal/resources/css/wiremodal.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'../../vendor/edulazaro/wirecookies/resources/css/wirecookies.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bundle wiremodal's JS. This is the step that makes the Configure and re-open buttons work, so do not skip it.&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;../../vendor/edulazaro/wiremodal/resources/js/wiremodal.js&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;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;Drop the single Blade component once, near the end of your layout.&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-wirecookies :policy-url="route('cookies')" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First-time visitors get a bottom banner after a short delay. When they choose Accept all, Reject all, or save from the Configure modal, the banner is replaced by a floating button that reopens the preferences panel so they can change their mind later. That is the whole UI. The interesting part is what it writes down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gating scripts on consent
&lt;/h2&gt;

&lt;p&gt;This is the reason to reach for a real consent tool instead of a static banner. Wirecookies stores the choice as a plain object in &lt;code&gt;localStorage&lt;/code&gt; under the key &lt;code&gt;cookie-preferences&lt;/code&gt;, shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"essential"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"marketing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"functional"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So on the page load you read that object and only boot analytics if the user actually said yes.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie-preferences&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// load Google Analytics, Plausible, whatever&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers returning visitors who already decided. For the first visit, or when someone changes their choice in the modal, there is nothing in storage yet at load time, so you also listen for the moment they save. Wirecookies dispatches a &lt;code&gt;wirecookies-saved&lt;/code&gt; event that bubbles up to &lt;code&gt;window&lt;/code&gt;, and its &lt;code&gt;detail&lt;/code&gt; is the preferences object.&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wirecookies-saved&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// enable analytics now that they opted in&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marketing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// fire marketing pixels&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;Between those two, the load-time read and the save-time event, you have a complete gate. Scripts stay dormant until the matching category is true, and they light up the instant the user grants it, without a page reload. There is no package API to learn for any of this: it is a &lt;code&gt;localStorage&lt;/code&gt; key and a DOM event, both of which you already know how to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring the categories
&lt;/h2&gt;

&lt;p&gt;The categories are defined in config, so publish it if you want to change them.&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;wirecookies-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That drops &lt;code&gt;config/wirecookies.php&lt;/code&gt;, where each category carries behavior only. &lt;code&gt;essential&lt;/code&gt; is &lt;code&gt;required&lt;/code&gt; and can never be turned off; the rest are opt-in with a &lt;code&gt;default&lt;/code&gt; of &lt;code&gt;false&lt;/code&gt;, which is what keeps everything switched off until the user acts.&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;'essential'&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;'required'&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;'default'&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;'analytics'&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;'required'&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;'default'&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;'marketing'&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;'required'&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;'default'&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;'functional'&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;'required'&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;'default'&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="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keys here (&lt;code&gt;analytics&lt;/code&gt;, &lt;code&gt;marketing&lt;/code&gt;, and so on) are the same keys you read off the preferences object in the gate above, which is what ties the config to your JavaScript. The visible label and description for each category come from the translation files, so you do not hard-code copy in config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pointing at your cookie policy
&lt;/h2&gt;

&lt;p&gt;The other config key you will touch is the link to your policy. &lt;code&gt;policy_url&lt;/code&gt; accepts a single string used for every language.&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;'policy_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/cookies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your policy lives at a different path per language, pass an array keyed by locale and Wirecookies picks the right one from &lt;code&gt;app()-&amp;gt;getLocale()&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="s1"&gt;'policy_url'&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;'es'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/cookies'&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;'/en/cookies'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it to &lt;code&gt;null&lt;/code&gt; and the link is hidden entirely. Every config key also has a matching component prop (&lt;code&gt;policy-url&lt;/code&gt;, &lt;code&gt;delay&lt;/code&gt;, &lt;code&gt;categories&lt;/code&gt;, &lt;code&gt;storage-key&lt;/code&gt;) if you would rather override it per render than edit the config file. The strings themselves ship in Spanish and English out of the box and follow the app locale, with a fallback to &lt;code&gt;app.fallback_locale&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The banner is the part everyone sees, but the part that keeps you compliant is refusing to load tracking until consent exists. Wirecookies makes that a two-line job: read &lt;code&gt;cookie-preferences&lt;/code&gt; from &lt;code&gt;localStorage&lt;/code&gt; on load, listen for &lt;code&gt;wirecookies-saved&lt;/code&gt; on &lt;code&gt;window&lt;/code&gt;, and gate each script on the category it needs. No account, no external script, no tracking of its own, just a plain object in the browser and an event you can hook.&lt;/p&gt;

&lt;p&gt;📌 You can see Wirecookies in action at &lt;a href="https://crowd.legal/" rel="noopener noreferrer"&gt;Crowd Legal&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>laravel</category>
      <category>php</category>
      <category>cookie</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Localized routes in Laravel with Laralang</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:46:46 +0000</pubDate>
      <link>https://dev.to/edulazaro/localized-routes-un-laravel-with-laralang-4g0o</link>
      <guid>https://dev.to/edulazaro/localized-routes-un-laravel-with-laralang-4g0o</guid>
      <description>&lt;p&gt;Translating the text in a Laravel app is one problem, and allowing localized URLs is a different one. You want &lt;code&gt;/dashboard&lt;/code&gt; in English and &lt;code&gt;/es/panel&lt;/code&gt; in Spanish, both real routes that resolve, appear in &lt;code&gt;route:list&lt;/code&gt;, and can be linked without knowing which language the visitor is on. Doing it by hand means a route group per language, duplicated definitions, and locale detection glued on top.&lt;/p&gt;

&lt;p&gt;Laralang does that half. You declare a route once, list the languages, and it registers one real route per locale, names them, and keeps &lt;code&gt;route()&lt;/code&gt; working. Here is the whole thing, including the parts that are easy to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require edulazaro/laralang
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;"locales"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing is not optional in practice. The config ships with a single locale, and both the middleware and the URL generator read the list from there, so until you fill it in your other languages are not recognised.&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;// config/locales.php&lt;/span&gt;
&lt;span class="s1"&gt;'locales'&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="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'es'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="s1"&gt;'prefixes'&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;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// default language, no prefix&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;'es'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// /es/...&lt;/span&gt;
    &lt;span class="s1"&gt;'fr'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// /fr/...&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules about prefixes that save a debugging session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unprefixed language is &lt;code&gt;config('app.locale')&lt;/code&gt;, not the first item in the array.&lt;/strong&gt; If your app runs in Spanish, &lt;code&gt;APP_LOCALE=es&lt;/code&gt; is what makes &lt;code&gt;/contacto&lt;/code&gt; live at the root and &lt;code&gt;/en/contact&lt;/code&gt; carry a prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A value is the literal prefix, and without one the locale code is used.&lt;/strong&gt; A missing key, &lt;code&gt;null&lt;/code&gt; and an empty string all count as no value, so only the default language can end up at the root and two locales can never fight over the same URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declare a localized route
&lt;/h2&gt;

&lt;p&gt;Swap &lt;code&gt;Route::get&lt;/code&gt; for &lt;code&gt;LocalizedRoute::get&lt;/code&gt;. The second argument is the locale list. A bare locale reuses the base path, and &lt;code&gt;'locale' =&amp;gt; 'translated-path'&lt;/code&gt; gives that language its own URL.&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\Laralang\LocalizedRoute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;LocalizedRoute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'en'&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;'panel'&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="nc"&gt;DashboardController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That registers &lt;code&gt;/dashboard&lt;/code&gt; and &lt;code&gt;/es/panel&lt;/code&gt;. Everything you chain applies to every generated route, so middleware, &lt;code&gt;where&lt;/code&gt; constraints and the rest work as usual. There is a method per verb (&lt;code&gt;get&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;put&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, &lt;code&gt;options&lt;/code&gt;, &lt;code&gt;any&lt;/code&gt;) plus &lt;code&gt;match&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Resources work too, and this is where a package like this usually gives up:&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\Laralang\LocalizedResource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;LocalizedResource&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;'photos'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'en'&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'uri'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'fotos'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verbs'&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'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'crear'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'edit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editar'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nc"&gt;PhotoController&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;only&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's own resource registrar builds the routes, so &lt;code&gt;only()&lt;/code&gt;, &lt;code&gt;except()&lt;/code&gt;, &lt;code&gt;names()&lt;/code&gt;, &lt;code&gt;shallow()&lt;/code&gt; and &lt;code&gt;scoped()&lt;/code&gt; keep working and apply to every language at once. You get &lt;code&gt;/photos/create&lt;/code&gt; and &lt;code&gt;/es/fotos/crear&lt;/code&gt;, and both are named &lt;code&gt;photos.create&lt;/code&gt; under their locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route names carry the locale
&lt;/h2&gt;

&lt;p&gt;Every generated route is named &lt;code&gt;{locale}.{name}&lt;/code&gt;, and there is no route registered under the bare name. Be explicit when you need a specific language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'es.dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// /es/panel&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;'en.dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// /dashboard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And use the plain name everywhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// whatever the current locale is&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works because Laralang replaces Laravel's URL generator. &lt;code&gt;route('dashboard')&lt;/code&gt; looks for &lt;code&gt;es.dashboard&lt;/code&gt; when the app locale is Spanish, and falls back to a route literally named &lt;code&gt;dashboard&lt;/code&gt; if there is no localized one, which is what lets you mix &lt;code&gt;Route::get&lt;/code&gt; and &lt;code&gt;LocalizedRoute::get&lt;/code&gt; in the same app without thinking about it.&lt;/p&gt;

&lt;p&gt;Active states in navigation keep working too, because &lt;code&gt;routeIs()&lt;/code&gt; understands the naming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;request&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;routeIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// true on en.dashboard and on es.dashboard&lt;/span&gt;
&lt;span class="nf"&gt;request&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;routeIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'es.dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// true only when you are on the Spanish one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Translated slugs, not just translated paths
&lt;/h2&gt;

&lt;p&gt;This is where it stops being cosmetic. Route model binding accepts a different column per locale, so each language resolves its own slug:&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;LocalizedRoute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'property/{property:slug_en}'&lt;/span&gt;&lt;span class="p"&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;'property/{property:slug_en}'&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;'propiedad/{property:slug_es}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'fr'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'propriete/{property:slug_fr}'&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="nc"&gt;PropertyController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'properties.show'&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;/propiedad/piso-en-el-centro&lt;/code&gt; and &lt;code&gt;/en/property/flat-in-the-centre&lt;/code&gt; hit the same controller with the same model, resolved through a different column. And because &lt;code&gt;route()&lt;/code&gt; picks the localized route for the current locale, &lt;code&gt;route('properties.show', $property)&lt;/code&gt; builds the right URL with the right slug without any branching in your views.&lt;/p&gt;

&lt;p&gt;A package that registers a single route with a dynamic locale prefix cannot express this: there is one definition, so there is one binding field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every URL of the current page, in one call
&lt;/h2&gt;

&lt;p&gt;Language switchers, &lt;code&gt;hreflang&lt;/code&gt; tags and multi locale sitemaps are all the same question: what is this page called in the other languages?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@foreach(Laralang::alternates() as $locale =&amp;gt; $url)
    &amp;lt;link rel="alternate" hreflang="{{ $locale }}" href="{{ $url }}" /&amp;gt;
@endforeach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;alternates()&lt;/code&gt; carries the current route parameters over, so dynamic segments survive, and it only returns the locales that actually have a route registered. A page that exists in two of your three languages produces two links instead of a dead one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Groups and prefixes
&lt;/h2&gt;

&lt;p&gt;The locale prefix always goes first, even inside a prefixed group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;LocalizedRoute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'en'&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;'tablero'&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="nc"&gt;AdminController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin.dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// /admin/dashboard&lt;/span&gt;
&lt;span class="c1"&gt;// /es/admin/tablero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Detecting the locale
&lt;/h2&gt;

&lt;p&gt;Every route created by &lt;code&gt;LocalizedRoute&lt;/code&gt; already carries the &lt;code&gt;SetRouteLocale&lt;/code&gt; middleware, so the localized routes need no wiring. The middlewares matter for everything else: plain routes, and any part of the app with no locale in the path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SetRouteLocale&lt;/code&gt; reads the locale from the route name or the URL prefix, and stores it in the session.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SetSessionLocale&lt;/code&gt; applies whatever the session holds. This is the one for an admin panel where the language is a user setting, not part of the URL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SetBrowserLocale&lt;/code&gt; picks the best match from &lt;code&gt;Accept-Language&lt;/code&gt; on first visit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SetSmartLocale&lt;/code&gt; combines them: prefix when there is one, session or browser when there is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are idempotent, so stacking them is safe.&lt;/p&gt;

&lt;p&gt;Two behaviours worth knowing before they surprise you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default locale prefix is stripped with a 301.&lt;/strong&gt; A request to &lt;code&gt;/en/contact&lt;/code&gt; when English is the default redirects to &lt;code&gt;/contact&lt;/code&gt;, so each page has one canonical URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Livewire and AJAX requests take the session path.&lt;/strong&gt; Those hit an endpoint with no locale prefix, so &lt;code&gt;SetRouteLocale&lt;/code&gt; delegates to the session, which the previous full page load already wrote. If a Livewire component ever renders in the wrong language while the page around it is fine, that session value is the thing to inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rescuing URLs before they 404
&lt;/h2&gt;

&lt;p&gt;A URL gets shared without its prefix, or with the wrong one. &lt;code&gt;/servicios&lt;/code&gt; reaches an app running in English, &lt;code&gt;/es/services&lt;/code&gt; reaches one that expects the Spanish path. Both are a 404 by default, and both are a page you already have.&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;// config/locales.php&lt;/span&gt;
&lt;span class="s1"&gt;'fallback'&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requested&lt;/th&gt;
&lt;th&gt;Rescued to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/servicios&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/es/servicios&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/fr/servicios&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/es/servicios&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/es/services&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/services&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It only runs once the router has already failed, so normal traffic costs nothing, and the candidate URL is handed to the router rather than compared as a string, so routes with parameters are rescued too. Locales are tried with the default one first, so the destination depends only on the path and never on the visitor, which is what makes a permanent redirect safe here.&lt;/p&gt;

&lt;p&gt;If your app has its own fallback route, leave the config off and call &lt;code&gt;LocalizedRoute::fallback()&lt;/code&gt; yourself before it: Laravel runs the first fallback registered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefixing every language
&lt;/h2&gt;

&lt;p&gt;By default the main language lives at the root. Give it a prefix of its own and that stops:&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;'prefixes'&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;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// /en/about&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;'es'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// /es/sobre-nosotros&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is registered at &lt;code&gt;/&lt;/code&gt; any more, so turn the fallback on and the root sends visitors to their own language, taken from their stored choice and then from &lt;code&gt;Accept-Language&lt;/code&gt;. That one is a &lt;code&gt;302&lt;/code&gt; with &lt;code&gt;Vary: Accept-Language&lt;/code&gt;, never a &lt;code&gt;301&lt;/code&gt;, because the destination depends on who is asking and a permanent redirect would be cached and pin a single language for everyone behind it.&lt;/p&gt;

&lt;p&gt;Decide this before going live. Permanent redirects get cached by browsers and CDNs and are never re-checked, so changing the shape of your URLs later leaves returning visitors following redirects to pages that no longer exist. The package bounds that window with an explicit lifetime you control:&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;'redirect_max_age'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Signed URLs that survive translation
&lt;/h2&gt;

&lt;p&gt;A signed URL is built from the full URL, so a link generated in Spanish and validated in a request that switched to English fails the signature check. There is a &lt;code&gt;signed_localized&lt;/code&gt; middleware for exactly that: it reads the locale from the route name, sets it, and then runs Laravel's own 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="nc"&gt;LocalizedRoute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/verify-email/{id}/{hash}'&lt;/span&gt;&lt;span class="p"&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;'/verify-email/{id}/{hash}'&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;'/verificar-email/{id}/{hash}'&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="nc"&gt;VerifyEmailController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verify'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'signed_localized'&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;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'verification.verify'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ziggy
&lt;/h2&gt;

&lt;p&gt;If Ziggy 2 is installed, Laralang swaps its Blade route generator so the routes of the current locale are also published under their unprefixed name. &lt;code&gt;route('dashboard')&lt;/code&gt; in JavaScript then behaves like it does in PHP, and a plain route already using that name is never overwritten.&lt;/p&gt;

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

&lt;p&gt;That is localized routing done: one declaration per page, translated paths and translated slugs, resources included, &lt;code&gt;route()&lt;/code&gt; that follows the current language, &lt;code&gt;hreflang&lt;/code&gt; in one call, and a fallback that rescues the URLs you would otherwise lose.&lt;/p&gt;

&lt;p&gt;It runs on PHP 8.2 to 8.5 and Laravel 11, 12 and 13, with every combination tested on each push.&lt;/p&gt;

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

&lt;p&gt;If you are coming from another localization package, the repo has a migration guide for &lt;code&gt;mcamara/laravel-localization&lt;/code&gt; and another for &lt;code&gt;niels-numbers/laravel-localizer&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>i18n</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Collecting user feedback in Laravel with Wirebug</title>
      <dc:creator>Eduardo Lázaro</dc:creator>
      <pubDate>Sat, 01 Aug 2026 05:56:58 +0000</pubDate>
      <link>https://dev.to/edulazaro/collecting-user-feedback-in-laravel-with-wirebug-5bi</link>
      <guid>https://dev.to/edulazaro/collecting-user-feedback-in-laravel-with-wirebug-5bi</guid>
      <description>&lt;p&gt;Add a feedback widget to an existing Laravel app and end up with a queryable inbox, with automatic technical context and no design work.&lt;/p&gt;

&lt;p&gt;The goal for this post is small. Adding a feedback widget to your Laravel app, having a queryable inbox of reports sitting in your own database. No design work, no third-party service, no schema you have to write yourself.&lt;/p&gt;

&lt;p&gt;The package doing the work is Wirebug. It drops a widget into your app that lets users report a bug, a suggestion or anything else, attaches an optional screenshot or screen recording, and collects the technical context automatically. Every report is a plain Eloquent row you can read however you like.&lt;/p&gt;

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

&lt;p&gt;Wirebug is a Composer package, and it registers its own migration, so there is nothing to publish before you can 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/wirebug
php artisan migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs PHP 8.2+, Laravel 11+ and Alpine.js on the page. You also import the CSS in your bundle, wiremodal first and wirebug second, since wirebug builds on wiremodal's styles.&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/wiremodal/resources/css/wiremodal.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'../../vendor/edulazaro/wirebug/resources/css/wirebug.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your widget only lives behind a login, publish the config and lock the endpoint down. The submit route ships with &lt;code&gt;web&lt;/code&gt; and a throttle by default, and you add &lt;code&gt;auth&lt;/code&gt; yourself.&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;wirebug-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/wirebug.php&lt;/span&gt;
&lt;span class="s1"&gt;'route'&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;'enabled'&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;'path'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'wirebug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'middleware'&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;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:10,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;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;The integration is one Blade component. Put it in a layout and pick the shared theme once on the root element.&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;html data-wire-theme="studio"&amp;gt;
    ...
    &amp;lt;x-wirebug /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because attachments matter for this angle, the first thing worth configuring is where they go. Screenshots and recordings are written through Laravel's Storage, and the report row keeps only the path, so pointing them at S3 or an S3-compatible R2 disk instead of the local filesystem is a config change and nothing else.&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;// config/wirebug.php&lt;/span&gt;
&lt;span class="s1"&gt;'uploads'&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;'disk'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// any disk from config/filesystems.php&lt;/span&gt;
    &lt;span class="s1"&gt;'path'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'wirebug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'max_kb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// 5 MB screenshot cap&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screenshot accepts jpg, png, gif and webp. SVG is rejected on purpose, since an uploaded SVG is an XSS vector and there is no reason to accept one for a bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the reports
&lt;/h2&gt;

&lt;p&gt;This is the part the whole post is building toward. A report is an &lt;code&gt;EduLazaro\WireBug\Models\BugReport&lt;/code&gt;, and the model ships the scopes you need to build a triage screen with no extra code. A fresh report has a &lt;code&gt;status&lt;/code&gt; of &lt;code&gt;new&lt;/code&gt;, so an inbox of things you have not looked at yet is one 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EduLazaro\WireBug\Models\BugReport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$inbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BugReport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;new&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;latest&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;h3&gt;
  
  
  Filtering by type
&lt;/h3&gt;

&lt;p&gt;The selector offers a few report types, and you filter by them with &lt;code&gt;ofType&lt;/code&gt;. There is one trap to know about. The type keys are &lt;code&gt;bug&lt;/code&gt;, &lt;code&gt;idea&lt;/code&gt; and &lt;code&gt;other&lt;/code&gt;, and the middle one is labelled "Suggestion" in the UI, so the query for suggestions is &lt;code&gt;ofType('idea')&lt;/code&gt;, not &lt;code&gt;ofType('suggestion')&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;$bugs&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BugReport&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;'bug'&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="nv"&gt;$suggestions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BugReport&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;'idea'&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="c1"&gt;// not 'suggestion'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Who sent it
&lt;/h3&gt;

&lt;p&gt;Each report has a polymorphic &lt;code&gt;reporter&lt;/code&gt; relation. It resolves through your app's morph map, so it hands you back whatever model actually submitted the report, a &lt;code&gt;User&lt;/code&gt;, a &lt;code&gt;Client&lt;/code&gt;, or whatever you have mapped. For a guest who was not logged in, it is simply 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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$inbox&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$who&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;reporter&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'Guest'&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;h3&gt;
  
  
  Reaching a guest who left no account
&lt;/h3&gt;

&lt;p&gt;Because a guest has no reporter to attach, Wirebug shows non-authenticated visitors an optional email field so you can still reply. Authenticated users never see it, since you already know who they are. That field is on by the &lt;code&gt;ask_guest_email&lt;/code&gt; config flag, and the value lands on the report row for you to read next to the null reporter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets captured automatically
&lt;/h2&gt;

&lt;p&gt;Every report carries technical context that the user never typed. Wirebug records the current URL, the user agent, the viewport, the app locale and the referer, which together are usually enough to reproduce a problem without a single follow-up question. The whole block is behind the &lt;code&gt;capture_context&lt;/code&gt; config flag, so if you would rather not store any of it you flip one boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recording and wire:navigate
&lt;/h2&gt;

&lt;p&gt;Wirebug can capture a screen recording using the browser's native &lt;code&gt;getDisplayMedia&lt;/code&gt; and &lt;code&gt;MediaRecorder&lt;/code&gt;, capped by config at ninety seconds, fifty megabytes and a two megabit bitrate. Nothing records until the user clicks, and the record button only shows up on desktop browsers that support the API.&lt;/p&gt;

&lt;p&gt;The one operational detail to know is that the recording lives in the page's JavaScript context. It survives &lt;code&gt;wire:navigate&lt;/code&gt; navigation, so a user can keep recording as Livewire swaps pages, but a hard reload throws the context away and ends the recording. That is a browser limit, not something to work around.&lt;/p&gt;

&lt;p&gt;When a submit succeeds, Wirebug dispatches a &lt;code&gt;wirebug-sent&lt;/code&gt; event on &lt;code&gt;window&lt;/code&gt;, which is a convenient place to fire a toast or a tracking call. Add the component, point the disk where you want it, and you have a feedback inbox you can query with Eloquent.&lt;/p&gt;

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

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