<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hafiz</title>
    <description>The latest articles on DEV Community by Hafiz (@hafiz619).</description>
    <link>https://dev.to/hafiz619</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1284090%2F71b229af-8e87-4b83-8e79-e5176a1f561e.png</url>
      <title>DEV Community: Hafiz</title>
      <link>https://dev.to/hafiz619</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hafiz619"/>
    <language>en</language>
    <item>
      <title>Laravel Cloud Managed Queues vs Horizon: What You Give Up and What You Get</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 22 Jun 2026 06:17:23 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-cloud-managed-queues-vs-horizon-what-you-give-up-and-what-you-get-414o</link>
      <guid>https://dev.to/hafiz619/laravel-cloud-managed-queues-vs-horizon-what-you-give-up-and-what-you-get-414o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Laravel Cloud shipped Managed Queues on May 26, and the pitch is hard to ignore: queue workers that scale up when jobs pile up, scale to zero when there's nothing to do, and a built-in dashboard for failed jobs. No Supervisor configs, no Redis tuning, no &lt;code&gt;horizon:terminate&lt;/code&gt; in your deploy script.&lt;/p&gt;

&lt;p&gt;If you're running Horizon today, the obvious question is: should you care? I went through the docs, the launch post, and the pricing model to answer that properly. The short version: Managed Queues solve real problems, but Horizon users give up more than the announcement suggests. There's a 15-minute delay cap that will silently break a lot of apps, and the Redis features Horizon users lean on don't exist in the SQS world.&lt;/p&gt;

&lt;p&gt;Here's the honest breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Managed Queues Actually Are
&lt;/h2&gt;

&lt;p&gt;First, the architecture, because it explains everything else. With Managed Queues, Laravel Cloud becomes your queue driver. Under the hood it's SQS: your app needs &lt;code&gt;aws/aws-sdk-php&lt;/code&gt; in its &lt;code&gt;composer.json&lt;/code&gt;, and Cloud provisions the queue, configures access, and reads queue depth directly from SQS without going through your application.&lt;/p&gt;

&lt;p&gt;Every worker runs in its own isolated container with guaranteed memory. You pick a tier from 256 MiB up to 8 GiB, and CPU scales with it. When jobs arrive, Cloud spins up workers based on queue pressure (depth plus message age, not just depth). When the queue drains, workers scale back to zero and billing stops.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-cloud-managed-queues-vs-horizon" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Compare that to Horizon (v5.47 as of June 2026): a free package, Redis only, running on infrastructure you provision. You define worker pools and balancing strategies in &lt;code&gt;config/horizon.php&lt;/code&gt;, keep the master process alive with Supervisor, and remember to call &lt;code&gt;horizon:terminate&lt;/code&gt; on every deploy. Horizon gives you enormous control. It also gives you all the operational responsibility.&lt;/p&gt;

&lt;p&gt;That's the actual trade: control and Redis features versus zero infrastructure and pay-per-second billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scale to zero.&lt;/strong&gt; This is the headline. A Horizon setup on a &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;Forge-managed VPS or Hetzner box&lt;/a&gt; costs the same whether it processes a million jobs or none. Managed queue workers cost nothing while idle. For side projects, staging environments, and apps with bursty workloads, that's a real difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;True worker isolation.&lt;/strong&gt; Each worker gets its own container with guaranteed memory. One job blowing past its memory limit kills that one worker, not its neighbors. With Horizon, workers share the box: one memory-hungry job can take down everything on the same server, which is exactly the failure mode I covered when the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;AI SDK's default config starved a Horizon queue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Burst absorption without capacity planning.&lt;/strong&gt; Cloud scales from zero to your max worker cap automatically. With Horizon, &lt;code&gt;maxProcesses&lt;/code&gt; is bounded by the RAM you bought. Dispatching 10,000 jobs at once means either provisioning headroom you pay for all month, or watching the backlog drain slowly. &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Processing 10,000 tasks&lt;/a&gt; is a sizing exercise on Horizon. On Managed Queues it's a config field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed jobs dashboard your support team can use.&lt;/strong&gt; Failed jobs surface with full payload, exception, and stack trace, and anyone with environment access can retry with one click. No &lt;code&gt;php artisan queue:retry&lt;/code&gt; over SSH. For teams where non-developers field "my export never arrived" tickets, this matters more than it sounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause and purge from the UI.&lt;/strong&gt; Pause terminates workers immediately and stops billing while jobs accumulate safely. Note the difference from Horizon's &lt;code&gt;queue:pause&lt;/code&gt;: Horizon's version keeps workers alive (and on your server, still costing you), Cloud's version actually stops the meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Give Up
&lt;/h2&gt;

&lt;p&gt;This is the part the launch post doesn't dwell on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delayed jobs are capped at 15 minutes.&lt;/strong&gt; This is the biggest gotcha and it deserves the bold. SQS has a hard 15-minute delay limit, and Managed Queues inherit it. If your app does &lt;code&gt;-&amp;gt;delay(now()-&amp;gt;addHour())&lt;/code&gt; for a follow-up email, or schedules a retry for tomorrow morning, that breaks. Horizon on Redis delays jobs for as long as you want. Plenty of Laravel apps use long delays without thinking about it, and nothing in your code will warn you before you migrate. Audit every &lt;code&gt;delay()&lt;/code&gt; and &lt;code&gt;release()&lt;/code&gt; call before considering a move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-least-once delivery only.&lt;/strong&gt; No strict ordering, no exactly-once processing. If a worker hits the visibility timeout, gets terminated during a deploy, or a queue is paused mid-flight, jobs can be redelivered. Your jobs must be idempotent. Good queue code should be idempotent anyway, but with Horizon on Redis many apps quietly get away with assuming a job runs once. On Managed Queues that assumption will eventually bite you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizon's Redis toolbox doesn't come along.&lt;/strong&gt; Tags for monitoring specific models or job types. &lt;code&gt;Redis::funnel()&lt;/code&gt; and &lt;code&gt;Redis::throttle()&lt;/code&gt; for rate limiting third-party API calls. Balancing strategies that shift workers between queues based on load. Per-queue wait time alerts via Slack or SMS. None of this exists on Managed Queues. The dashboard shows volume, duration, memory, and worker counts per queue, which is good operational visibility, but it's not Horizon's per-tag, per-job granularity. (For deeper tracing, Laravel's answer is pairing Cloud with Nightwatch, which is another subscription.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One queue name per managed queue.&lt;/strong&gt; Horizon lets one supervisor drain &lt;code&gt;default,emails,exports&lt;/code&gt; with priorities. On Cloud, each queue name is its own managed queue with its own config. The Starter plan allows exactly 1 queue and 3 max workers, Growth allows 10 queues and 25 workers each, Business is uncapped (50 workers soft cap, raisable). If your app uses queue names for priority lanes, you'll restructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts.&lt;/strong&gt; A queue that scaled to zero takes several seconds to spin up its first worker (around 10 seconds per the engineering team). For background reports nobody notices. For user-facing jobs like "your download is ready," a 10-second floor on top of job runtime is noticeable. Workers can't currently be kept warm; a configurable warm minimum is planned but not shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard-only management, for now.&lt;/strong&gt; No API or CLI for creating, pausing, or configuring queues at launch. If you manage infrastructure as code, that's a regression from a &lt;code&gt;horizon.php&lt;/code&gt; file in version control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're on Laravel Cloud.&lt;/strong&gt; Obvious, but worth saying: Managed Queues only exist as a platform feature of Laravel Cloud. Horizon runs anywhere Redis does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pricing Math
&lt;/h2&gt;

&lt;p&gt;Managed Queues bill two meters: worker compute per second (by memory tier) and queue operations at $1 per million. An operation is any API call against the queue: every dispatch, receive, delete, and every polling check at your configured interval (default 5 seconds). Payloads are measured in 64 KB chunks, with a 1 MiB max per job.&lt;/p&gt;

&lt;p&gt;Run the numbers on a typical small production app: say 300,000 jobs a month, averaging 2 seconds each on a 256 MiB worker. That's roughly 600,000 worker-seconds, plus around a million operations (three per job, plus polling). You're looking at single-digit dollars per month, and a dev or staging environment that sits idle most of the day costs close to nothing.&lt;/p&gt;

&lt;p&gt;Now the Horizon side: a €4.49/month Hetzner CX22 runs Horizon comfortably for that workload, flat rate, with the rest of the box free for your app. At steady, predictable volume, a fixed server stays hard to beat, and at high sustained volume the per-second meter can climb past a beefier fixed server. The crossover isn't a single number because it depends on job duration and memory tier, but the shape is clear: spiky and idle-heavy favors Managed Queues, flat and sustained favors a fixed worker.&lt;/p&gt;

&lt;p&gt;That's also why Cloud itself still offers worker clusters (fixed workers running &lt;code&gt;queue:work&lt;/code&gt; continuously) and recommends them for sustained, predictable throughput. Even inside Laravel's own platform, autoscaling isn't always the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  So Who Should Switch?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay on Horizon if:&lt;/strong&gt; you use delays longer than 15 minutes anywhere, you depend on tags, &lt;code&gt;Redis::throttle()&lt;/code&gt;, or balancing strategies, your throughput is steady enough that a fixed server is cheaper, or you simply aren't on Laravel Cloud and have no other reason to move. Horizon is mature, free, and v5.47 shows it's still actively maintained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Managed Queues if:&lt;/strong&gt; you're already on Cloud (it should be your default there over app cluster background processes), your workload is bursty or idle-heavy, you want support staff retrying failed jobs from a UI, or you're starting a new project and would rather never write a Supervisor config. Start with a single queue at 256 MiB, watch the memory chart, and adjust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't switch mid-flight without an audit.&lt;/strong&gt; The delay cap and at-least-once semantics are silent breaking changes. Grep for &lt;code&gt;delay(&lt;/code&gt;, &lt;code&gt;release(&lt;/code&gt;, and &lt;code&gt;WithoutOverlapping&lt;/code&gt; and check every result before moving a production queue.&lt;/p&gt;

&lt;p&gt;My take: Managed Queues are the right default for new apps on Cloud, and the failed jobs dashboard alone will sell it to teams. But Horizon is not obsolete. It's the more capable tool that costs you operational attention instead of per-second billing. The 15-minute delay cap is the dealbreaker to check first, before price, before features.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I run Horizon on Laravel Cloud instead of Managed Queues?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Cloud's worker clusters let you self-manage &lt;code&gt;queue:work&lt;/code&gt; processes with your own driver, and you can run Horizon against a Redis instance there. You give up scale-to-zero and the built-in failed jobs dashboard, but you keep Redis features and long delays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Managed Queues work with Laravel 10?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The supported minimums are Laravel 11.53.1, 12.60.2, and 13.11.2, and your app must include &lt;code&gt;aws/aws-sdk-php&lt;/code&gt;. Deploys that create a managed queue on an unsupported version fail with a clear error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my &lt;code&gt;failed_jobs&lt;/code&gt; table?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Managed Queues surface failed jobs in the Cloud dashboard automatically, with no failed jobs driver to configure. Failures are visible cross-queue with payload, exception, and stack trace, and you can retry or delete from the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do scheduled jobs with long delays work if there's a 15-minute cap?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They don't, directly. The workaround is restructuring: instead of dispatching with a long delay, store the intended run time and let the scheduler dispatch the job when it's due. It's a known SQS pattern, but it's code you have to write and test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Horizon being deprecated?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No sign of that. Horizon v5.47.2 shipped in early June 2026 with support through Laravel 13. Cloud's own queue clusters were deprecated in favor of Managed Queues, but Horizon is a framework package, not a Cloud feature, and it remains the standard for self-hosted Redis queues.&lt;/p&gt;

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

&lt;p&gt;Managed Queues are Laravel Cloud's strongest argument yet for teams that hate running queue infrastructure: real isolation, honest pay-for-what-runs billing, and a failed jobs story Horizon never had. Horizon remains the choice when you need Redis semantics, long delays, fine-grained control, or predictable flat costs. Know which list describes your app before you touch anything, starting with that 15-minute delay cap.&lt;/p&gt;

&lt;p&gt;If you're weighing a move to Laravel Cloud or untangling a queue setup that's outgrown its config, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelcloud</category>
      <category>horizon</category>
      <category>queues</category>
    </item>
    <item>
      <title>Cashier (Stripe) vs Cashier (Paddle) for a Bootstrapped Laravel SaaS: The Numbers Nobody Shows You</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 17 Jun 2026 11:08:05 +0000</pubDate>
      <link>https://dev.to/hafiz619/cashier-stripe-vs-cashier-paddle-for-a-bootstrapped-laravel-saas-the-numbers-nobody-shows-you-7oj</link>
      <guid>https://dev.to/hafiz619/cashier-stripe-vs-cashier-paddle-for-a-bootstrapped-laravel-saas-the-numbers-nobody-shows-you-7oj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cashier-stripe-vs-paddle-real-cost-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every "Stripe vs Paddle" comparison I've read compares features. Webhook flexibility. Checkout customization. API surface area. That's useful if you're an engineer evaluating architecture, but it's useless if you're a bootstrapped founder trying to figure out how much money you'll actually keep.&lt;/p&gt;

&lt;p&gt;So let's do the math instead. I'm going to take a $100/month subscription and trace it through both platforms, including the fees that most comparisons conveniently leave out. Then I'll tell you which one I'd pick at different stages, and why.&lt;/p&gt;

&lt;p&gt;Both platforms have official Laravel packages: &lt;code&gt;laravel/cashier&lt;/code&gt; (v16.5, Stripe) and &lt;code&gt;laravel/cashier-paddle&lt;/code&gt; (v2.8, Paddle). Both support Laravel 10 through 13. The integration quality is comparable. So this really comes down to money and operational cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe Actually Costs
&lt;/h2&gt;

&lt;p&gt;Stripe's headline rate is 2.9% + $0.30 per transaction in the US, or 1.5% + €0.25 in most of Europe. On a $100 subscription, that's $3.20 in the US. Simple enough.&lt;/p&gt;

&lt;p&gt;But if you're running a &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;SaaS with recurring billing&lt;/a&gt;, that's not the full picture. You'll probably want Stripe Tax to handle VAT and sales tax automatically. That adds 0.5% per transaction where tax is collected. On our $100 charge, that's another $0.50.&lt;/p&gt;

&lt;p&gt;If your customers pay with international cards, add another 1.5%. Currency conversion adds 1-2% on top of that. And chargebacks cost $15 each, win or lose.&lt;/p&gt;

&lt;p&gt;Here's the realistic breakdown for a $100 US subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base processing:        $3.20  (2.9% + $0.30)
Stripe Tax:             $0.50  (0.5%)
────────────────────────────────
Total Stripe fees:      $3.70
You keep:               $96.30
Effective rate:         3.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an EU seller charging €100 to an EU customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base processing:        €1.75  (1.5% + €0.25)
Stripe Tax:             €0.50  (0.5%)
────────────────────────────────
Total Stripe fees:      €2.25
You keep:               €97.75
Effective rate:         2.25%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That EU rate looks great. But there's a catch. Stripe Tax calculates and collects the tax. It doesn't file your returns everywhere automatically. You'll still need to register for OSS (One-Stop Shop) in the EU and handle filing, either yourself or through a service. Some of that is included in Stripe's Tax Complete tier, but you'll need to verify coverage for your specific countries.&lt;/p&gt;

&lt;p&gt;And none of this includes the time you spend managing it. Configuring &lt;a href="https://hafiz.dev/blog/stripe-integration-in-laravel-complete-guide-to-subscriptions-one-time-payments" rel="noopener noreferrer"&gt;Stripe webhooks&lt;/a&gt;, handling failed payments, generating compliant invoices, dealing with chargebacks. That's your time or your developer's time, and it has a cost even if it doesn't show up on an invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Paddle Actually Costs
&lt;/h2&gt;

&lt;p&gt;Paddle's rate is 5% + $0.50 per transaction. Full stop. On a $100 subscription, you pay $5.50. You keep $94.50.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;All-inclusive fee:      $5.50  (5% + $0.50)
────────────────────────────────
Total Paddle fees:      $5.50
You keep:               $94.50
Effective rate:         5.5%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 5.5% looks painful compared to Stripe's 3.7%. But here's what's included in Paddle's fee that isn't included in Stripe's:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tax compliance in 200+ countries.&lt;/strong&gt; Paddle registers, calculates, collects, and files sales tax, VAT, and GST everywhere. You don't touch it. You don't even think about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merchant of Record status.&lt;/strong&gt; Paddle is the legal seller. Your customer's credit card statement shows "Paddle" (or a Paddle subsidiary), not your company. This means Paddle handles refunds, chargebacks, and compliance obligations. If a customer disputes a charge, Paddle deals with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chargeback protection.&lt;/strong&gt; Included in the 5%. No $15 per dispute fee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoicing.&lt;/strong&gt; Paddle generates compliant invoices automatically for every jurisdiction.&lt;/p&gt;

&lt;p&gt;The trade-off is clear: Paddle costs more per transaction, but it removes an entire category of operational work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Number That Actually Matters
&lt;/h2&gt;

&lt;p&gt;The fee difference on a $100 transaction is $1.80 ($5.50 Paddle vs $3.70 Stripe). On $1,000 MRR, that's $18/month. On $5,000 MRR, it's $90/month. On $10,000 MRR, it's $180/month.&lt;/p&gt;

&lt;p&gt;Now compare that to the cost of handling tax compliance yourself with Stripe:&lt;/p&gt;

&lt;p&gt;If you're an EU-based solo developer (like me, based in Italy), selling to customers across the EU, you need to handle VAT via OSS. Stripe Tax helps with calculation, but the filing, the registration, the record-keeping: that takes time. Conservative estimate: 2-4 hours per month at the start. If your time is worth $50/hour, that's $100-200/month in opportunity cost.&lt;/p&gt;

&lt;p&gt;Add in the occasional chargeback investigation ($15 each plus your time), invoice formatting issues, and the mental load of being legally responsible for tax compliance in dozens of countries. The $90/month difference at $5,000 MRR starts looking like a bargain for Paddle.&lt;/p&gt;

&lt;p&gt;But. At $10,000+ MRR, the math shifts. By then you probably have a bookkeeper or accountant. Tax filing is systematized. The $180/month gap becomes real money over a year ($2,160). And Stripe gives you more control: more checkout customization, more webhook flexibility, deeper integration with your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Laravel Integration Side
&lt;/h2&gt;

&lt;p&gt;Both packages follow similar patterns. Install, configure, add the &lt;code&gt;Billable&lt;/code&gt; trait to your User model. Both handle subscriptions, plan swapping, cancellation grace periods.&lt;/p&gt;

&lt;p&gt;The practical differences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout flow.&lt;/strong&gt; Stripe lets you build a fully custom checkout within your app. Paddle uses an overlay or redirect checkout hosted by Paddle. If you want the payment form embedded directly in your Blade views, Stripe gives you that control. With Paddle, the customer briefly leaves your UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks.&lt;/strong&gt; Both packages register webhook routes automatically. Stripe sends more granular &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;events&lt;/a&gt; (payment_intent.succeeded, invoice.payment_failed, customer.subscription.updated). Paddle sends fewer, broader events (transaction.completed, subscription.activated). For most SaaS apps, both provide everything you need. Stripe's granularity matters more if you're building complex billing flows with metered usage or prorations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunds.&lt;/strong&gt; With Stripe (via &lt;code&gt;laravel/cashier&lt;/code&gt;), you call &lt;code&gt;$user-&amp;gt;refund($paymentId)&lt;/code&gt; and handle the money movement yourself. With Paddle (via &lt;code&gt;laravel/cashier-paddle&lt;/code&gt;), you call &lt;code&gt;$transaction-&amp;gt;refund()&lt;/code&gt; and Paddle handles everything including the tax adjustment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; Both have sandbox/test modes. Stripe's test mode is more mature with more test card numbers and edge case simulation. Paddle's sandbox works but has fewer testing tools.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Under $5,000 MRR, especially if you sell to EU customers: use Paddle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The time you save on tax compliance, chargeback handling, and invoice generation is worth more than the fee difference. You should be building features and acquiring customers, not debugging VAT edge cases in Slovenia. Paddle lets you ship billing in an afternoon and forget about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Above $10,000 MRR, or if you need deep checkout customization: use Stripe.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this scale, you can afford to systematize the tax compliance work. The per-transaction savings add up. And Stripe's flexibility lets you build billing experiences that Paddle's checkout overlay can't match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Between $5,000 and $10,000 MRR:&lt;/strong&gt; This is the gray zone. If you're still a solo dev or a tiny team, stay on Paddle. If you've hired help and your accountant has capacity, the Stripe migration makes sense.&lt;/p&gt;

&lt;p&gt;One more thing to consider. Switching from Paddle to Stripe (or vice versa) mid-flight is painful. Active subscriptions can't be cleanly migrated. Customers may need to re-enter payment details. You could lose 10-20% of subscribers in the churn. So pick the one that fits your next 12-18 months, not just today.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I start with Paddle and switch to Stripe later?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can, but it's not seamless. Paddle is the Merchant of Record, so they own the customer billing relationship. When you migrate, each customer needs to set up a new subscription with Stripe. There's no automatic migration path. Plan for some churn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Paddle's checkout hurt conversion rates?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paddle's overlay checkout is polished and trusted, but it does briefly take the customer out of your UI. Some developers report no measurable difference. Others see a small drop. If your audience is technical (developers, designers), they're less likely to be spooked by a Paddle checkout. If you're selling to enterprise buyers, a branded Stripe checkout embedded in your app may convert better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Lemon Squeezy as an alternative?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lemon Squeezy is another Merchant of Record platform (now owned by Stripe). It's popular with indie developers and often compared to Paddle. However, there's no official Laravel Cashier package for Lemon Squeezy. You'd need a third-party integration or build your own. If Laravel-native billing is important to you, it's Stripe or Paddle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I still need an accountant with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but your accountant's job gets much simpler. Paddle sends you a monthly payout with a single invoice for their services. Your accountant records one line item of income instead of hundreds of individual transactions with varying tax treatments across jurisdictions. It's the difference between a 30-minute task and a multi-hour one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the 5% + $0.50 negotiable with Paddle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paddle offers custom pricing for businesses processing over $50,000/month. If you're approaching that volume, reach out to their sales team. At scale, the negotiated rate can close much of the gap with Stripe.&lt;/p&gt;

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

&lt;p&gt;The right choice depends on where you are, not where you want to be. If you're in the first year of a bootstrapped SaaS, every hour you spend on billing infrastructure is an hour you're not spending on the product. Paddle's higher fee buys you that time back. If you're past product-market fit and optimizing unit economics, Stripe's lower fees compound into real savings.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;laravel/cashier&lt;/code&gt; and &lt;code&gt;laravel/cashier-paddle&lt;/code&gt; are well-maintained, first-party packages with official Laravel documentation. The integration quality won't be your bottleneck either way. If you're building a Laravel SaaS and want help picking the right billing setup for your stage, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>saas</category>
      <category>stripe</category>
      <category>paddle</category>
    </item>
    <item>
      <title>Multi-Tenancy + Queues: The Three Bugs Every Laravel SaaS Hits in Its First Year</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 15 Jun 2026 05:54:01 +0000</pubDate>
      <link>https://dev.to/hafiz619/multi-tenancy-queues-the-three-bugs-every-laravel-saas-hits-in-its-first-year-al1</link>
      <guid>https://dev.to/hafiz619/multi-tenancy-queues-the-three-bugs-every-laravel-saas-hits-in-its-first-year-al1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your multi-tenant Laravel app works perfectly in development. Every test passes. You push to production, onboard your first ten customers, and everything looks fine. Then one morning a customer emails you a screenshot of someone else's dashboard data.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical. It's what happens when multi-tenancy and queues collide without the right safeguards. The combination is tricky because queue workers are long-running daemons. Unlike HTTP requests (which boot fresh for every visitor), a queue worker starts once and processes hundreds of jobs sequentially. Any tenant state left over from one job bleeds into the next.&lt;/p&gt;

&lt;p&gt;I've seen three specific bugs come up again and again in &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;multi-tenant Laravel SaaS apps&lt;/a&gt;. They're all silent. They don't throw exceptions in local development. They don't fail your test suite. And they can leak data between tenants in production. Here's what they are and how to fix each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: Tenant Context Evaporates in Queued Jobs
&lt;/h2&gt;

&lt;p&gt;This is the classic one, and it's the scariest because it fails silently.&lt;/p&gt;

&lt;p&gt;You dispatch a job from a controller while Tenant A is active. The job gets serialized to the queue. A worker picks it up seconds later. But the worker process has no idea which tenant dispatched that job. It's running in the central (landlord) context, or worse, it's still holding stale context from the previous job it processed for Tenant B.&lt;/p&gt;

&lt;p&gt;Here's the flow that causes the data leak:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/multi-tenancy-queues-three-bugs-laravel-saas" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem gets worse if you're using &lt;code&gt;SerializesModels&lt;/code&gt;. Laravel's model serialization stores the model's ID and class name, then rehydrates the model when the job runs. But rehydration calls &lt;code&gt;newQueryForRestoration()&lt;/code&gt;, which applies any global scopes, including your &lt;code&gt;BelongsToTenant&lt;/code&gt; scope. That scope tries to filter by &lt;code&gt;tenant_id&lt;/code&gt;, but &lt;code&gt;tenant_id&lt;/code&gt; is null because no tenant is active yet. The query returns nothing. &lt;code&gt;ModelNotFoundException&lt;/code&gt;. Your job fails, but the real cause is buried under a misleading error message.&lt;/p&gt;

&lt;p&gt;The worst part? This never shows up in tests. Most test suites use &lt;code&gt;Queue::fake()&lt;/code&gt; or the &lt;code&gt;sync&lt;/code&gt; driver, which processes jobs inline with no serialization round-trip. The bug only appears with a real queue worker.&lt;/p&gt;

&lt;p&gt;And it's hard to spot in production too. If your models don't use tenant-scoped global scopes, you won't even get exceptions. The job will happily query the central database, find nothing (or find the wrong tenant's data), and complete "successfully." You'll only discover the problem when a customer reports seeing data that isn't theirs. Or worse, when they don't report it because they didn't notice.&lt;/p&gt;

&lt;p&gt;Here's how to detect it before a customer does. Add a sanity check at the start of every tenant job's &lt;code&gt;handle()&lt;/code&gt; method during your early production phase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;report&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"Job "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;" ran without tenant context. "&lt;/span&gt;
            &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"Expected tenant: &lt;/span&gt;&lt;span class="si"&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;tenantId&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;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Proceed with actual job logic...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you an early warning in your error tracker instead of a silent data leak. Remove it once you've confirmed your bootstrapper is working correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Both major tenancy packages handle this, but you have to explicitly enable it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy (v3.10):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enable the &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; in your &lt;code&gt;config/tenancy.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'bootstrappers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Add this&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serializes the current tenant's ID into the job payload and restores tenant context before the job runs. But there's a catch. The bootstrapper fires &lt;em&gt;after&lt;/em&gt; &lt;code&gt;SerializesModels&lt;/code&gt; tries to rehydrate your models. So don't pass tenant-scoped Eloquent models directly into job constructors. Pass the ID 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="c1"&gt;// Bad: model rehydration runs before tenant context is restored&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;public&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Good: pass the ID, query inside handle()&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$invoiceId&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Tenant context is active by this point&lt;/span&gt;
        &lt;span class="nv"&gt;$invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&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;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Process it...&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;strong&gt;With spatie/laravel-multitenancy (v4.1):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;queues_are_tenant_aware_by_default&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in &lt;code&gt;config/multitenancy.php&lt;/code&gt;. Or implement the &lt;code&gt;TenantAware&lt;/code&gt; marker interface on individual jobs:&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;Spatie\Multitenancy\Jobs\TenantAware&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;ProcessInvoice&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TenantAware&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This job will automatically run in the correct tenant context&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jobs that should explicitly run in the central context can implement &lt;code&gt;NotTenantAware&lt;/code&gt; instead. The same &lt;code&gt;SerializesModels&lt;/code&gt; warning applies here: pass IDs, not models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Cache Key Collisions Across Tenants
&lt;/h2&gt;

&lt;p&gt;This bug is quieter than the first one. No exceptions, no failed jobs. Just wrong numbers on a dashboard that nobody notices for weeks.&lt;/p&gt;

&lt;p&gt;Imagine you cache a dashboard stat:&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;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'monthly_revenue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addHour&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key, &lt;code&gt;monthly_revenue&lt;/code&gt;, is the same string for every tenant. If Tenant A's &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue job processes a report&lt;/a&gt; and caches the result, Tenant B reads the same cache key and sees Tenant A's revenue figure.&lt;/p&gt;

&lt;p&gt;The obvious fix is to prefix every cache key with the tenant ID manually. But that's error-prone because you'll forget it in at least one place. The better fix is to let the tenancy package handle prefixing globally.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;CacheTenancyBootstrapper&lt;/code&gt; (shown in the Bug 1 config above) automatically prefixes all cache keys with the tenant's identifier. Every &lt;code&gt;Cache::get()&lt;/code&gt; and &lt;code&gt;Cache::put()&lt;/code&gt; call is transparently scoped. You don't change your application code at all.&lt;/p&gt;

&lt;p&gt;But watch for these edge cases that the bootstrapper doesn't catch automatically:&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;// These need manual tenant scoping:&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Redis locks&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"report_generation_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Rate limiting keys&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"api_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;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;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Laravel Scout search indexes&lt;/span&gt;
&lt;span class="c1"&gt;// Use tenant-prefixed index names or a filterable tenant_id attribute&lt;/span&gt;

&lt;span class="c1"&gt;// 4. spatie/laravel-permission cache&lt;/span&gt;
&lt;span class="c1"&gt;// Set a tenant-specific cache key in config/permission.php&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With spatie/laravel-multitenancy:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;PrefixCacheTask&lt;/code&gt; to your switch tenant tasks:&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/multitenancy.php&lt;/span&gt;
&lt;span class="s1"&gt;'switch_tenant_tasks'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;\Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;\Spatie\Multitenancy\Tasks\PrefixCacheTask&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Add this&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prefixes cache keys for memory-based stores like Redis and APC. The same edge cases apply: locks, rate limiters, and third-party package caches all need manual attention.&lt;/p&gt;

&lt;p&gt;One more thing. If you're running Laravel Octane in a multi-tenant setup, you have an additional risk. Octane reuses the same application instance across requests. A stale cache prefix from a previous request's tenant can bleed into the next request if the bootstrapper doesn't reset properly between requests.&lt;/p&gt;

&lt;p&gt;And cache isn't the only place where unprefixed keys cause cross-tenant leaks. Session cookies on wildcard domains (&lt;code&gt;*.yourapp.com&lt;/code&gt;) can let a session from one tenant's subdomain work on another tenant's subdomain. Validation rules like &lt;code&gt;Rule::unique('users', 'email')&lt;/code&gt; check the full table unless you scope them with a &lt;code&gt;where&lt;/code&gt; clause. Rate limiting keys based on IP addresses mean tenants behind the same corporate proxy share rate counters. These aren't queue-specific bugs, but they compound when a queued job reads from a session or applies a validation rule without tenant scoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: Failed Job Retries Run in the Wrong Tenant
&lt;/h2&gt;

&lt;p&gt;Your tenancy bootstrapper serializes the tenant ID into the job payload. Jobs dispatch and process correctly. You think you're covered. Then a job fails.&lt;/p&gt;

&lt;p&gt;This one is particularly nasty because it takes time to appear. Bugs 1 and 2 can show up on day one if you're paying attention. But Bug 3 only triggers when a job actually fails, and then only when someone retries it. Most SaaS apps don't have a robust retry workflow in their first few months. They're focused on getting things working, not recovering from failures. So this bug sits dormant, waiting for the first time an external API times out or a database connection hiccups.&lt;/p&gt;

&lt;p&gt;Laravel stores failed jobs in the &lt;code&gt;failed_jobs&lt;/code&gt; table. When you run &lt;code&gt;php artisan queue:retry 5&lt;/code&gt; three days later, the framework pulls the serialized payload, reconstructs the job, and dispatches it again. But here's the problem: the retry mechanism doesn't fire your tenancy bootstrapper the same way the original dispatch did.&lt;/p&gt;

&lt;p&gt;With spatie/laravel-multitenancy, this was &lt;a href="https://github.com/spatie/laravel-multitenancy/issues/259" rel="noopener noreferrer"&gt;a confirmed bug&lt;/a&gt; in earlier versions. The tenant ID was embedded in the payload, but the retry path didn't restore tenant context before the job started processing. The job would run in whatever tenant context the worker happened to have at that moment, which could be the central database or a completely different tenant.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;The fix depends on your package version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With stancl/tenancy v3.10:&lt;/strong&gt; The &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; handles retries correctly in recent versions. The tenant ID is stored at the top level of the job payload and restored on retry. Verify this by inspecting a failed job's payload:&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;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'failed_jobs'&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;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$failed&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// You should see a tenant_id key at the top level&lt;/span&gt;
&lt;span class="nf"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// Should not be null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;tenant_id&lt;/code&gt; is missing from the payload, your bootstrapper isn't configured correctly. Go back to Bug 1 and ensure &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; is in your bootstrappers array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With spatie/laravel-multitenancy v4.1:&lt;/strong&gt; The &lt;code&gt;MakeQueueTenantAwareAction&lt;/code&gt; in v4 handles this correctly. But if you're on an older version, you can listen for the retry event manually:&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;// In a service provider&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Events\JobRetryRequested&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobRetryRequested&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;]&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;makeCurrent&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;&lt;strong&gt;Testing retries properly:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part most teams skip. You can't test retries with &lt;code&gt;Queue::fake()&lt;/code&gt;. You need an integration test that uses a real queue driver, dispatches a job that deliberately fails, then retries it and verifies the tenant context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'retries failed jobs in the correct tenant context'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;makeCurrent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Dispatch a job that will fail on first attempt&lt;/span&gt;
    &lt;span class="nf"&gt;dispatch&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;FailOnceJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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="c1"&gt;// Process the queue (job fails)&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--once'&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="c1"&gt;// Retry the failed job&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:retry'&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="s1"&gt;'all'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Process again (should succeed in correct tenant context)&lt;/span&gt;
    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--once'&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="c1"&gt;// Assert the job ran in the correct tenant&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'job_results'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Audit Checklist
&lt;/h2&gt;

&lt;p&gt;If you're running a &lt;a href="https://hafiz.dev/blog/laravel-multi-tenancy-database-vs-subdomain-vs-path-routing-strategies" rel="noopener noreferrer"&gt;multi-tenant Laravel application&lt;/a&gt;, here's a quick audit you can run right now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant context in jobs:&lt;/strong&gt; Dispatch a job from a tenant context with the &lt;code&gt;database&lt;/code&gt; or &lt;code&gt;redis&lt;/code&gt; driver (not &lt;code&gt;sync&lt;/code&gt;). Check whether the job runs against the correct tenant's data. If you're passing Eloquent models to job constructors, switch to passing IDs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache isolation:&lt;/strong&gt; Open &lt;code&gt;tinker&lt;/code&gt; as Tenant A, run &lt;code&gt;Cache::put('test', 'tenant-a')&lt;/code&gt;. Switch to Tenant B, run &lt;code&gt;Cache::get('test')&lt;/code&gt;. If you get &lt;code&gt;tenant-a&lt;/code&gt; back, your cache isn't scoped. Enable the cache bootstrapper or prefix task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed job retries:&lt;/strong&gt; Deliberately fail a tenant job, wait a minute, run &lt;code&gt;queue:retry all&lt;/code&gt;. Check which tenant's database the retried job hit. If it's wrong, check your package version and bootstrapper configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue topology:&lt;/strong&gt; If one tenant dispatches a massive CSV export job, does it block jobs for every other tenant? Consider &lt;a href="https://hafiz.dev/blog/laravel-queue-route-centralize-queue-topology" rel="noopener noreferrer"&gt;dedicating separate queues&lt;/a&gt; for heavy operations so one tenant's workload doesn't starve the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker restart cadence:&lt;/strong&gt; Queue workers hold state in memory. If you deploy a tenancy config change but don't restart workers, the old configuration stays active. Always run &lt;code&gt;php artisan queue:restart&lt;/code&gt; after deploying changes to your tenancy bootstrappers or cache prefix configuration. In production, use a process manager like Supervisor that can gracefully restart workers on deploy.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need stancl/tenancy or spatie/laravel-multitenancy for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not strictly. You can build tenant-aware queues manually by storing the tenant ID in every job and restoring context in &lt;code&gt;handle()&lt;/code&gt;. But the packages automate the serialization, context restoration, and cache scoping. If you're already using one of them for your &lt;a href="https://hafiz.dev/blog/filament-v5-multi-tenancy-complete-implementation-guide" rel="noopener noreferrer"&gt;multi-tenancy implementation&lt;/a&gt;, enabling queue support is a few lines of config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the sync queue driver to avoid these bugs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Technically yes, because the sync driver processes jobs inline in the same request, so tenant context is always present. But the sync driver blocks the request until the job finishes, which defeats the purpose of using queues. These bugs only surface with async drivers (database, Redis, SQS), and that's exactly what you'll use in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Laravel Horizon help with tenant-scoped queues?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Horizon gives you visibility into what's happening on your queues, but it doesn't handle tenant context itself. You still need the tenancy package's queue bootstrapper for context restoration. That said, Horizon's queue balancing and monitoring are useful for spotting when one tenant's jobs dominate the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about database-per-tenant setups? Is the jobs table per tenant or central?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep the &lt;code&gt;jobs&lt;/code&gt; and &lt;code&gt;failed_jobs&lt;/code&gt; tables in the central (landlord) database. If you put them in tenant databases, the queue worker won't know which tenant database to connect to before it even picks up a job. Both stancl/tenancy and spatie/laravel-multitenancy recommend central job storage with tenant context serialized into the payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I run separate queue workers per tenant?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For most apps, no. A shared worker pool with tenant context in the payload works fine. Separate workers per tenant only makes sense if you have strict resource isolation requirements or tenants with wildly different job volumes. The complexity of managing dozens of worker processes usually isn't worth it until you're well past your first year.&lt;/p&gt;

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

&lt;p&gt;Multi-tenancy and queues are both solved problems individually. The bugs live at the intersection, in the gap between "tenant context exists during the HTTP request" and "tenant context needs to exist in a background worker too." All three bugs share the same root cause: queue workers are long-lived processes that don't get fresh context the way web requests do.&lt;/p&gt;

&lt;p&gt;The good news is that both stancl/tenancy (v3.10) and spatie/laravel-multitenancy (v4.1) have solid solutions for all three. But you have to enable them, test them with a real queue driver, and audit your cache keys. If you're building a multi-tenant SaaS on Laravel and want help getting the queue layer right, &lt;a href="mailto:contact@hafiz.dev"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>multitenancy</category>
      <category>queues</category>
      <category>saas</category>
    </item>
    <item>
      <title>Every Livewire Public Property Is a Form Field: The Security Audit Every Laravel App Needs</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:18:27 +0000</pubDate>
      <link>https://dev.to/hafiz619/every-livewire-public-property-is-a-form-field-the-security-audit-every-laravel-app-needs-145i</link>
      <guid>https://dev.to/hafiz619/every-livewire-public-property-is-a-form-field-the-security-audit-every-laravel-app-needs-145i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/livewire-public-properties-security-audit" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every public property in your Livewire component is sent to the browser. Every single one. The snapshot that Livewire uses to maintain state between requests includes every public property value in plain JSON. Your users can see them, modify them, and send them back to your server.&lt;/p&gt;

&lt;p&gt;Most Laravel developers don't think about this. They write &lt;code&gt;public $userId&lt;/code&gt; the same way they'd write a protected property on any other PHP class. The difference is that a regular PHP property lives only on the server. A Livewire public property lives on both sides, and the client side isn't yours.&lt;/p&gt;

&lt;p&gt;This post covers what can go wrong, what already went wrong in production for thousands of apps, and how to audit your own components in about 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the snapshot works
&lt;/h2&gt;

&lt;p&gt;When Livewire renders a component, it serializes all public properties into a JSON snapshot that gets embedded in the page. On every subsequent request (a button click, a form submission, a &lt;code&gt;wire:model&lt;/code&gt; update), the browser sends that snapshot back to the server. Livewire hydrates the component from the snapshot, applies the update, and sends a new snapshot back.&lt;/p&gt;

&lt;p&gt;The attack vector is simple: the snapshot travels through the browser, and the browser is controlled by the user.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/livewire-public-properties-security-audit" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anything in that snapshot can be changed before it comes back. IDs, status flags, prices, permissions, whatever your public properties hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concrete attack
&lt;/h2&gt;

&lt;p&gt;Here's a component that looks normal but is vulnerable:&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\Component&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;EditProfile&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="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$email&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;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&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;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&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;userId&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;'name'&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;name&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="nv"&gt;$this&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="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 problem: &lt;code&gt;$userId&lt;/code&gt; is public. A user loading their own profile page sees their own ID in the snapshot. They open DevTools, change the ID to another user's ID, and the next &lt;code&gt;save()&lt;/code&gt; call updates someone else's profile. No authentication bypass needed. No SQL injection. Just modifying a JSON value the server trusts implicitly.&lt;/p&gt;

&lt;p&gt;This pattern shows up constantly in real codebases. Any component where a public property determines &lt;em&gt;whose&lt;/em&gt; data gets read or written is vulnerable if the property isn't locked or the action doesn't re-authorize.&lt;/p&gt;

&lt;h2&gt;
  
  
  CVE-2025-54068: proof this matters
&lt;/h2&gt;

&lt;p&gt;In July 2025, a critical vulnerability (CVE-2025-54068) was disclosed in Livewire v3 versions 3.0.0-beta.1 through 3.6.3. The flaw was in the property hydration mechanism itself, allowing unauthenticated attackers to achieve remote code execution by crafting malicious property updates. No authentication required. No user interaction needed.&lt;/p&gt;

&lt;p&gt;The vulnerability was patched in v3.6.4. If you're running anything older, update immediately.&lt;/p&gt;

&lt;p&gt;The broader lesson from this CVE is that the property hydration pipeline is a real attack surface. The RCE was the most severe example, but garden-variety property manipulation (changing IDs, toggling flags, modifying amounts) is something any developer with browser DevTools can do right now against your components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to protect your components
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lock properties that shouldn't change
&lt;/h3&gt;

&lt;p&gt;Livewire v3 introduced the &lt;code&gt;#[Locked]&lt;/code&gt; attribute for exactly this problem. When a property is locked, any attempt to modify it from the client throws an exception.&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\Locked&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;Livewire\Component&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;EditProfile&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;#[Locked]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if someone modifies &lt;code&gt;$userId&lt;/code&gt; in the snapshot, Livewire rejects the request before your code even runs. This is the simplest fix for any property that gets set during &lt;code&gt;mount()&lt;/code&gt; and should never change after that.&lt;/p&gt;

&lt;p&gt;Don't forget the import: &lt;code&gt;use Livewire\Attributes\Locked;&lt;/code&gt;. Missing it is a silent failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use Eloquent models instead of IDs
&lt;/h3&gt;

&lt;p&gt;Livewire automatically protects Eloquent model IDs when you store the full model as a property:&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;EditProfile&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&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;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="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;'name'&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;name&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="nv"&gt;$this&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="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;Livewire ensures the model's ID can't be tampered with. No &lt;code&gt;#[Locked]&lt;/code&gt; needed. This is the recommended pattern for most components that operate on a single model. If you're storing &lt;code&gt;$postId&lt;/code&gt; or &lt;code&gt;$userId&lt;/code&gt; as a plain integer, ask yourself why you're not storing the model instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Authorize in every action method
&lt;/h3&gt;

&lt;p&gt;Even with locked properties, action parameters are still modifiable. The &lt;code&gt;wire:click="delete({{ $post-&amp;gt;id }})"&lt;/code&gt; in your Blade template sends the post ID as an argument, and that argument can be changed in the browser.&lt;/p&gt;

&lt;p&gt;Always authorize:&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;)&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;=&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;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&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="nb"&gt;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;Never trust that the value coming from the browser is the same value you rendered. Treat every Livewire action parameter exactly like you'd treat a POST request parameter. Validate and authorize every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-minute audit
&lt;/h2&gt;

&lt;p&gt;Run these searches against your Livewire components. Each one finds a potential vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find unlocked ID properties:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'public \$.*[Ii]d'&lt;/span&gt; app/Livewire/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'#\[Locked\]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any result that stores an ID without &lt;code&gt;#[Locked]&lt;/code&gt; or without being a full Eloquent model binding is a candidate for fixing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find action methods that trust their parameters:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'function delete\|function update\|function remove\|function approve'&lt;/span&gt; app/Livewire/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check each result: does the method verify that the authenticated user has permission to perform the action on the specific resource? If it goes straight from parameter to database query without authorization, it's vulnerable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find components that use &lt;code&gt;$this-&amp;gt;someId&lt;/code&gt; in queries without authorization:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'find(\$this-&amp;gt;'&lt;/span&gt; app/Livewire/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;find($this-&amp;gt;someId)&lt;/code&gt; should be followed by an authorization check, or the property should be &lt;code&gt;#[Locked]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once you've fixed the patterns, write &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest tests&lt;/a&gt; that attempt to tamper with locked properties and verify the exceptions fire. Automated tests catch regressions when someone removes a &lt;code&gt;#[Locked]&lt;/code&gt; attribute without realizing what it protects.&lt;/p&gt;

&lt;p&gt;For a broader security audit covering Composer dependencies and supply chain risks, the &lt;a href="https://hafiz.dev/blog/fake-laravel-packages-targeting-your-env-how-to-audit-composer-dependencies" rel="noopener noreferrer"&gt;Composer audit guide&lt;/a&gt; walks through the process of checking what you've actually installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Livewire does and doesn't protect
&lt;/h2&gt;

&lt;p&gt;A few things Livewire handles for you that are worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Middleware re-application.&lt;/strong&gt; If your Livewire component is loaded via a route with authorization middleware (like &lt;code&gt;can:update,post&lt;/code&gt;), Livewire re-applies that middleware on every subsequent request. So a user who loads the page but then loses permission will be blocked on the next interaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model property IDs.&lt;/strong&gt; As mentioned, storing a full Eloquent model as &lt;code&gt;public User $user&lt;/code&gt; protects the model ID automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checksum validation.&lt;/strong&gt; Livewire signs its snapshots with the application key. This prevents wholesale snapshot forgery. But it doesn't prevent modification of individual property values, the checksum covers the snapshot's structure, not the content of mutable properties.&lt;/p&gt;

&lt;p&gt;What Livewire does NOT protect: plain public property values (integers, strings, booleans), action method parameters, and any property you don't explicitly lock.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I lock every public property?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Properties bound to &lt;code&gt;wire:model&lt;/code&gt; need to be mutable. Lock the properties that get set in &lt;code&gt;mount()&lt;/code&gt; and should never change: IDs, user references, permission flags, anything that determines &lt;em&gt;whose&lt;/em&gt; data the component operates on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;#[Locked]&lt;/code&gt; work on Livewire 4?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The attribute exists in both Livewire v3 and v4. The import is &lt;code&gt;use Livewire\Attributes\Locked;&lt;/code&gt; in both versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use protected properties instead?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Protected properties don't persist between Livewire requests. They're fine for static values you set once and never need again, but any runtime data that must survive between user interactions has to be a public property. That's why &lt;code&gt;#[Locked]&lt;/code&gt; exists: it gives you the persistence of a public property with the safety of a protected one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this only a problem with Livewire, or does Inertia have the same issue?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inertia sends props to the frontend too, but Inertia props are read-only on the client. The client doesn't send them back on subsequent requests. Livewire's two-way sync is what creates the attack surface. Inertia forms use explicit POST requests with validated data, so the pattern is fundamentally different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My app is behind authentication. Am I still at risk?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The attack doesn't require being unauthenticated. A logged-in user can modify their own component's snapshot to access or modify another user's data. Authentication proves who someone is, authorization proves what they're allowed to do. You need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Every time you write &lt;code&gt;public $something&lt;/code&gt; in a Livewire component, ask yourself one question: what happens if the user changes this value? If the answer is "something bad," lock it with &lt;code&gt;#[Locked]&lt;/code&gt; or store the full Eloquent model instead.&lt;/p&gt;

&lt;p&gt;The 30-minute audit above catches the most common patterns. Run it once, fix what you find, and add &lt;code&gt;#[Locked]&lt;/code&gt; to your mental default for any property that determines data ownership.&lt;/p&gt;

&lt;p&gt;Security at the application layer and security at the server layer are different problems. For the infrastructure side, the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers closing ports, hiding your IP, and locking SSH. Both layers matter.&lt;/p&gt;

&lt;p&gt;Got a Livewire app you want audited? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>How I Refactored a 1,200-Line Filament Resource Into Something I Could Actually Maintain</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:20:19 +0000</pubDate>
      <link>https://dev.to/hafiz619/how-i-refactored-a-1200-line-filament-resource-into-something-i-could-actually-maintain-31ll</link>
      <guid>https://dev.to/hafiz619/how-i-refactored-a-1200-line-filament-resource-into-something-i-could-actually-maintain-31ll</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/refactoring-large-filament-resource" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You know the resource is in trouble when scrolling to &lt;code&gt;table()&lt;/code&gt; takes three full page-downs past the form definition. When every pull request touches the same file because everything lives in one place. When adding a column to the table means scrolling past 400 lines of form fields to find where the table starts.&lt;/p&gt;

&lt;p&gt;Filament resources grow faster than almost any other file in a Laravel project. The framework makes it easy to define forms, tables, actions, filters, and relations in one class, which is great for small models. But for anything complex, that single class becomes the most dangerous file in your codebase: too long to navigate, too coupled to test, too fragile to change.&lt;/p&gt;

&lt;p&gt;I hit this with a resource that managed a multi-step onboarding flow. The form had tabs, conditional fields, repeater components, and custom validation. The table had 15 columns, 6 filters, and 4 custom actions. The resource file was 1,247 lines. Every time I opened it, I spent more time scrolling than coding.&lt;/p&gt;

&lt;p&gt;This post walks through four levels of extraction I used to bring it back under control. Each level is independent. You can apply one, two, or all four depending on how large your resource is and how much refactoring time you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1: Split form and table into methods
&lt;/h2&gt;

&lt;p&gt;This takes five minutes and immediately makes the file navigable.&lt;/p&gt;

&lt;p&gt;Instead of defining everything inline in &lt;code&gt;form()&lt;/code&gt; and &lt;code&gt;table()&lt;/code&gt;, extract logical sections into static methods within the same class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Resource&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&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;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tabs&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;'Order'&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;tabs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&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;'Details'&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;detailsFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&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;'Shipping'&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;shippingFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                    &lt;span class="nc"&gt;Tab&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;'Payment'&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;paymentFormSchema&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;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;detailsFormSchema&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&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;'order_number'&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;disabled&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&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;'status'&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;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&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;'customer_id'&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;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&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;searchable&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;preload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="c1"&gt;// ... 15 more fields&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shippingFormSchema&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&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;'shipping_address'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&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;'shipping_city'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&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;'shipping_country'&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;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Country&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&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;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;paymentFormSchema&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="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;p&gt;Do the same for the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Table&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Table&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;$table&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableColumns&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;filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableFilters&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;actions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableActions&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;bulkActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tableBulkActions&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tableColumns&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;TextColumn&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;'order_number'&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;sortable&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;searchable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;TextColumn&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;'customer.name'&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;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;BadgeColumn&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;'status'&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;colors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="nc"&gt;TextColumn&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;'total'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;money&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;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&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;p&gt;This doesn't reduce line count, but it makes the file navigable. You can collapse methods in your IDE. The &lt;code&gt;form()&lt;/code&gt; method becomes 10 lines instead of 200. You can jump directly to &lt;code&gt;shippingFormSchema()&lt;/code&gt; when that's the section you need to edit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; Always. Even for small resources, this is good practice. It costs nothing and pays off immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2: Extract form sections into separate classes
&lt;/h2&gt;

&lt;p&gt;When a form section has 20+ fields or complex conditional logic, extract it into its own class. This is where the real line count reduction happens.&lt;/p&gt;

&lt;p&gt;Create a class that extends &lt;code&gt;Filament\Forms\Components\Section&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\Filament\Forms\Sections\Order&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;Filament\Forms\Components\Section&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;Filament\Forms\Components\TextInput&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;Filament\Forms\Components\Select&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;ShippingSection&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Section&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;make&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;$heading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Shipping'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&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="nv"&gt;$heading&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;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&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;'shipping_address'&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;required&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;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&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;'shipping_city'&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="nc"&gt;Select&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;'shipping_country'&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;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Country&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'code'&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;searchable&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&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;'shipping_postal_code'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;TextInput&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;'shipping_phone'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;columns&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;collapsible&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 resource references it with 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;App\Filament\Forms\Sections\Order\ShippingSection&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&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;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;Tabs&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;'Order'&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;tabs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&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;'Details'&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;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;detailsFormSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&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;'Shipping'&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;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="nc"&gt;ShippingSection&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="p"&gt;]),&lt;/span&gt;
            &lt;span class="nc"&gt;Tab&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;'Payment'&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;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="nc"&gt;PaymentSection&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="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;Each section class owns its fields, validation, and layout. The resource file drops from 1,200 lines to maybe 200. And if the same shipping form is needed in another resource (like a &lt;code&gt;CustomerResource&lt;/code&gt; or a &lt;code&gt;ReturnResource&lt;/code&gt;), you reuse the section class without duplicating a thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Directory structure I use:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/Filament/Forms/Sections/
    Order/
        ShippingSection.php
        PaymentSection.php
        DetailsSection.php
    Customer/
        PersonalInfoSection.php
        AddressSection.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When a form section has more than 15 fields, when the same fields appear in multiple resources, or when a section has complex conditional logic that makes the resource file hard to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 3: Extract table actions into Action classes
&lt;/h2&gt;

&lt;p&gt;Filament table actions defined inline add up fast. A single action with a confirmation modal, form fields, and business logic can be 30-50 lines. Four actions and you're at 200 lines just for the actions array.&lt;/p&gt;

&lt;p&gt;The solution: create dedicated Action classes. Here's a pattern adapted from Johannes Pichler's approach:&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\Filament\Actions\Order&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;Filament\Tables\Actions\Action&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;Filament\Notifications\Notification&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;MarkAsShippedAction&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;make&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Action&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Action&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;'mark-as-shipped'&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;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Mark Shipped'&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;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'heroicon-o-truck'&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;requiresConfirmation&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;action&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="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$record&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;'shipped'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderShippedNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

                &lt;span class="nc"&gt;Notification&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order marked as shipped'&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;success&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="p"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visible&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;$record&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;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'processing'&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;In the resource, the action registration becomes 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="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tableActions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;MarkAsShippedAction&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="nc"&gt;SendInvoiceAction&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="nc"&gt;RefundAction&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="nc"&gt;Tables\Actions\EditAction&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="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;Each action is testable independently. Each action can be reused across resources if needed. And the resource file doesn't grow every time you add a new action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When actions contain business logic beyond simple CRUD. If the action is just &lt;code&gt;EditAction::make()&lt;/code&gt; or &lt;code&gt;DeleteAction::make()&lt;/code&gt;, leave it inline. Extract when the action has custom logic, confirmations, form fields, or notifications.&lt;/p&gt;

&lt;p&gt;For a broader framework on when to extract logic into Actions versus Services, the &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;Service vs Action vs Job decision tree&lt;/a&gt; covers the general principles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 4: Use page-level overrides
&lt;/h2&gt;

&lt;p&gt;Filament resources delegate to page classes for Create, Edit, View, and List. Each page can override the form or table configuration from the resource. This is useful when your create and edit forms are significantly different.&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\Filament\Resources\OrderResource\Pages&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\Filament\Resources\OrderResource&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;Filament\Resources\Pages\CreateRecord&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;Filament\Forms\Form&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;CreateOrder&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;CreateRecord&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;$resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Form&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;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="c1"&gt;// Simplified create form: only essential fields&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&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;'customer_name'&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;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Select&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;'product_id'&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;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;TextInput&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;'quantity'&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;numeric&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="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 create form has 3 fields while the edit form (defined on the resource) has 40. Without this separation, you'd have conditional &lt;code&gt;-&amp;gt;hidden()&lt;/code&gt; calls everywhere, making the form definition harder to follow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; When create and edit forms differ significantly, when the list page needs a different table configuration than what the resource defines, or when a specific page has unique header actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How far to go
&lt;/h2&gt;

&lt;p&gt;Not every resource needs all four levels. Here's the simple version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under 200 lines:&lt;/strong&gt; Don't refactor. The resource is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;200-500 lines:&lt;/strong&gt; Level 1 (method extraction). Takes five minutes. Do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;500-1,000 lines:&lt;/strong&gt; Levels 1 + 2 (method extraction + section classes). This handles most cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over 1,000 lines:&lt;/strong&gt; All four levels. Your resource is complex enough to justify the structure. The time spent refactoring pays back within a week of not scrolling past 400 lines of form fields to find the table definition.&lt;/p&gt;

&lt;p&gt;The goal isn't to have the fewest lines in the resource. It's to make the resource navigable, testable, and safe to change. If you can open the file, find what you need in under 5 seconds, and change it without worrying about side effects, the refactoring worked.&lt;/p&gt;

&lt;p&gt;For the broader architecture of a Filament-based SaaS, the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;full SaaS guide&lt;/a&gt; covers the patterns beyond individual resources. And for the &lt;a href="https://hafiz.dev/blog/mastering-design-patterns-in-laravel" rel="noopener noreferrer"&gt;design patterns&lt;/a&gt; that underpin these extraction techniques, Strategy and Composition are the two that matter most here.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does extracting form sections affect Filament's reactivity (wire:model, conditional visibility)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Extracted sections are just PHP classes that return the same component arrays. Livewire's reactivity is based on the rendered component tree, not on which PHP class defined the components. &lt;code&gt;-&amp;gt;visible(fn (Get $get) =&amp;gt; $get('status') === 'active')&lt;/code&gt; works identically whether it's defined in the resource or in a section class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use Traits instead of separate classes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traits work for small extractions (a shared set of filters, a common set of bulk actions). For form sections and table configurations, separate classes are better because they're independently testable and don't pollute the resource's namespace. A resource with 6 traits is just as hard to navigate as a resource with 1,200 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the &lt;code&gt;rmitesh/filament-action&lt;/code&gt; package?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It provides an artisan command to generate Filament action classes: &lt;code&gt;php artisan make:filament-action CommentAction --resource=UserResource&lt;/code&gt;. If you're extracting many actions across multiple resources, it saves boilerplate. For a few extractions, the manual approach in Level 3 is lighter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test extracted section classes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instantiate the section, call the static &lt;code&gt;make()&lt;/code&gt; method, and assert the schema contains the expected components. You can also use Filament's testing utilities to render a full form with the section and assert field values. The key benefit: you test the section without loading the entire resource.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this approach with Filament v4/v5?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The section and action extraction patterns work across Filament v3, v4, and v5. The component API is stable. The only thing that changes between versions is import paths and some method names, which are easy to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After applying all four levels to that 1,247-line resource, the resource file itself dropped to 187 lines. The form is a list of section references. The table is a list of column and action references. Each section and action lives in its own file, testable and reusable.&lt;/p&gt;

&lt;p&gt;The total line count across all files is actually higher than the original single file. That's the right trade-off. Code organization isn't about fewer lines. It's about each file doing one thing, being easy to find, and being safe to change without side effects.&lt;/p&gt;

&lt;p&gt;Working with Filament on a project that's outgrowing its current structure? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>architecture</category>
      <category>php</category>
    </item>
    <item>
      <title>I Stopped Putting Everything in Service Classes. Here's the Decision Tree I Use Now</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:39:11 +0000</pubDate>
      <link>https://dev.to/hafiz619/i-stopped-putting-everything-in-service-classes-heres-the-decision-tree-i-use-now-40dl</link>
      <guid>https://dev.to/hafiz619/i-stopped-putting-everything-in-service-classes-heres-the-decision-tree-i-use-now-40dl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A few years ago I had a &lt;code&gt;UserService&lt;/code&gt; that had grown to 28 methods. It handled registration, email verification, password resets, subscription upgrades, profile updates, and account deletion. It was the most-imported class in the codebase and the most dangerous to touch. One change to the registration flow meant scrolling past 400 lines of code that had nothing to do with it.&lt;/p&gt;

&lt;p&gt;At some point I stopped and asked myself what a Service class actually is in Laravel. There's no &lt;code&gt;php artisan make:service&lt;/code&gt; command. No interface requirement. No convention in the framework documentation telling you what belongs there. We just started putting things in Services because someone told us to move logic out of controllers, and Services were the first pattern we learned.&lt;/p&gt;

&lt;p&gt;The result is predictable. The controller gets thin. The Service gets fat. The problem moved, it didn't get solved.&lt;/p&gt;

&lt;p&gt;This post is the decision tree I wish I'd had at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three patterns, briefly
&lt;/h2&gt;

&lt;p&gt;Before the tree, a quick grounding on what each pattern actually does, because the language is inconsistent in the community.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Service class&lt;/strong&gt; groups related operations on the same domain object. A &lt;code&gt;SubscriptionService&lt;/code&gt; knows how to subscribe a user, upgrade them, cancel them, and check their current plan. It holds methods, not just one. Think of it as the "everything about subscriptions" class. It can be stateful or stateless.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;Action class&lt;/strong&gt; handles a single, atomic operation. &lt;code&gt;CreateUser&lt;/code&gt;, &lt;code&gt;SendPasswordResetEmail&lt;/code&gt;, &lt;code&gt;ChargePaymentMethod&lt;/code&gt;. One class, one job, one public method. It can't be a Job because you need the result immediately. It can be reused from a controller, an Artisan command, another class, and a test. The key word is reuse across contexts.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Job&lt;/strong&gt; runs asynchronously on the queue. Full stop. That's the definition. If your code doesn't need to run in the background, it's not a Job, no matter how tempting it is to make it one. Jobs are for fire-and-forget work where you don't need the result before the HTTP response returns.&lt;/p&gt;

&lt;p&gt;There's some overlap. But the overlap is a signal that you need to choose, not that both options are equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision tree
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-service-action-job-decision-tree" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Walk through it with any piece of logic and you'll get an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong when you ignore the tree
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bloated Service.&lt;/strong&gt; You create a &lt;code&gt;UserService&lt;/code&gt; for user registration. Six months later someone adds password reset to it because, well, it's user-related. Then profile updates. Then account deletion. Then a method that checks if the user is eligible for a discount. Your Service is now a 600-line class with no coherent identity. Every test for registration pulls in the entire class including all the subscription logic you never wanted to touch.&lt;/p&gt;

&lt;p&gt;The fix: split by responsibility, not by model. &lt;code&gt;RegistrationService&lt;/code&gt; handles registration. &lt;code&gt;PasswordResetService&lt;/code&gt; handles password resets. Or, if those operations are simple and atomic, make them Actions instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Action folder explosion.&lt;/strong&gt; The opposite problem. You adopt the Action pattern and start creating an Action for every single thing. &lt;code&gt;GetUserByEmailAction&lt;/code&gt;. &lt;code&gt;FormatDateAction&lt;/code&gt;. &lt;code&gt;ValidatePostcodeAction&lt;/code&gt;. After 200 files, navigating &lt;code&gt;app/Actions&lt;/code&gt; is slower than just looking in the controller.&lt;/p&gt;

&lt;p&gt;Actions should answer a clear question: "Would I want to call this from a controller &lt;em&gt;and&lt;/em&gt; from an Artisan command &lt;em&gt;and&lt;/em&gt; from a queue job?" If the answer is no, keep it inline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jobs used as glorified Actions.&lt;/strong&gt; The most common mistake. You've got some logic that feels heavy, so you make it a Job. But you dispatch it synchronously with &lt;code&gt;dispatch()-&amp;gt;now()&lt;/code&gt;, or you realise you need the return value, so you jump through hoops. A Job that you always dispatch synchronously and need a result from is just an Action wearing the wrong costume. Make it an Action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real examples from production
&lt;/h2&gt;

&lt;p&gt;A few scenarios I've hit that show how the tree plays out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charging a subscription.&lt;/strong&gt; Is it async? No. You need the result to know whether to give the user access. Single operation? Yes. Used in multiple places (web checkout, API checkout, CLI seeder)? Yes. This is an Action: &lt;code&gt;ChargeSubscription::handle($user, $plan)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sending a weekly digest email to 50,000 users.&lt;/strong&gt; Async? Yes. Job. You chunk the users, dispatch one Job per batch, move on. The Job calls a Mailable. You don't need the result in the HTTP response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything related to how a subscription works.&lt;/strong&gt; Subscribe, cancel, check status, apply promo code, handle webhook from Stripe. These are multiple operations on the same domain concept, with shared logic (checking existing state before changing it). This is a Service: &lt;code&gt;SubscriptionService&lt;/code&gt;. Each method handles one transition, but they all need to know about each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sending a single transactional email.&lt;/strong&gt; Async? Yes, probably. But &lt;code&gt;Mail::queue()&lt;/code&gt; handles that. You don't need a Job class wrapping a Mailable. The Mailable itself handles queueing when sent via &lt;code&gt;Mail::queue()&lt;/code&gt;. This is one of those cases where the extra class adds ceremony without adding value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people argue about
&lt;/h2&gt;

&lt;p&gt;The honest version of this debate is that Service vs Action is often a team preference more than a technical requirement. Both work. The real risk isn't choosing the wrong one. It's applying one pattern to everything regardless of fit.&lt;/p&gt;

&lt;p&gt;I use Services when I'm working on a domain area with multiple operations that share state or context. I use Actions when I have a single operation I know I'll call from multiple places. I use neither when the logic is simple enough to live in the controller and I don't expect to reuse it.&lt;/p&gt;

&lt;p&gt;The rule I enforce on my own projects: if a class has more than five methods or more than 150 lines, it needs to be split or reconsidered. That ceiling forces the question "what does this class actually do?" before it becomes a dumping ground.&lt;/p&gt;

&lt;p&gt;For a deeper look at async patterns and how Jobs fit into a larger queue architecture, the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs guide&lt;/a&gt; covers sizing workers, retries, and the production setup worth knowing before you start dispatching at scale.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I always use an Action over a Service for new code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not always. Actions work best for atomic, stateless operations with a single entry point. If you're building a domain area (subscriptions, invoicing, notifications) where multiple operations share context, a Service is cleaner. The question to ask is: does this class do one thing, or does it know everything about a concept?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should Actions live in the directory structure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/Actions/&lt;/code&gt;. For larger projects, namespace further by domain: &lt;code&gt;app/Actions/Billing/ChargeSubscription.php&lt;/code&gt;. Keep the name as a verb-noun pair: &lt;code&gt;CreateUser&lt;/code&gt;, &lt;code&gt;SendPasswordReset&lt;/code&gt;. Avoid names like &lt;code&gt;UserAction&lt;/code&gt;. That's just a Service with a different suffix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the &lt;code&gt;lorisleiva/laravel-actions&lt;/code&gt; package?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's a solid package. It lets a single Action class run as a controller, a Job, a listener, and a command depending on context. Worth considering if your team commits to the pattern across the codebase. For testing Actions and Services, the &lt;a href="https://hafiz.dev/blog/laravel-pest-4-testing-complete-guide" rel="noopener noreferrer"&gt;Pest 4 testing guide&lt;/a&gt; covers the isolation patterns. It adds real value when you have Actions that need to run in multiple contexts. For simpler projects it's additional overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a Job call an Action?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, and this is often the right pattern. The Job handles the queue mechanics (retries, delays, backoff). The Action handles the actual logic. &lt;code&gt;ProcessSubscriptionRenewal::handle()&lt;/code&gt; dispatches work, calls &lt;code&gt;ChargeSubscription::handle()&lt;/code&gt;, and handles the queue-specific failure cases. Clean separation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Events and Listeners?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Events and Listeners are for decoupled side effects after something happens. The &lt;a href="https://hafiz.dev/blog/how-laravel-events-listeners-observers-actually-work" rel="noopener noreferrer"&gt;Events, Listeners, and Observers guide&lt;/a&gt; covers the patterns in depth. A user registered, fire &lt;code&gt;UserRegistered&lt;/code&gt;, let the listeners handle the welcome email, the onboarding sequence, the analytics event. Don't use Actions or Services for this. That's the Events system doing its job. The relationship is: Actions and Services cause things to happen, Events communicate that they happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;The pattern you choose matters less than applying it consistently and knowing when to break from it. Service classes aren't wrong. Actions aren't always better. Jobs aren't for synchronous logic dressed up with a queue.&lt;/p&gt;

&lt;p&gt;The moment a class starts doing too many things, it's a signal to reach for the tree.&lt;/p&gt;

&lt;p&gt;Got a codebase you're trying to untangle? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>php</category>
      <category>bestpractices</category>
    </item>
    <item>
      <title>Laravel Cloud vs Forge vs Hetzner: What I'd Actually Pick at Each Stage</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 01 Jun 2026 04:45:13 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-cloud-vs-forge-vs-hetzner-what-id-actually-pick-at-each-stage-12n</link>
      <guid>https://dev.to/hafiz619/laravel-cloud-vs-forge-vs-hetzner-what-id-actually-pick-at-each-stage-12n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-vs-forge-vs-vps-cost-comparison" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most developers make the infrastructure decision based on what they know, not what fits the project. They pick Laravel Cloud because it's new, or Forge because a senior dev on their team uses it, or a bare VPS because it looks cheap. None of those are reasons. They're starting points.&lt;/p&gt;

&lt;p&gt;The right choice at 500 users is often the wrong one at 50,000. And the cost difference between these options isn't what most people expect. The invoice number is only part of it.&lt;/p&gt;

&lt;p&gt;Here's what each option actually costs at three stages, using current May 2026 pricing. The goal isn't to declare a winner. It's to give you the real numbers so you can make the call that fits where you are right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're comparing
&lt;/h2&gt;

&lt;p&gt;A quick clarification before the numbers, because people mix these up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel Cloud&lt;/strong&gt; is a managed PaaS built by the Laravel team. You push your code and it handles servers, autoscaling, databases, SSL, queue workers, and deployments. You never SSH anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel Forge&lt;/strong&gt; is a server management layer. You still own the VPS and pay for it separately. Forge handles provisioning, Nginx config, deployments, SSL, and queue workers. You keep server-level control without doing the tedious parts manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bare Hetzner VPS&lt;/strong&gt; is just a server. You install PHP, configure Nginx, manage SSL renewals, set up queue workers, write deploy scripts. Everything is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers at each stage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Under 1,000 users
&lt;/h3&gt;

&lt;p&gt;Side projects, early MVPs, apps you're not sure will stick.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Starter&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$4-8/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Hobby + Hetzner CX22&lt;/td&gt;
&lt;td&gt;$12 + €4.49&lt;/td&gt;
&lt;td&gt;~$17/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX22&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;€4.49 (~$5/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this stage the differences are real but small in absolute terms. Cloud's Starter tier is pay-as-you-go with no base fee. A small app with modest traffic runs $4-8/month. The Forge Hobby + Hetzner CX22 combo costs more upfront but gives you direct server access. A bare Hetzner CX22 at €4.49/month after the April 2026 price increase is still excellent value for 2 vCPUs and 4GB RAM.&lt;/p&gt;

&lt;p&gt;Worth noting: Hetzner CX22 is roughly 3x cheaper than a comparable DigitalOcean droplet for equivalent specs. If you're choosing the VPS path, Hetzner is the obvious pick in Europe and increasingly popular for US projects too.&lt;/p&gt;

&lt;h3&gt;
  
  
  1,000 to 10,000 users
&lt;/h3&gt;

&lt;p&gt;You've found traction. Deployment and uptime start mattering.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly base&lt;/th&gt;
&lt;th&gt;Typical all-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$25-45/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Growth + Hetzner CX32&lt;/td&gt;
&lt;td&gt;$19 + ~€9&lt;/td&gt;
&lt;td&gt;~$30/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare Hetzner CX32&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;~€9 (~$10/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloud Growth at $20/month plus usage lands around $25-45/month depending on traffic patterns. Forge Growth at $19/month plus a mid-range Hetzner server is competitive at roughly $30/month total. The bare VPS is still cheapest in cash at $10/month, but the operational work starts adding up.&lt;/p&gt;

&lt;p&gt;For a developer shipping features, the real question isn't "which is cheapest," it's this: how many hours a month am I spending on server maintenance versus building product? At 5,000 users, a misconfigured Nginx config or a failed deployment script starts costing you real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  10,000 to 100,000 users
&lt;/h3&gt;

&lt;p&gt;Infrastructure decisions affect your margins and your incident response.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Typical monthly cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel Cloud Growth&lt;/td&gt;
&lt;td&gt;$60-200+&lt;/td&gt;
&lt;td&gt;Usage scales with traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forge Business + multiple Hetzner&lt;/td&gt;
&lt;td&gt;$39 + $40-80&lt;/td&gt;
&lt;td&gt;More DevOps work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare VPS (load balanced)&lt;/td&gt;
&lt;td&gt;$25-60&lt;/td&gt;
&lt;td&gt;Cheapest, most complex&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this scale, Cloud's autoscaling is the right call if your traffic is unpredictable. Traffic spikes that would take down a single Hetzner server get absorbed automatically. But that scaling costs money. Usage-based billing at this volume can push your monthly bill well above the base fee. Spend caps are announced for Cloud but not yet live as of this writing. The &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;Laravel Cloud post&lt;/a&gt; covers what's coming on that front.&lt;/p&gt;

&lt;p&gt;Forge at this scale means managing multiple servers, a load balancer, and probably a separate Redis instance. The Business plan at $39/month includes monitoring and automated database backups, which you need at 100k users. The Hetzner bill grows with your server count. Total lands at $80-120/month depending on architecture.&lt;/p&gt;

&lt;p&gt;A bare VPS setup at this scale requires real infrastructure work, load balancers, Redis clusters, backup scripts, monitoring. It's the cheapest option but it's a part-time job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't show up on the invoice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your time.&lt;/strong&gt; A bare VPS is cheap in cash but expensive in hours. Initial setup for someone who knows what they're doing is 3-5 hours. Ongoing maintenance is roughly 2 hours a month: security patches, PHP updates, debugging a failed deployment on a Friday evening. Multiply your hourly rate by that and the VPS isn't cheap anymore.&lt;/p&gt;

&lt;p&gt;Forge cuts ongoing maintenance to near zero and initial setup to under an hour. Cloud cuts it entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployment pipeline.&lt;/strong&gt; Forge gives you zero-downtime deployments out of the box. On a bare VPS you configure that yourself. It's learnable. The &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;CI/CD with GitHub Actions guide&lt;/a&gt; covers the setup, but it takes time you could spend shipping. Cloud handles it without any configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoscaling anxiety vs bill anxiety.&lt;/strong&gt; These are the two risks you're trading off. On a bare VPS or Forge, a traffic spike can take you down. On Cloud, a traffic spike drives up your bill. Neither is "safe," they're just different failure modes. Cloud's spend caps will help when they ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually pick at each stage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Side project or early MVP:&lt;/strong&gt; Bare Hetzner CX22 at €4.49/month. It's cheap enough to be disposable and the constraint forces you to understand what you actually need. When server maintenance starts interrupting feature work, that's the signal to move up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solo developer with a growing SaaS:&lt;/strong&gt; Forge Growth + Hetzner CX32 at roughly $30/month total. You get the deployment automation, SSL, queue workers, and server monitoring without giving up control. It's where the best price-to-control ratio lives. Pair it with the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; to get the security layer sorted once and forget about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small team building a product:&lt;/strong&gt; Laravel Cloud Growth. When multiple developers are deploying, having one person own the servers creates a bottleneck. Cloud removes that entirely. Preview environments per PR are a real productivity win at this stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agency managing client sites:&lt;/strong&gt; Forge Business at $39/month. Unlimited servers, automated database backups, and server monitoring across all client projects. The per-client cost when split across 10 sites is negligible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App with unpredictable traffic:&lt;/strong&gt; Laravel Cloud. Product Hunt launches, viral moments, seasonal spikes. If your traffic can 10x overnight, you want autoscaling and you don't want to be the one managing it at midnight. The &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;full SaaS architecture guide&lt;/a&gt; covers how to structure the app layer to take full advantage of Cloud's scaling.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I switch from Forge or a bare VPS to Laravel Cloud later?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. It's a deployment change, not a code change. Your Laravel app runs identically on both. The migration is mainly about moving your database and updating your deployment pipeline. Most teams do it in a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the real total cost of Forge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forge Growth is $19/month plus your VPS. A Hetzner CX22 at €4.49/month brings the total to roughly $24-25/month. That's competitive with Cloud's Starter tier but with unlimited servers and flat-rate billing, so no usage surprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Laravel Cloud handle queue workers and cron jobs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Queue workers and cron jobs are first-class features on Growth and above. It's one of the things that makes Cloud well-suited for SaaS, where background processing is usually essential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Laravel Vapor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vapor runs on AWS Lambda at $39/month plus AWS usage. It's the right choice for workloads with extreme traffic variability or teams deep in the AWS ecosystem. For most Laravel developers, Cloud or Forge fits better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the $5/month plan for Laravel Cloud available yet?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not as of this writing. The Laravel team announced it as coming soon in May 2026. The current entry point is the Starter tier with no base fee and pay-as-you-go usage, which runs $4-8/month for a small app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest version
&lt;/h2&gt;

&lt;p&gt;The developers who regret their infrastructure choice usually overbuilt early or held on to a cheap setup for too long. A €4.49 Hetzner server handles your first 10,000 users fine if it's set up correctly. Cloud makes sense when your traffic becomes unpredictable or when server maintenance is competing with feature work. Forge sits in the middle and is the right answer for more situations than it gets credit for.&lt;/p&gt;

&lt;p&gt;Start with the cheapest option that doesn't slow you down. Upgrade when the friction becomes real, not before.&lt;/p&gt;

&lt;p&gt;Got a side project or SaaS and want a second opinion on the infrastructure setup? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>devops</category>
      <category>laravelcloud</category>
    </item>
    <item>
      <title>Laravel AI SDK Silently Kills Your Horizon Queue (And How to Fix It in 4 Config Changes)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 27 May 2026 09:20:13 +0000</pubDate>
      <link>https://dev.to/hafiz619/laravel-ai-sdk-silently-kills-your-horizon-queue-and-how-to-fix-it-in-4-config-changes-2lcf</link>
      <guid>https://dev.to/hafiz619/laravel-ai-sdk-silently-kills-your-horizon-queue-and-how-to-fix-it-in-4-config-changes-2lcf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your Horizon was healthy. Email jobs, notifications, small processing tasks, all running fine with zero issues. You added the AI SDK, wrapped a few agent calls in background jobs, and deployed to production. Within a week you noticed failed jobs nobody reported, one user received the same AI analysis twice, and on a Tuesday morning when five report jobs queued simultaneously, your email queue froze for eight minutes.&lt;/p&gt;

&lt;p&gt;Nothing threw an exception. No alerting fired. Horizon's dashboard showed a handful of failed jobs and then everything looked normal again. The failure mode is invisible because the worker doesn't crash. It gets killed mid-execution, Horizon records the failure silently, and there's no stack trace and no message, nothing to debug.&lt;/p&gt;

&lt;p&gt;The problem isn't the AI SDK. It's that Horizon's default configuration was designed for jobs measured in milliseconds. AI API calls take 30 to 120 seconds. Four specific default values in your config silently conflict with that reality, and three of them aren't in &lt;code&gt;config/horizon.php&lt;/code&gt;. They're scattered across your codebase in ways you'd only find if you knew to look.&lt;/p&gt;

&lt;p&gt;If you're new to Laravel's queue system and want to understand the fundamentals before tuning Horizon specifically, the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt; covers job structure, retries, and failure handling from the ground up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI jobs break Horizon's defaults
&lt;/h2&gt;

&lt;p&gt;Horizon's default supervisor configuration assumes jobs are fast. A worker polls the queue, picks up a job, executes it in a second or two, picks up the next one. The defaults reflect this: a 60-second timeout before the worker is killed, a &lt;code&gt;retry_after&lt;/code&gt; value in the 90-second range before a job is considered stuck, and no backoff delay between retries.&lt;/p&gt;

&lt;p&gt;AI SDK jobs break every one of these assumptions. A single &lt;code&gt;Agent::run()&lt;/code&gt; call that uses tools, generates structured output, or hands off to a sub-agent can legitimately take 45 to 90 seconds before it returns. When you layer in the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agent patterns&lt;/a&gt; that chain multiple providers, job duration climbs further.&lt;/p&gt;

&lt;p&gt;Here's what actually happens when that 90-second AI job runs on a default Horizon setup:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The SIGKILL at step four is silent. Horizon marks the job as failed with no visible error in the dashboard UI beyond the failure count incrementing. There's no stack trace, no message from the AI provider, no PHP exception in your logs. Most developers spend hours looking for an application error that doesn't exist, because the job never threw one.&lt;/p&gt;

&lt;p&gt;The double-processing at step seven is the worst part. Laravel's official Horizon documentation warns about it explicitly: "the timeout value should always be at least a few seconds shorter than the &lt;code&gt;retry_after&lt;/code&gt; value... otherwise, your jobs may be processed twice." But almost nobody reads that warning until after a user reports seeing duplicate results.&lt;/p&gt;

&lt;p&gt;Four changes fix all of this cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 1: Set a supervisor timeout that reflects AI job duration
&lt;/h2&gt;

&lt;p&gt;Horizon's default supervisor timeout is 60 seconds. That's the value in &lt;code&gt;config/horizon.php&lt;/code&gt; under each supervisor's configuration. When a job runs longer than this, the worker process receives SIGKILL and is forcefully terminated.&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/horizon.php: what ships by default&lt;/span&gt;
&lt;span class="s1"&gt;'supervisor-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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&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;'default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// kills any job taking longer than 60 seconds&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For AI jobs, raise this to something that reflects the realistic upper bound of your slowest agent call. 300 seconds (five minutes) is a sensible ceiling for most production AI workloads:&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;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&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;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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 Horizon docs note a critical constraint: "always ensure the Horizon timeout is greater than any job-level timeout, otherwise jobs may be terminated mid-execution." So if you define &lt;code&gt;$timeout&lt;/code&gt; on the job class itself, the supervisor timeout must exceed it by a few seconds.&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;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Job-level timeout: must be &amp;lt; supervisor timeout&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&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;Setting the job-level timeout slightly below the supervisor timeout means clean termination happens at the job level first, which triggers the &lt;code&gt;failed()&lt;/code&gt; method and gives you a cleanup hook. A SIGKILL from the supervisor sends a process signal that skips &lt;code&gt;failed()&lt;/code&gt; entirely, leaving any in-progress state (partially written database rows, uncleaned temp files, open API sessions) without cleanup. For AI jobs that write intermediate results or update progress columns, this distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 2: Fix retry_after to prevent double processing
&lt;/h2&gt;

&lt;p&gt;This is the one that causes duplicate user output, and it lives in &lt;code&gt;config/queue.php&lt;/code&gt;, not in &lt;code&gt;config/horizon.php&lt;/code&gt;. That's why most developers miss it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;retry_after&lt;/code&gt; value defines how many seconds Redis waits before assuming a job is stuck and re-queuing it. The default for the Redis connection is 90 seconds. After you raise your supervisor timeout to 300, that 90-second &lt;code&gt;retry_after&lt;/code&gt; means any job running longer than 90 seconds gets re-queued while the original is still executing.&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/queue.php: the default Redis connection&lt;/span&gt;
&lt;span class="s1"&gt;'redis'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_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="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// danger: shorter than the AI supervisor timeout&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's docs say this directly: timeout must be shorter than &lt;code&gt;retry_after&lt;/code&gt;. So if your supervisor timeout is 300 seconds, &lt;code&gt;retry_after&lt;/code&gt; needs to be at least 300 plus a buffer. Add 60 seconds as the minimum buffer:&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;'redis'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_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="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// supervisor timeout (300) + 60s buffer&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the change most likely to already be causing silent problems in your app. The double-processing scenario has a specific pattern: a user triggers an AI analysis, gets the result, then gets the same result again 90 seconds later with no explanation. If you've had users report duplicate outputs and assumed it was a UI bug or a double-click, it may have been this. The job ran, got killed, re-queued, and ran again from the beginning.&lt;/p&gt;

&lt;p&gt;If you have separate Redis connections for different queues, each connection needs its own &lt;code&gt;retry_after&lt;/code&gt; value matched to the highest supervisor timeout that uses it. A dedicated &lt;code&gt;redis-ai&lt;/code&gt; connection with its own &lt;code&gt;retry_after&lt;/code&gt; of 360 is cleaner than raising the value for every queue across the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 3: Add exponential backoff for rate limit failures
&lt;/h2&gt;

&lt;p&gt;AI API providers rate-limit aggressively. OpenAI, Anthropic, and Gemini all have per-minute token limits. When a job hits a rate limit, it throws an exception and fails. Without backoff configuration, Horizon retries immediately. Under sustained load, a burst of AI jobs exhausting the rate limit causes every retry to also hit the rate limit, causing another retry, in a tight loop that burns through your API budget and fills the failed jobs list.&lt;/p&gt;

&lt;p&gt;The fix is exponential backoff, which Horizon supports at both the supervisor level and the job class level.&lt;/p&gt;

&lt;p&gt;At the supervisor level:&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;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&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;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'backoff'&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;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// wait 30s, then 60s, then 120s between retries&lt;/span&gt;
    &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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;Or on the job class directly, which takes precedence over supervisor config:&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;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&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;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Exponential backoff: 30s after first failure, 60s after second&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&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 job class approach is more explicit and survives config changes without you needing to remember to update the supervisor. It's the pattern to prefer when different AI jobs have different retry requirements. A cheap text classification job can retry faster than an expensive multi-tool agent call.&lt;/p&gt;

&lt;p&gt;One thing worth noting: &lt;code&gt;tries&lt;/code&gt; counts total attempts, not retries. A job with &lt;code&gt;$tries = 3&lt;/code&gt; gets three chances total: the original execution plus two retries. Set it low enough that an actually broken job doesn't exhaust your API quota before hitting the failed jobs list. Three is usually the right number for AI jobs with rate limit risks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 4: Move AI jobs to a dedicated queue and supervisor
&lt;/h2&gt;

&lt;p&gt;Even with the timeout and retry_after fixed, AI jobs and fast jobs sharing the same supervisor still block each other. Here's why: if you have three workers in &lt;code&gt;supervisor-1&lt;/code&gt; and three 90-second AI jobs land simultaneously, all three workers are occupied for 90 seconds. Email jobs, notifications, and payment webhooks sit queued and waiting until one of those workers finishes.&lt;/p&gt;

&lt;p&gt;The solution is a dedicated queue for AI work and a supervisor that only handles it, completely isolated from your default queue.&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;// In your job class&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ai'&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;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&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;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&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;Or when dispatching:&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;RunAiReportJob&lt;/span&gt;&lt;span class="o"&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;$report&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;onQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;config/horizon.php&lt;/code&gt;, run two supervisors with different constraints:&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;'environments'&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;'production'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// Fast jobs: tight timeout, many workers&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&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;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;

        &lt;span class="c1"&gt;// AI jobs: long timeout, fewer workers, more memory&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&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;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&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;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&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;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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 result:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-horizon-queue-config" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three workers for AI is a starting point. The right number depends on your API rate limits and job volume. Three concurrent AI jobs consuming tokens simultaneously can hit per-minute limits quickly, so scaling the worker count upward requires monitoring your provider's rate limit headroom first.&lt;/p&gt;

&lt;p&gt;You might also want to set &lt;code&gt;balance&lt;/code&gt; to &lt;code&gt;simple&lt;/code&gt; for the AI supervisor rather than &lt;code&gt;auto&lt;/code&gt;. The &lt;code&gt;auto&lt;/code&gt; strategy dynamically scales workers based on queue depth, but it has a side effect: Horizon considers workers "hanging" when scaling down and will force-kill them after the supervisor timeout if they're mid-execution. For long-running AI jobs, &lt;code&gt;simple&lt;/code&gt; balance with a fixed process count is more predictable. You control the concurrency explicitly, and Horizon doesn't try to adjust it based on queue depth in a way that could interrupt active jobs.&lt;/p&gt;

&lt;p&gt;For a deeper look at structuring queue topology across multiple job types, the &lt;a href="https://hafiz.dev/blog/laravel-queue-route-centralize-queue-topology" rel="noopener noreferrer"&gt;Laravel queue route guide&lt;/a&gt; covers centralising queue assignments cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete production config
&lt;/h2&gt;

&lt;p&gt;Here's everything together as a reference:&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/queue.php: raise retry_after to cover the longest AI job&lt;/span&gt;
&lt;span class="s1"&gt;'redis'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'REDIS_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="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// supervisor timeout (300) + 60s buffer&lt;/span&gt;
    &lt;span class="s1"&gt;'block_for'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/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/horizon.php: two supervisors, two concerns&lt;/span&gt;
&lt;span class="s1"&gt;'environments'&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;'production'&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;'supervisor-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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&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;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-ai'&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;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&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;'ai'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backoff'&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;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&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;'memory'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;512&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// App\Jobs\RunAiReportJob.php: job-level timeouts and backoff&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunAiReportJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;$queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ai'&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;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// slightly below supervisor timeout&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;array&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Your AI SDK call here&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;failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$exception&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;// Log, notify, or clean up on final failure&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;After deploying, run &lt;code&gt;php artisan horizon:terminate&lt;/code&gt; to restart Horizon and pick up the new config. The &lt;code&gt;queue:restart&lt;/code&gt; signal alone doesn't reload Horizon's supervisor configuration.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does Horizon kill jobs at 60 seconds when I never set a timeout?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Horizon inherits the timeout from the queue worker defaults. If you don't explicitly set &lt;code&gt;timeout&lt;/code&gt; in the supervisor config, it falls back to the worker default of 60 seconds. This is documented but easy to miss, because the installed &lt;code&gt;config/horizon.php&lt;/code&gt; doesn't always include &lt;code&gt;timeout&lt;/code&gt; in every supervisor block. Absence isn't zero, it's the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I just set a very high timeout everywhere instead of creating separate supervisors?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can, but it's the wrong call. A 300-second timeout on your email supervisor means a stuck email job (due to a mail server timeout, for example) occupies a worker for five minutes instead of one. Separate supervisors let you apply constraints appropriate to each job type. The email jobs don't need to know about AI job timeouts, and vice versa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the AI SDK dispatch jobs automatically, or do I need to wrap calls manually?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You wrap them manually. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt; runs synchronously by default. Dispatching an agent call as a background job is an explicit architectural choice you make by wrapping the &lt;code&gt;Agent::run()&lt;/code&gt; call in a &lt;code&gt;ShouldQueue&lt;/code&gt; class. The SDK doesn't push to queues automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to an AI job that exceeds &lt;code&gt;$timeout&lt;/code&gt; on the job class?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;$timeout&lt;/code&gt; is set on the job class and the supervisor timeout is higher, the job gets a &lt;code&gt;TimeoutExceededException&lt;/code&gt;, which triggers the &lt;code&gt;failed()&lt;/code&gt; method cleanly. This is the preferred failure mode: you get the cleanup hook, the exception is logged, and tries are decremented normally. A SIGKILL from the supervisor timeout skips all of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I verify the timeout and retry_after relationship is correct after deploying?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run a test job that deliberately sleeps for a duration between your supervisor timeout and retry_after, then watch Horizon's dashboard. If the job appears twice in Recent Jobs, your &lt;code&gt;retry_after&lt;/code&gt; is still too low. Also check the Redis key directly: &lt;code&gt;redis-cli LRANGE queues:ai 0 -1&lt;/code&gt; will show you if a job is sitting in both an executing state and the queue simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing to remember
&lt;/h2&gt;

&lt;p&gt;Most of the pain from adding AI SDK to an existing Laravel app isn't in the code. It's in configuration assumptions that were correct before and quietly broke afterward. None of these four changes are complex. They're just easy to not know about until something goes wrong in production.&lt;/p&gt;

&lt;p&gt;The timeout and retry_after relationship is the most dangerous of the four. It's in the official docs, it's a known issue with a clear warning, and it still catches experienced developers because the two values live in different config files with no cross-reference in the UI. You'd have to know to check one when you change the other.&lt;/p&gt;

&lt;p&gt;The queue isolation change has the biggest ongoing benefit. Once your AI jobs run in their own supervisor, you can tune that supervisor independently. You can scale the worker count up during peak AI usage without affecting the process count for your other queues. You can set different memory limits, different balancing strategies, and different alerting thresholds. Fast jobs and slow jobs just have different operational needs, and the config reflects that.&lt;/p&gt;

&lt;p&gt;If you're running AI workloads on Horizon right now without dedicated supervisors, go check your &lt;code&gt;retry_after&lt;/code&gt; value before anything else. If it's under 300, you may already be double-processing jobs and not seeing it in your logs.&lt;/p&gt;

&lt;p&gt;Building something with the AI SDK and want a review of the queue architecture before it hits production? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravelhorizon</category>
      <category>queues</category>
      <category>laravelaisdk</category>
    </item>
    <item>
      <title>6 Eloquent Patterns That Silently Break MySQL Index Usage (And How to Fix Each One)</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 25 May 2026 05:21:18 +0000</pubDate>
      <link>https://dev.to/hafiz619/6-eloquent-patterns-that-silently-break-mysql-index-usage-and-how-to-fix-each-one-3b9p</link>
      <guid>https://dev.to/hafiz619/6-eloquent-patterns-that-silently-break-mysql-index-usage-and-how-to-fix-each-one-3b9p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You added the index. You confirmed it's there with &lt;code&gt;SHOW INDEX FROM orders&lt;/code&gt;. The query is still timing out in production, against the same &lt;code&gt;created_at&lt;/code&gt; column you've had indexed for months.&lt;/p&gt;

&lt;p&gt;This happens more than it should. MySQL's B-tree index is sitting right next to your query, completely ignored, and Eloquent handed it a condition the optimizer can't act on. No error. No warning. No hint in the logs. Just a full table scan against 4 million rows while your dashboard waits.&lt;/p&gt;

&lt;p&gt;The reason almost always traces to one concept: sargability.&lt;/p&gt;

&lt;p&gt;A predicate is sargable (Search ARGument ABLE) when MySQL can use an index to resolve it directly. The moment a function wraps the indexed column, sargability breaks. MySQL has to compute that function for every single row, then filter the results. That means reading the entire table regardless of what indexes exist.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/eloquent-patterns-silently-break-mysql-index" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Six Eloquent patterns trigger this silently. You'll recognize at least two of them in your current codebase.&lt;/p&gt;

&lt;p&gt;Before going through them, set up &lt;code&gt;EXPLAIN&lt;/code&gt; as your verification tool. Wrap any suspicious query like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nf"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toSql&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPLAIN '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toSql&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBindings&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the EXPLAIN output, the fields that matter most are &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;rows&lt;/code&gt;. A &lt;code&gt;type: ALL&lt;/code&gt; result means a full table scan. A &lt;code&gt;type: range&lt;/code&gt; or &lt;code&gt;type: ref&lt;/code&gt; means an index is in use. The &lt;code&gt;rows&lt;/code&gt; column tells you how many rows MySQL examined. On a table with 4 million rows, &lt;code&gt;rows: 4000000&lt;/code&gt; next to &lt;code&gt;type: ALL&lt;/code&gt; is the confirmation you're looking for.&lt;/p&gt;

&lt;p&gt;Two more fields worth checking: &lt;code&gt;key&lt;/code&gt; shows which index MySQL actually used (it'll be &lt;code&gt;NULL&lt;/code&gt; on a full scan), and &lt;code&gt;Extra&lt;/code&gt; shows &lt;code&gt;Using filesort&lt;/code&gt; or &lt;code&gt;Using temporary&lt;/code&gt; when MySQL had to do extra work outside the index. Any of those three signals in a single EXPLAIN row means you have a query worth fixing.&lt;/p&gt;

&lt;p&gt;Laravel Telescope is the easiest way to surface slow query candidates without configuring MySQL's slow query log. It logs every query with execution time. Sort by duration to find the biggest problems first.&lt;/p&gt;

&lt;p&gt;If you want to understand how to build the right indexes in the first place, the &lt;a href="https://hafiz.dev/blog/database-indexing-in-laravel-boost-mysql-performance-with-smart-indexes" rel="noopener noreferrer"&gt;Laravel database indexing guide&lt;/a&gt; covers composite indexes, covering indexes, and when index cardinality matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: whereDate() and its siblings
&lt;/h2&gt;

&lt;p&gt;This is the most documented one, and still the least-fixed. The &lt;code&gt;whereDate()&lt;/code&gt; method generates &lt;code&gt;DATE(created_at) = '2026-05-22'&lt;/code&gt;. That &lt;code&gt;DATE()&lt;/code&gt; wraps your column, sargability is gone, and the index on &lt;code&gt;created_at&lt;/code&gt; does nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bypasses index on created_at&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;today&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;// What MySQL actually runs:&lt;/span&gt;
&lt;span class="c1"&gt;// WHERE DATE(created_at) = '2026-05-22'&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 4,893,201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same is true for &lt;code&gt;whereMonth()&lt;/code&gt;, &lt;code&gt;whereYear()&lt;/code&gt;, &lt;code&gt;whereDay()&lt;/code&gt;, and &lt;code&gt;whereTime()&lt;/code&gt;. Every one of them wraps the column in a MySQL function before comparing.&lt;/p&gt;

&lt;p&gt;A common place this compounds: reporting queries that filter by year and month separately.&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;// Two function wraps, both non-sargable&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&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;whereMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="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;// MySQL runs: WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 4,893,201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both conditions examine every row. The fix is still a range:&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;// Uses index on created_at&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;today&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;startOfDay&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;today&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;endOfDay&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// What MySQL actually runs:&lt;/span&gt;
&lt;span class="c1"&gt;// WHERE created_at &amp;gt;= '2026-05-22 00:00:00' AND created_at &amp;lt;= '2026-05-22 23:59:59'&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: range, rows: 183&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're ranging across multiple days, prefer &lt;code&gt;tomorrow()-&amp;gt;startOfDay()&lt;/code&gt; as the exclusive upper bound instead of &lt;code&gt;endOfDay()&lt;/code&gt;. The &lt;code&gt;endOfDay()&lt;/code&gt; helper returns &lt;code&gt;23:59:59&lt;/code&gt;, which can miss rows with microsecond timestamps right at midnight.&lt;/p&gt;

&lt;p&gt;One note about PostgreSQL: it supports functional indexes, so you can create an index directly on &lt;code&gt;DATE(created_at)&lt;/code&gt; and &lt;code&gt;whereDate()&lt;/code&gt; will use it. MySQL has no real equivalent. For MySQL apps, the range approach is the correct fix regardless of database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: LIKE with a leading wildcard
&lt;/h2&gt;

&lt;p&gt;Every developer knows &lt;code&gt;LIKE '%value%'&lt;/code&gt; is slow. What's less obvious is exactly why.&lt;/p&gt;

&lt;p&gt;MySQL's B-tree index stores values in lexicographical order, like a phone book. A leading &lt;code&gt;%&lt;/code&gt; tells MySQL "I don't know how this string starts," so the index is useless. MySQL has to read every row and apply the pattern match manually, front to back.&lt;/p&gt;

&lt;p&gt;A trailing wildcard without a leading one is fine: &lt;code&gt;LIKE 'value%'&lt;/code&gt; can use a B-tree index because the starting characters are known. The problem is specifically a &lt;code&gt;%&lt;/code&gt; at the start.&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;// Can't use index on email&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'LIKE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%@company.com'&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;// EXPLAIN type: ALL, rows: 2,100,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have three practical options depending on what you're actually doing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Add a separate indexed column&lt;/strong&gt; for the thing you're filtering on. If you're always filtering by email domain, store it explicitly:&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;// Migration: $table-&amp;gt;string('email_domain')-&amp;gt;index();&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email_domain'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'company.com'&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;// EXPLAIN type: ref, rows: 14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: Use a FULLTEXT index&lt;/strong&gt; for text search within a column:&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;// Requires: $table-&amp;gt;fullText('bio');&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereFullText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel developer'&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;FULLTEXT search is handled by MySQL's inverted index, which is built specifically for text matching. It doesn't do exact substring matches but it handles word-based search well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: Move to a search engine&lt;/strong&gt; like Meilisearch or Algolia for any real search feature. Laravel Scout wraps the integration cleanly and the performance difference is not comparable to anything MySQL can do natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Functions in orderByRaw()
&lt;/h2&gt;

&lt;p&gt;This one catches developers because it doesn't cause a slow WHERE filter. It causes a slow sort. When you apply a function to a column in ORDER BY, MySQL falls back to a filesort on the full result set rather than using the index for sorting.&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;// Triggers filesort&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOWER(name) ASC'&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;// EXPLAIN Extra: Using filesort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A filesort uses memory for smaller result sets and disk for larger ones. On a table with millions of rows, it's consistently slow.&lt;/p&gt;

&lt;p&gt;For case-insensitive sorting, the cleanest fix is using the right collation on the column. Most Laravel apps default to &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, which is already case-insensitive for comparisons and sorting. That means a plain &lt;code&gt;orderBy('name')&lt;/code&gt; is already case-insensitive if your column uses that collation. No &lt;code&gt;LOWER()&lt;/code&gt; needed at all.&lt;/p&gt;

&lt;p&gt;If you're on MySQL 8.0.13+ and actually need a functional index for a custom sort expression, you can create one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Functional index on MySQL 8.0.13+&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_name_lower&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your &lt;code&gt;orderByRaw('LOWER(name)')&lt;/code&gt; query will use it. The extra parentheses around &lt;code&gt;LOWER(name)&lt;/code&gt; in the DDL are required syntax for functional indexes in MySQL 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: Type mismatch on string columns
&lt;/h2&gt;

&lt;p&gt;This is the sneakiest one, and Laravel's query builder documentation explicitly warns about it.&lt;/p&gt;

&lt;p&gt;MySQL and MariaDB automatically typecast values during string-to-integer comparisons. Non-numeric strings convert to &lt;code&gt;0&lt;/code&gt;. So when you write:&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;// status is a varchar column: 'pending', 'processing', 'failed'&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL casts every &lt;code&gt;status&lt;/code&gt; value to an integer before comparing. &lt;code&gt;'pending'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. &lt;code&gt;'processing'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. &lt;code&gt;'failed'&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;. Every non-numeric string matches, the result is wrong, and because the implicit conversion breaks sargability, you get a full table scan on top of it.&lt;/p&gt;

&lt;p&gt;You get bad data silently along with bad performance. That's the double 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="c1"&gt;// Correct: compare string to string&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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 you're accepting filter values from a request, cast before querying:&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="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;This also matters inside local scopes. A scope that accepts a &lt;code&gt;$value&lt;/code&gt; parameter and passes it directly to &lt;code&gt;where()&lt;/code&gt; without type-checking produces this problem invisibly every time it's called with an integer.&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;// Dangerous scope: no type check&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;scopeWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&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;$query&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Caller passes an int from a form request: implicit cast happens silently&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status_id'&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;Add an explicit cast inside the scope so the problem can't sneak through regardless of what the caller passes:&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;scopeWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&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;$query&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$status&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;
  
  
  Pattern 5: whereRaw() with function wraps
&lt;/h2&gt;

&lt;p&gt;Developers who've heard about &lt;code&gt;whereDate()&lt;/code&gt; sometimes switch to &lt;code&gt;whereRaw()&lt;/code&gt; thinking they're writing safer SQL. They're not. You can reproduce the exact same non-sargable query yourself.&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;// Still bypasses index on created_at&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'YEAR(created_at) = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2026&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;// Still bypasses index on shipped_at&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DATE(shipped_at) &amp;gt;= ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'2026-01-01'&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;// Still bypasses index on id&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CAST(id AS CHAR) = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$stringId&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;Any function that wraps an indexed column in a WHERE clause breaks sargability, whether Eloquent generates it or you write it manually. The optimizer sees the function and can't use the B-tree index. That's the rule.&lt;/p&gt;

&lt;p&gt;The year-based filtering fix uses a Carbon range:&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;Carbon\Carbon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Full year range: sargable&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&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;startOfYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;Carbon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&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;endOfYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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;For the &lt;code&gt;CAST(id AS CHAR)&lt;/code&gt; case, the right fix is not to cast the column. Fix the type upstream. Cast the incoming string to an integer before passing it to the query. The database column should never need to change types mid-query just because the application passed the wrong type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 6: orWhere() on a composite index without grouping
&lt;/h2&gt;

&lt;p&gt;This one is less about function wrapping and more about how MySQL resolves OR conditions against composite indexes.&lt;/p&gt;

&lt;p&gt;Say you have a composite index on &lt;code&gt;(user_id, status)&lt;/code&gt; and you write:&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;orWhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'urgent'&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;MySQL generates &lt;code&gt;WHERE user_id = X OR status = 'urgent'&lt;/code&gt;. The first condition can use the composite index because &lt;code&gt;user_id&lt;/code&gt; is the leftmost prefix. But the second condition needs to find all rows where &lt;code&gt;status = 'urgent'&lt;/code&gt; regardless of &lt;code&gt;user_id&lt;/code&gt;, which points to a completely different part of the index. MySQL often decides a full table scan is cheaper than doing two separate index lookups and merging the results.&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;// On a table with 2M rows&lt;/span&gt;
&lt;span class="c1"&gt;// EXPLAIN type: ALL, rows: 1,987,432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL's Index Merge optimization can sometimes handle OR conditions, but it's unreliable. Merging two result sets adds CPU overhead, and the optimizer frequently skips it.&lt;/p&gt;

&lt;p&gt;Group your OR conditions explicitly using closures so the query intent is clear:&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;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$query&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;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'!='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'closed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orWhere&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="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$query&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;'priority'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&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;whereNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'escalated_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="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;For cases where both branches are hitting large portions of the table, two separate queries with a union often outperform a single OR query. Each query can use its own index cleanly, and there's no merge overhead:&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;$myOrders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$urgentOrders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'urgent'&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created_at'&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="nv"&gt;$myOrders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$urgentOrders&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;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'desc'&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;The union approach works best when the two sets don't overlap much. If they do overlap heavily, add &lt;code&gt;unionAll()&lt;/code&gt; to skip the deduplication cost, since UNION by default removes duplicate rows through a sort pass.&lt;/p&gt;

&lt;p&gt;One thing to watch: union queries return results in an undefined order unless you explicitly call &lt;code&gt;orderBy()&lt;/code&gt; on the outer query. Add it when the result order matters to the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;What MySQL runs&lt;/th&gt;
&lt;th&gt;Index?&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whereDate('col', $date)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATE(col) = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Half-open range with &lt;code&gt;whereBetween()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LIKE '%value'&lt;/code&gt; or &lt;code&gt;'%value%'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full wildcard scan&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Separate column, FULLTEXT, or Scout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderByRaw('LOWER(col)')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filesort&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Column collation or functional index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;where('varchar_col', 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Implicit cast&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Compare with correct string type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whereRaw('YEAR(col) = ?')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same as whereDate&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Carbon range with &lt;code&gt;whereBetween()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-&amp;gt;where()-&amp;gt;orWhere()&lt;/code&gt; on composite&lt;/td&gt;
&lt;td&gt;OR breaks range scan&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Grouped closures or union queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;&lt;strong&gt;How do I find these patterns in an existing app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enable MySQL's slow query log and set &lt;code&gt;long_query_time&lt;/code&gt; to something low like &lt;code&gt;0.1&lt;/code&gt; seconds. Then run &lt;code&gt;EXPLAIN&lt;/code&gt; on anything that shows up. Look for &lt;code&gt;type: ALL&lt;/code&gt; in the output. Laravel Telescope surfaces the generated SQL for every query, which makes it easy to copy into a MySQL client and run EXPLAIN manually. The &lt;a href="https://hafiz.dev/blog/laravel-query-optimization-from-3-seconds-to-30ms" rel="noopener noreferrer"&gt;Laravel query optimization guide&lt;/a&gt; walks through the debugging workflow in more detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this apply to local scopes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, completely. A local scope that calls &lt;code&gt;whereDate()&lt;/code&gt; or uses a leading wildcard LIKE generates the exact same SQL. The ORM layer doesn't change what MySQL executes. Always check the generated SQL with &lt;code&gt;-&amp;gt;toSql()&lt;/code&gt; before assuming a scope is index-friendly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is whereDate() ever acceptable?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On small tables, under roughly 50,000 rows, the performance difference is imperceptible. The problems start when the table crosses a few hundred thousand rows. The range fix costs nothing extra, so there's no good reason not to use it regardless of table size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about PostgreSQL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL supports functional indexes, so you can create an index on &lt;code&gt;DATE(created_at)&lt;/code&gt; directly and &lt;code&gt;whereDate()&lt;/code&gt; will use it. MySQL has no equivalent without generated columns, which add schema overhead. For MySQL apps, the range approach is always the cleaner fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why doesn't Eloquent warn you about these patterns?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because Eloquent doesn't know your schema. It doesn't know which columns are indexed, what types they are, or how many rows your table has. The ORM's job is to generate valid SQL. Understanding query plans is yours, and &lt;code&gt;EXPLAIN&lt;/code&gt; is the tool for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Most of these patterns look completely reasonable when you write them. That's what makes them dangerous. The fix isn't complicated in any of the six cases. It's usually a two-line change, but you have to know to look for it.&lt;/p&gt;

&lt;p&gt;The underlying rule is always the same: if a function wraps your indexed column in the WHERE or ORDER BY clause, MySQL can't use the index. Once you've internalized that, you'll spot non-sargable queries anywhere, not just in the six patterns above. It changes how you read Eloquent code, how you review PRs, and how you respond when someone reports a slow page. Instead of reaching for a new server or a caching layer first, you run EXPLAIN.&lt;/p&gt;

&lt;p&gt;A lot of Laravel performance problems that get blamed on infrastructure are actually query problems. The query optimizer doesn't care that you're using Eloquent. It doesn't know you expected the index to be used. It just follows the rules of the SQL it receives. Writing sargable queries is how you work with the optimizer rather than around it.&lt;/p&gt;

&lt;p&gt;Building something and want a second set of eyes on the query layer before it becomes a problem at scale? &lt;a href="mailto:contact@hafiz.dev"&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>databaseperformance</category>
      <category>eloquent</category>
    </item>
    <item>
      <title>Code PHP From Your Phone: The VPS + Tmux + Termius Setup That Actually Works</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Sat, 23 May 2026 11:57:16 +0000</pubDate>
      <link>https://dev.to/hafiz619/code-php-from-your-phone-the-vps-tmux-termius-setup-that-actually-works-h2a</link>
      <guid>https://dev.to/hafiz619/code-php-from-your-phone-the-vps-tmux-termius-setup-that-actually-works-h2a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/code-php-from-your-phone-vps-tmux-termius" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I've been running this setup on a Hetzner server for a while. This post documents exactly how I set it up, including the mistakes I made along the way that most tutorials skip over. I went through the full setup live while writing this, so the gotchas are real.&lt;/p&gt;

&lt;p&gt;If you haven't secured the VPS itself yet, start with &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;how I hardened mine with SSH, Cloudflare, and Tailscale&lt;/a&gt; first. That post covers locking down SSH, hiding your server IP, and routing access through Tailscale. This post covers what comes after: the PHP stack, Tmux sessions, Claude Code, and connecting from your phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a remote dev environment
&lt;/h2&gt;

&lt;p&gt;Local dev has one fatal flaw. The moment your laptop closes, everything dies. Queue workers, running scripts, anything you left going. You come back and reconstruct context from scratch.&lt;/p&gt;

&lt;p&gt;A VPS with Tmux flips this. Sessions persist indefinitely. You detach when you're done, reattach from any device and your work is exactly where you left it. The server never sleeps.&lt;/p&gt;

&lt;p&gt;There's a practical benefit for anyone traveling or away from their desk. Your full dev environment runs on the server. Your local device is just a terminal window. An iPad, a phone, a borrowed laptop: they all get identical access to the same running environment.&lt;/p&gt;

&lt;p&gt;For Laravel specifically: queue workers run continuously on the VPS. No need to restart them every session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.x + Composer already installed on the VPS? Skip Step 1.&lt;/li&gt;
&lt;li&gt;Tmux already installed? Skip Step 2.&lt;/li&gt;
&lt;li&gt;Just want the phone connection? Jump to Step 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Check and install the PHP stack
&lt;/h2&gt;

&lt;p&gt;First, see what's already on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PHP: not installed'&lt;/span&gt;
composer &lt;span class="nt"&gt;-V&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Composer: not installed'&lt;/span&gt;
tmux &lt;span class="nt"&gt;-V&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Tmux: not installed'&lt;/span&gt;
which claude 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Claude Code: not installed'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If PHP is missing, install PHP 8.4 with the extensions a Laravel app needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; software-properties-common
&lt;span class="nb"&gt;sudo &lt;/span&gt;add-apt-repository ppa:ondrej/php &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; php8.4 php8.4-cli php8.4-fpm php8.4-mbstring &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-xml php8.4-curl php8.4-zip php8.4-mysql &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-redis php8.4-bcmath php8.4-intl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install Composer if it's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://getcomposer.org/installer | php
&lt;span class="nb"&gt;sudo mv &lt;/span&gt;composer.phar /usr/local/bin/composer
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/composer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the Laravel installer globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer global require laravel/installer
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$HOME/.config/composer/vendor/bin:$PATH"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up Git with your details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.name &lt;span class="s2"&gt;"Your Name"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.email &lt;span class="s2"&gt;"your@email.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Tmux for sessions that survive disconnects
&lt;/h2&gt;

&lt;p&gt;Tmux is what makes this whole setup usable. Without it, every SSH disconnect kills your running processes. With it, sessions sit on the server indefinitely.&lt;/p&gt;

&lt;p&gt;Install if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;tmux &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a session helper function to your &lt;code&gt;.bashrc&lt;/code&gt;. This is the &lt;code&gt;tm&lt;/code&gt; command you'll use every time you connect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'

# Tmux session helper: creates or reattaches to a named session
tm() {
  local name="&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
  name="&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;//\./-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
  name="&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;//\//-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
  if [ -n "&lt;/span&gt;&lt;span class="nv"&gt;$TMUX&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
    tmux has-session -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null || tmux new-session -d -s "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" -c "&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="sh"&gt;"
    tmux switch-client -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;"
  else
    tmux attach -t "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null || tmux new -s "&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="sh"&gt;" -c "&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="sh"&gt;"
  fi
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the Tmux status bar appear at the bottom of the terminal, showing the session name and timestamp. That bar is the confirmation that you're inside a persistent session.&lt;/p&gt;

&lt;p&gt;For a Laravel project, I keep four windows in a single session:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hafiz.dev/blog/code-php-from-your-phone-vps-tmux-termius" rel="noopener noreferrer"&gt;View the interactive diagram on hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Tmux commands you'll use daily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Ctrl+B, D         &lt;span class="c"&gt;# detach: leaves session running on server&lt;/span&gt;
Ctrl+B, C         &lt;span class="c"&gt;# new window in current session&lt;/span&gt;
Ctrl+B, &lt;span class="o"&gt;[&lt;/span&gt;number]  &lt;span class="c"&gt;# switch to window 0, 1, 2...&lt;/span&gt;
Ctrl+B, ,         &lt;span class="c"&gt;# rename current window&lt;/span&gt;
tmux &lt;span class="nb"&gt;ls&lt;/span&gt;           &lt;span class="c"&gt;# list all sessions&lt;/span&gt;
tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; name  &lt;span class="c"&gt;# reattach to a named session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical gotcha:&lt;/strong&gt; Always detach with &lt;code&gt;Ctrl+B, D&lt;/code&gt;. Never close the terminal window or press &lt;code&gt;Ctrl+C&lt;/code&gt; on the shell. That kills the session. Detach keeps it running on the server. This distinction is the entire point of Tmux.&lt;/p&gt;

&lt;p&gt;For more on Laravel queue workers and why running them continuously matters, the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;queue jobs guide&lt;/a&gt; covers the production setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Claude Code on the VPS
&lt;/h2&gt;

&lt;p&gt;Install with the native installer (no Node.js required, and this is the recommended method as of 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://claude.ai/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; The installer will warn you that &lt;code&gt;~/.local/bin&lt;/code&gt; is not in your PATH. It won't fix this for you. Run the fix manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$HOME/.local/bin:$PATH"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# 2.1.150 (Claude Code)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first run, &lt;code&gt;claude&lt;/code&gt; opens a browser authentication link. You need a Claude Pro ($20/month), Max, Team, Enterprise, or Console API account. The free tier doesn't include Claude Code access.&lt;/p&gt;

&lt;p&gt;Start Claude Code in a dedicated Tmux window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Ctrl+B, C         &lt;span class="c"&gt;# new window&lt;/span&gt;
Ctrl+B, ,         &lt;span class="c"&gt;# rename it "claude"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/my-laravel-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code runs inside your project directory. It can read files, run artisan commands, write code, and manage git from the terminal. When you detach and reattach from any device, it's in the same state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Termius for phone access
&lt;/h2&gt;

&lt;p&gt;Termius is the best SSH client for iOS and Android. It has a custom keyboard row with Ctrl, Esc, Tab, and pipe. The keys you actually need in a terminal. Plus a keep-alive setting that prevents the connection dropping when you switch apps.&lt;/p&gt;

&lt;p&gt;Download from the App Store or Google Play.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha: create your account before adding anything.&lt;/strong&gt; The free tier includes sync. If you add hosts and keys without logging in first, your local data may not transfer to your account when you sign up later. Open Termius, tap Profile, create the account, then proceed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add your SSH key:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap &lt;strong&gt;Vaults → Keychain → +&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Paste Key&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;On your Mac, run &lt;code&gt;cat ~/.ssh/id_rsa&lt;/code&gt; (or &lt;code&gt;id_ed25519&lt;/code&gt;) and copy the entire output including the header and footer lines&lt;/li&gt;
&lt;li&gt;Paste into the Private Key field, give it a label like &lt;code&gt;hetzner-key&lt;/code&gt;, save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz6ya0w57xadb6xnyhgvd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz6ya0w57xadb6xnyhgvd.webp" alt="SSH key saved in Termius Keychain" width="721" height="1568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add the host:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap &lt;strong&gt;Vaults → Hosts → +&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label:&lt;/strong&gt; &lt;code&gt;Hetzner Dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP:&lt;/strong&gt; your server's IPv4 address (get it with &lt;code&gt;curl -4 ifconfig.me&lt;/code&gt; on the server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; &lt;code&gt;root&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Key, Certificate, FIDO2&lt;/strong&gt; → select &lt;code&gt;hetzner-key&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Save, then tap the host to connect&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdixgsp7jvn87p50r3qge.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdixgsp7jvn87p50r3qge.webp" alt="Termius host configured with root username and hetzner-key" width="721" height="1568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once connected, reattach to your session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyq9nl2ihk2towrgp2cok.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyq9nl2ihk2towrgp2cok.webp" alt="Tmux session running from iPhone showing the green status bar" width="716" height="1372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The status bar reappears. Your queue worker, Claude Code session, and anything else you left running are all exactly where you left them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a Startup Snippet to auto-attach on every connection:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Edit Host, tap &lt;strong&gt;Startup Snippet&lt;/strong&gt; and add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; dev 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; tmux new &lt;span class="nt"&gt;-s&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time you open Termius and tap the host, you land directly in your Tmux session. No typing required. On a phone keyboard that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the Termius AI Agent feature
&lt;/h2&gt;

&lt;p&gt;When adding a host, Termius shows an "AI Agent" section suggesting setup for Claude Code, Gemini, and OpenCode. This is a Termius Pro feature that integrates AI assistants directly into the mobile terminal. If you're on Termius Pro, it connects to the Claude Code session running in your Tmux window. On the free tier, you don't need it; Claude Code works fine via regular SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it works
&lt;/h2&gt;

&lt;p&gt;The test: create a session from your phone, start a long-running command, detach, reconnect, see it still running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From your phone, inside Tmux&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Connected from phone at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;9999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detach with &lt;code&gt;Ctrl+B, D&lt;/code&gt;. Run &lt;code&gt;tmux ls&lt;/code&gt; and the session shows as running. Reattach with &lt;code&gt;tmux attach -t dev&lt;/code&gt; and your sleep is still going. Open the same session from your Mac and you'll see the exact same state: the same session name, the same running command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Viewing the app from your phone browser
&lt;/h2&gt;

&lt;p&gt;If you want to actually preview the Laravel app in a browser from your phone, &lt;code&gt;php artisan serve&lt;/code&gt; alone won't work. It binds to &lt;code&gt;127.0.0.1:8000&lt;/code&gt; on the VPS. Your phone's browser can't reach that address.&lt;/p&gt;

&lt;p&gt;The clean solution: configure Caddy to serve the app on the server's Tailscale IP. Accessible from your phone's browser as long as Tailscale is running on both devices, invisible to the public internet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/caddy/Caddyfile
http://100.x.x.x {
    root * /var/www/my-laravel-app/public
    php_fastcgi unix//run/php/php8.4-fpm.sock
    file_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a coding and deployment workflow without browser preview, you don't need this. Claude Code on the VPS handles edits without a visual browser.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this survive a VPS reboot?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tmux sessions don't survive reboots. If the server restarts (for security updates, for example), your sessions are gone. Add a Supervisor config to restart queue workers and other persistent processes automatically on boot. Claude Code you restart manually when you reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I need to SSH from a machine without my key?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use your VPS provider's browser console. Hetzner calls it the Console button in the server dashboard. It gives you terminal access without SSH. Bookmark it before you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My phone keyboard can't send Ctrl+B properly. What do I do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use the Termius keyboard row above the standard keyboard. Tap &lt;code&gt;ctrl&lt;/code&gt;, then tap &lt;code&gt;b&lt;/code&gt;, then release. The modifier keys in Termius are designed for this. If you see &lt;code&gt;^B^B^B&lt;/code&gt; appearing as text, you're pressing &lt;code&gt;ctrl&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; simultaneously on the standard keyboard instead of using the Termius row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a Hetzner VPS specifically required?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any Ubuntu VPS works. DigitalOcean, Vultr, Linode: same commands, same setup. Hetzner is the one I use. The CX23 runs at €4/month with solid specs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Tailscale for this to work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not for the basic setup. If port 22 is open on your server, Termius connects via the public IP. Tailscale is the right next step if you want to close port 22 and keep SSH off the public internet, which the &lt;a href="https://hafiz.dev/blog/how-i-hardened-my-vps-ssh-cloudflare-tailscale" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual workflow
&lt;/h2&gt;

&lt;p&gt;Once everything is running, the daily flow from your phone looks like this: open Termius, tap the host, land directly inside your Tmux session via the Startup Snippet. Queue worker is running. Claude Code is open in window 3. Your last git commit message is visible in window 0. Nothing to reconstruct.&lt;/p&gt;

&lt;p&gt;The whole setup took about an hour to get right, including the debugging. The Hetzner server costs less than a coffee per month. And the next time your laptop battery dies mid-flight, your dev environment is fine.&lt;/p&gt;

&lt;p&gt;If you build something with this setup and want to talk through the architecture, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vps</category>
      <category>php</category>
      <category>remotedevelopment</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>What's Coming to Laravel Cloud: $5/month Plan, Spend Caps, and Instant Scale-to-Zero</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Wed, 20 May 2026 05:41:15 +0000</pubDate>
      <link>https://dev.to/hafiz619/whats-coming-to-laravel-cloud-5month-plan-spend-caps-and-instant-scale-to-zero-2p9k</link>
      <guid>https://dev.to/hafiz619/whats-coming-to-laravel-cloud-5month-plan-spend-caps-and-instant-scale-to-zero-2p9k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-cloud-5-dollar-plan-spend-caps-scale-to-zero" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The two things that kept most Laravel developers off Laravel Cloud were cost and the fear of a surprise bill. The Laravel team just announced both are being fixed, along with three other significant changes coming over the next few weeks.&lt;/p&gt;

&lt;p&gt;Here's what was announced and what it actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Announcements
&lt;/h2&gt;

&lt;p&gt;The announcement laid them out clearly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A new $5/month plan.&lt;/strong&gt; Previously, your options were Starter (free tier, pay-as-you-go, limited features) or Growth ($20/month). The new $5/month tier sits between them. The stated goal: "We want Cloud to be accessible to every Laravel developer."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Spend caps.&lt;/strong&gt; Set a spending limit on your account. When you hit it, Cloud will either alert you or shut off compute entirely. No more anxiety about waking up to an unexpected $300 bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. True scale-to-zero with 10x faster wake-up.&lt;/strong&gt; Hibernation has existed for a while, but wake-up previously took 5-20 seconds. App, database, and cache wake-up is now measured in milliseconds. Users won't notice it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Bot protection rules.&lt;/strong&gt; Keeps hibernated apps dormant longer by filtering bot traffic before it wakes your environment. This directly reduces compute spend. Bots won't trigger unnecessary wake-ups that eat into your budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Managed queues.&lt;/strong&gt; Queue clusters are being rebuilt into a new "managed queues" product. Set a maximum worker count and Cloud handles everything else. It scales on queue depth (not just CPU) and scales to zero when idle. No more guessing at worker sizing.&lt;/p&gt;

&lt;p&gt;These changes aren't live yet. The announcement said "coming over the next few weeks," so treat this as a planning-ahead post rather than a same-day implementation guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spend Cap Is the Real News
&lt;/h2&gt;

&lt;p&gt;The $5/month price point gets the headline, but spend caps are what actually unlocks Cloud for cautious teams.&lt;/p&gt;

&lt;p&gt;Usage-based pricing always carries anxiety. Even with hibernation enabled, one unexpected traffic spike, a crawling bot hammering your endpoints, or a runaway job queue could turn a $10 month into an $80 one. That unpredictability kept teams on fixed-cost Forge servers where the bill was predictable even if the compute was oversized.&lt;/p&gt;

&lt;p&gt;Spend caps change the risk profile completely. You can enable hibernation, deploy a small instance, and know that the maximum you'll pay in a worst-case month is whatever cap you set. That's a fundamentally different product than "usage-based with no ceiling."&lt;/p&gt;

&lt;p&gt;Combine spend caps with the new bot protection rules and the hibernation economics get significantly better. Bots are the silent killers of scale-to-zero apps. They wake your environment constantly without generating real revenue. Filtering them before they trigger a wake-up means your app actually stays dormant during off-peak hours instead of yo-yoing on and off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Millisecond Wake-Up Changes
&lt;/h2&gt;

&lt;p&gt;The previous 5-20 second cold start was the biggest practical barrier to using hibernation on anything user-facing. You could use it for staging environments and dev instances without issue, but putting a hibernating app in front of real users meant the first request after idle time got a slow response. Users noticed. Support tickets followed.&lt;/p&gt;

&lt;p&gt;Millisecond wake-up removes that constraint. If the wake-up is imperceptible, hibernation becomes a pure cost optimization with no UX downside. You can run a production app with a low-cost Flex instance, hibernate between traffic windows, and pay only for active time, without needing a "warm-up" strategy or a cron job to keep things alive.&lt;/p&gt;

&lt;p&gt;One question worth watching: the tweet mentions milliseconds for app, database, and cache wake-up. The database wake-up is the historically slow part. If serverless Postgres wakes in milliseconds consistently, that's a genuine infrastructure achievement. The community will stress-test this quickly once it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed Queues as a Quiet Win
&lt;/h2&gt;

&lt;p&gt;The managed queues announcement is being slightly overshadowed by the pricing news, but it's significant for teams running serious background processing.&lt;/p&gt;

&lt;p&gt;Queue workers have always required manual tuning. How many workers does this job type need? What happens if the queue backs up? How do you prevent workers from sitting idle at 3am burning compute? The standard answer was either Horizon with careful configuration or accepting occasional over-provisioning, patterns covered in the &lt;a href="https://hafiz.dev/blog/laravel-queue-jobs-processing-10000-tasks-without-breaking" rel="noopener noreferrer"&gt;Laravel queue jobs guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Managed queues take that decision away. Cloud monitors queue depth and adjusts worker count automatically. When the queue drains, workers scale to zero. When it fills, they spin up. You set a maximum and stop thinking about it.&lt;/p&gt;

&lt;p&gt;For applications with variable job loads (processing payments, sending email campaigns, handling webhooks), this is cleaner than anything available on Forge or Vapor today. It ties directly into the monitoring and observability improvements that make Cloud worth the higher base cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Math: Cloud vs Forge vs Vapor
&lt;/h2&gt;

&lt;p&gt;With the new plan, the comparison looks different for small projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indie developer with multiple side projects:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud (new $5 tier + usage with hibernation): $5 + ~$2-5/month per app&lt;/li&gt;
&lt;li&gt;Forge: $17/month base (you can add cheap VPS servers, but Forge itself is $17)&lt;/li&gt;
&lt;li&gt;VPS direct: $5-10/month per server (you manage everything)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One developer in the announcement thread shared they're running seven apps with decent traffic for $6/month total using scale-to-zero. That number will improve further with bot protection and millisecond cold starts. For anyone currently running multiple low-traffic side projects on separate servers, the Cloud math starts looking favorable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-scale startup on Growth ($20/month):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud Growth: $20/month + ~$16-25 usage = ~$36-45/month&lt;/li&gt;
&lt;li&gt;Forge: $17/month + $20-40/month DigitalOcean/Hetzner VPS = ~$37-57/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this tier, the total cost is similar, but Cloud includes autoscaling, managed databases, preview environments, and zero DevOps overhead. Forge requires you to configure servers, manage Nginx, handle SSL renewal, and tune your own scaling. The value comparison has always favoured Cloud for teams without a dedicated DevOps person. The new pricing makes it viable to reach that tier sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vapor ($39/month):&lt;/strong&gt;&lt;br&gt;
Vapor runs on AWS Lambda, which gives you true serverless with different performance characteristics. It's still the right choice for teams deeply invested in the AWS ecosystem. But for most Laravel developers who just want their app deployed and scaled without thinking about infrastructure, Cloud's model is more Laravel-native and increasingly competitive on price. The &lt;a href="https://hafiz.dev/blog/laravel-cicd-github-actions-complete-guide" rel="noopener noreferrer"&gt;CI/CD and deployment guide&lt;/a&gt; from last week covers how to automate your Cloud deployments via GitHub Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Be Paying Attention
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Indie developers and side project builders.&lt;/strong&gt; The $5/month plan plus spend caps makes Cloud viable for projects that previously couldn't justify $20/month. Multiple side projects on a single Cloud account with hibernation enabled could cost less than one Forge server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small agencies running client sites.&lt;/strong&gt; Preview environments per pull request plus managed databases at $5-20/month per project is a clearly compelling agency workflow. The managed queues addition makes it viable for client projects with background job requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams on Forge who don't enjoy server management.&lt;/strong&gt; If you're paying someone to manage Forge servers (patching, monitoring, scaling), Cloud is worth a fresh look. The total cost might be similar, but the management overhead disappears. For teams building SaaS products on Laravel, the &lt;a href="https://hafiz.dev/blog/building-saas-with-laravel-and-filament-complete-guide" rel="noopener noreferrer"&gt;complete SaaS guide&lt;/a&gt; covers the broader architecture decisions where Cloud fits naturally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone who previously tried Cloud and left because of cost or cold start anxiety.&lt;/strong&gt; Both objections are being addressed directly. Worth revisiting once the features ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Stay Where They Are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Developers who want full server control.&lt;/strong&gt; Forge gives you root access and complete flexibility. Cloud abstracts that away by design. If you need custom Nginx configs, non-standard PHP extensions, or specific OS-level tooling, Forge is still the better choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams on Vapor with existing AWS infrastructure.&lt;/strong&gt; The Lambda model suits certain workloads well and migrating away from a working setup for incremental cost savings rarely makes sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High-traffic, always-on applications.&lt;/strong&gt; Hibernation economics only work when your app has genuine idle periods. If you're running a busy production app that never sleeps, the cost advantage disappears and a dedicated server (Forge or otherwise) may still be cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://hafiz.dev/blog/scotty-vs-laravel-envoy-spatie-deploy-tool" rel="noopener noreferrer"&gt;Scotty vs Envoy post&lt;/a&gt; from April made the case that the Laravel deployment tooling ecosystem is maturing fast. This announcement reinforces that. A $5/month managed hosting tier with spend caps and millisecond cold starts is a product that didn't exist anywhere in the PHP ecosystem six months ago.&lt;/p&gt;

&lt;p&gt;The spend caps and bot protection show the team is listening to actual adoption blockers rather than just adding features. Those two additions together fix the core reason cautious developers stayed away.&lt;/p&gt;

&lt;p&gt;The managed queues rebuild is the kind of unsexy infrastructure improvement that saves teams real operational hours once you're running it in production.&lt;/p&gt;

&lt;p&gt;Once these ship, the "why not just use Forge" argument gets harder to make for most new Laravel projects.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When are these features available?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The announcement said "coming over the next few weeks." No specific release date was given. Follow the &lt;a href="https://laravel.com/blog" rel="noopener noreferrer"&gt;official Laravel Cloud blog&lt;/a&gt; for the rollout timeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will the $5/month plan include custom domains?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not confirmed in the announcement. The current Starter (free) plan includes custom domains, so it's likely the $5 tier does too. Check the official pricing page once it's updated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the spend cap pause the app or just alert you?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The announcement confirmed it will either alert you or shut off compute. The configuration details (which trigger, per-environment vs per-account) will be in the documentation once it ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate existing apps before these features land?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wait until they're live and documented before making migration decisions. The announcements are forward-looking; current Cloud behavior still applies today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does bot protection work with legitimate crawlers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Specific configuration details weren't shared in the announcement. Presumably you'll be able to allowlist crawlers like Googlebot. More detail will come in the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth Watching
&lt;/h2&gt;

&lt;p&gt;These announcements move Laravel Cloud from "interesting but expensive" to "worth serious consideration for almost any new Laravel project." The spend caps alone change the risk calculation. The millisecond wake-up makes hibernation viable for production. The $5 plan removes the cost objection for small projects.&lt;/p&gt;

&lt;p&gt;Whether this changes your deployment setup depends on where you are now. But if you wrote off Cloud six months ago, the next few weeks are worth another look.&lt;/p&gt;

&lt;p&gt;If you're evaluating Laravel Cloud for a specific project and want to think through the numbers, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>deployment</category>
      <category>laravelcloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>Using Ollama with the Laravel AI SDK: Run Local LLMs for Free</title>
      <dc:creator>Hafiz</dc:creator>
      <pubDate>Mon, 18 May 2026 05:11:59 +0000</pubDate>
      <link>https://dev.to/hafiz619/using-ollama-with-the-laravel-ai-sdk-run-local-llms-for-free-2do1</link>
      <guid>https://dev.to/hafiz619/using-ollama-with-the-laravel-ai-sdk-run-local-llms-for-free-2do1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published at &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-ollama-local-llms-guide" rel="noopener noreferrer"&gt;hafiz.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;API costs add up fast during AI development. You prompt an agent 50 times debugging a tool, that's 50 API calls. You run your test suite, that's another batch. Multiply that across a team and you're spending real money before shipping anything.&lt;/p&gt;

&lt;p&gt;Ollama solves this cleanly. It runs open-source models locally on your machine (Llama 3, Qwen, Mistral, and dozens more) and the Laravel AI SDK treats it as a first-party provider, exactly like OpenAI or Anthropic. Switch between them with a single environment variable. No code changes, no new packages, no API keys.&lt;/p&gt;

&lt;p&gt;This post covers the full setup: installing Ollama, configuring it in the Laravel AI SDK, building agents that run locally, and the dev/production workflow that lets you use Ollama locally while shipping with a cloud provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ollama Does
&lt;/h2&gt;

&lt;p&gt;Ollama is a lightweight tool that downloads and serves open-source language models locally. Once it's running, it exposes an HTTP API on &lt;code&gt;localhost:11434&lt;/code&gt; that the Laravel AI SDK connects to directly.&lt;/p&gt;

&lt;p&gt;There's no internet connection required after the initial model download. No rate limits. No costs per token. If you've built your app with the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-tutorial-build-a-smart-assistant-in-30-minutes" rel="noopener noreferrer"&gt;Laravel AI SDK smart assistant tutorial&lt;/a&gt;, your existing agents work with Ollama with a one-line change.&lt;/p&gt;

&lt;p&gt;The tradeoff is hardware. Larger models need more RAM and a capable GPU to run at acceptable speeds. But for development, smaller models like Llama 3.2:3B run well on any modern developer machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Ollama
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;macOS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or download the macOS app from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt; which installs as a menu bar app and starts automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ollama.com/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows:&lt;/strong&gt;&lt;br&gt;
Download the installer from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt;. Ollama runs as a background service after installation.&lt;/p&gt;

&lt;p&gt;Once installed, verify it's running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434
&lt;span class="c"&gt;# Should return: Ollama is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pulling Models
&lt;/h2&gt;

&lt;p&gt;Download a model with &lt;code&gt;ollama pull&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;&lt;span class="c"&gt;# General purpose, runs on any machine with 4GB+ RAM&lt;/span&gt;
ollama pull llama3.2

&lt;span class="c"&gt;# Smaller version, 2GB, good for constrained machines&lt;/span&gt;
ollama pull llama3.2:1b

&lt;span class="c"&gt;# Strong at code-related tasks, good for Laravel AI agents&lt;/span&gt;
ollama pull qwen2.5-coder:7b

&lt;span class="c"&gt;# Mistral, fast and capable general model&lt;/span&gt;
ollama pull mistral
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can list all downloaded models:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And test a model from the terminal before wiring it into Laravel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run llama3.2 &lt;span class="s2"&gt;"Explain Laravel service containers in one sentence"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the &lt;a href="https://hafiz.dev/laravel/artisan-commands" rel="noopener noreferrer"&gt;artisan commands&lt;/a&gt; used in this guide, having at least one model pulled before starting saves debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring the Laravel AI SDK
&lt;/h2&gt;

&lt;p&gt;The SDK ships with Ollama support out of the box. The only &lt;code&gt;.env&lt;/code&gt; addition is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave the value blank. Ollama doesn't require authentication for local use, but the SDK expects the variable to exist. Add it to your &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.example&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If Ollama is running on the default port, that's all you need. If you've changed the port or are running Ollama on a remote machine, configure the URL in &lt;code&gt;config/ai.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'providers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other providers&lt;/span&gt;

    &lt;span class="s1"&gt;'ollama'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ollama'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OLLAMA_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'url'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OLLAMA_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:11434/api'&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;And in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default URL is &lt;code&gt;http://localhost:11434/api&lt;/code&gt; so for standard setups you don't need to add this. It works without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Ollama in Your Agents
&lt;/h2&gt;

&lt;p&gt;Two ways to route an agent to Ollama: set it as the default provider for a specific agent class, or override it per-prompt at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-Agent with PHP Attributes
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;#[Provider]&lt;/code&gt; and &lt;code&gt;#[Model]&lt;/code&gt; attributes to your agent class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Agents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Attributes\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;Laravel\Ai\Attributes\Provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Provider(Lab::Ollama)]&lt;/span&gt;
&lt;span class="na"&gt;#[Model('llama3.2')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&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;Promptable&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;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'You are a helpful support agent. Answer questions about our product concisely.'&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 every time you prompt this agent, it uses Ollama locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cleanest pattern for development. You write your agent once with Ollama attributes, build and test locally with no API costs, then change the attributes (or override them via &lt;code&gt;.env&lt;/code&gt;) when deploying to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Overriding Per-Prompt
&lt;/h3&gt;

&lt;p&gt;For one-off local testing without modifying the agent class:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Ollama&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'llama3.2'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful when you want to quickly compare responses between Ollama and a cloud provider without changing the agent configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dev/Production Workflow
&lt;/h2&gt;

&lt;p&gt;The cleanest approach is to set a default provider at the application level in &lt;code&gt;config/ai.php&lt;/code&gt;, driven by environment variables:&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'&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;'text'&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;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AI_PROVIDER'&lt;/span&gt;&lt;span class="p"&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="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AI_MODEL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="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 in your local &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;AI_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;
&lt;span class="py"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;llama3.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in production &lt;code&gt;.env&lt;/code&gt; (or your Forge/Vapor environment):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;AI_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;anthropic&lt;/span&gt;
&lt;span class="py"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero code changes between environments. Your agents, tools, and structured output stay identical. Only the provider changes. This works well for any agent that doesn't use PHP attribute overrides; those take precedence over the default config.&lt;/p&gt;

&lt;p&gt;For agents with explicit &lt;code&gt;#[Provider]&lt;/code&gt; attributes, you'd need to either remove the attributes or use a different approach for environment-based switching. The attribute approach is better for agents that should always use a specific provider (a code review agent that truly needs a smart model in all environments). The default config approach is better for general-purpose agents where Ollama in dev and a cloud model in prod makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Models to Use
&lt;/h2&gt;

&lt;p&gt;Not all models are equal, and the right choice depends on what your agent is doing. Here's a practical guide based on common Laravel AI SDK use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Llama 3.2 (3B or 8B)&lt;/strong&gt; is the safe default for most use cases. The 3B version runs comfortably on any developer machine with 4GB RAM. The 8B version is noticeably better at following complex instructions but needs 8GB. Good for support agents, document summarisation, and general Q&amp;amp;A. Start here if you're not sure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qwen 2.5 Coder (7B)&lt;/strong&gt; is the right choice for agents that work with code. It outperforms Llama on code generation and review tasks despite similar size. If you're building an agent that analyzes PHP files, generates migrations, or reviews code quality, use this one instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistral (7B)&lt;/strong&gt; is fast and reliable for instruction-following tasks. If you need quick responses and the task isn't code-heavy, Mistral is worth trying. It tends to be faster than Llama 3.2 at the same quality level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid very large models (30B+)&lt;/strong&gt; for development. They're slow on typical developer machines and the speed penalty makes iteration painful. The quality gap between 7B and 30B matters less in development where you're primarily testing tool calls and output format, not production response quality. Save the big models for your production cloud provider.&lt;/p&gt;

&lt;p&gt;A practical setup for a Laravel SaaS would be: use &lt;code&gt;llama3.2:8b&lt;/code&gt; for general agents and &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; for any agent touching code. Both run on a 16GB machine without issues. If you're on a 8GB machine, use &lt;code&gt;llama3.2:3b&lt;/code&gt; for everything and accept slightly weaker instruction following in exchange for speed.&lt;/p&gt;

&lt;p&gt;If you've already built a multi-agent system with the SDK, you can route different sub-agents to different Ollama models the same way you'd assign different cloud models, and the &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-what-it-changes-why-it-matters-and-should-you-use-it" rel="noopener noreferrer"&gt;AI SDK overview&lt;/a&gt; covers the broader SDK capabilities worth knowing before diving into local model optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings with Ollama
&lt;/h2&gt;

&lt;p&gt;Ollama also works for local embeddings, which means you can do RAG development with zero API costs:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'How do I cancel my subscription?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Ollama&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'nomic-embed-text'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the embedding model first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull nomic-embed-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nomic-embed-text&lt;/code&gt; is a solid local embedding model that produces 768-dimension vectors. For production RAG you'd swap to OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; or a similar cloud model, but for building and testing your vector search logic, Ollama keeps costs at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ollama Doesn't Support
&lt;/h2&gt;

&lt;p&gt;The Laravel AI SDK's Ollama integration covers text generation and embeddings. It does not support image generation, text-to-speech, speech-to-text, or file uploads. If your agents use those capabilities, you'll need a cloud provider for those specific features.&lt;/p&gt;

&lt;p&gt;This is usually fine for a dev/production split. Most agent logic (tools, structured output, conversation flow) doesn't depend on images or audio. You can run the core agent logic against Ollama locally, and the multimedia features only come into play in staging or production against cloud providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Agents That Use Ollama
&lt;/h2&gt;

&lt;p&gt;One thing to be aware of: when running your test suite, you probably don't want tests making real Ollama calls any more than you'd want real OpenAI calls. The SDK's fake testing utilities work regardless of which provider is configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'responds to password reset questions'&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;SupportAgent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'To reset your password, visit the login page and click "Forgot password".'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How do I reset my password?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'password'&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;Faking the agent response means your tests are fast, deterministic, and don't depend on Ollama being installed or running. The &lt;a href="https://hafiz.dev/blog/how-to-stop-an-ai-agent-from-destroying-your-laravel-app" rel="noopener noreferrer"&gt;agent safety post&lt;/a&gt; covers more on keeping agent behavior predictable in tests.&lt;/p&gt;

&lt;p&gt;The development workflow then becomes: build and iterate against real Ollama locally, run the test suite with faked responses, deploy with cloud providers in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ollama on a Shared Dev Server
&lt;/h2&gt;

&lt;p&gt;If your team uses a shared development server, you can run Ollama there and point everyone's local Laravel instances at it. Just update &lt;code&gt;OLLAMA_URL&lt;/code&gt; in each developer's &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;http://your-dev-server:11434/api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure Ollama is configured to accept connections from outside localhost on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OLLAMA_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0 ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means one machine does the model serving and your team shares it, without everyone needing to pull and run models locally. Useful if some team members are on constrained hardware.&lt;/p&gt;

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

&lt;h4&gt;
  
  
  Does Ollama work with Laravel AI SDK agents that use tools?
&lt;/h4&gt;

&lt;p&gt;Yes, but model quality matters more for tool use. Some smaller models handle tool calls inconsistently. Llama 3.2 8B is reliable for tool use. If you're seeing missed or malformed tool calls, try a larger or more capable model.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use Ollama in production?
&lt;/h4&gt;

&lt;p&gt;You can if you have dedicated server hardware with enough RAM and ideally a GPU. Most teams use Ollama for local development and testing, then cloud providers in production. The cost and maintenance overhead of running Ollama in production usually outweighs the savings unless you have high volume and a specific privacy requirement.&lt;/p&gt;

&lt;h4&gt;
  
  
  What's the difference between Ollama and running models via API?
&lt;/h4&gt;

&lt;p&gt;With Ollama, the model runs on your machine. No data leaves your network. With cloud APIs (OpenAI, Anthropic), your prompts are sent to the provider's servers. For development involving sensitive or proprietary data, Ollama is the better choice.&lt;/p&gt;

&lt;h4&gt;
  
  
  Do I need a GPU?
&lt;/h4&gt;

&lt;p&gt;No. Most models run on CPU, just more slowly. For development iteration a CPU is fine. Responses take 5-15 seconds depending on model size and your hardware. A GPU drops that to under 2 seconds for 7B models.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use Ollama with the sub-agents pattern?
&lt;/h4&gt;

&lt;p&gt;Yes. Each sub-agent can have its own &lt;code&gt;#[Provider(Lab::Ollama)]&lt;/code&gt; and &lt;code&gt;#[Model]&lt;/code&gt; attributes. The &lt;a href="https://hafiz.dev/blog/laravel-ai-sdk-sub-agents-tutorial" rel="noopener noreferrer"&gt;sub-agents guide&lt;/a&gt; covers the full pattern; the Ollama attributes drop in without any other changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Locally
&lt;/h2&gt;

&lt;p&gt;The setup comes down to four steps: install Ollama, pull a model, add &lt;code&gt;OLLAMA_API_KEY=&lt;/code&gt; to your &lt;code&gt;.env&lt;/code&gt;, and add &lt;code&gt;#[Provider(Lab::Ollama)]&lt;/code&gt; to your agent class. After that, you're running AI locally with no API costs and no rate limits while you build.&lt;/p&gt;

&lt;p&gt;In production, switch back to OpenAI or Anthropic by changing the provider attribute or your default config. The rest of your code stays exactly the same.&lt;/p&gt;

&lt;p&gt;If you're setting this up for a team or have questions about the dev/production split, &lt;a href="mailto:contact@hafiz.dev"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>aisdk</category>
      <category>aidevelopment</category>
      <category>ollama</category>
    </item>
  </channel>
</rss>
