<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Josh Salway</title><description>Full-stack Developer focused on building practical web applications.</description><link>https://joshsalway.com/</link><item><title>Improving Queue Safety in Laravel</title><link>https://joshsalway.com/articles/improving-queue-safety-in-laravel/</link><guid isPermaLink="true">https://joshsalway.com/articles/improving-queue-safety-in-laravel/</guid><description>Every queued job needs eight things the Laravel scaffold doesn&apos;t give you. 23 findings verified against Laravel 13.4.0 with links to real GitHub issues spanning 2015 to 2026.</description><pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;A follow-up to &lt;a href=&quot;https://joshsalway.com/articles/why-your-laravel-jobs-might-retry-forever-after-an-oom/&quot;&gt;Why Your Laravel Jobs Might Retry Forever After an OOM&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eight things every queued job needs before you ship:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Retry bounds&lt;/strong&gt; -- &lt;code&gt;$tries&lt;/code&gt; (count-based) or &lt;code&gt;retryUntil()&lt;/code&gt; + &lt;code&gt;$maxExceptions&lt;/code&gt; (time-based). Never both. Never platform defaults.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;$timeout&lt;/code&gt;&lt;/strong&gt; shorter than the connection&apos;s &lt;code&gt;retry_after&lt;/code&gt; (default 90s on both database and Redis) to avoid duplicate execution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-zero &lt;code&gt;$backoff&lt;/code&gt;&lt;/strong&gt; so retries pace themselves.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;failed()&lt;/code&gt; method&lt;/strong&gt; so failures aren&apos;t silent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeouts on every HTTP call&lt;/strong&gt; inside the job.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An idempotency guard&lt;/strong&gt; at the top of &lt;code&gt;handle()&lt;/code&gt; if the job writes external state. Queues redeliver.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicit lock expiry&lt;/strong&gt; -- &lt;code&gt;$uniqueFor&lt;/code&gt; on &lt;code&gt;ShouldBeUnique&lt;/code&gt;, &lt;code&gt;-&gt;expireAfter()&lt;/code&gt; on &lt;code&gt;WithoutOverlapping&lt;/code&gt;. &lt;code&gt;0&lt;/code&gt; means &quot;never&quot; in Laravel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On serverless:&lt;/strong&gt; tight &lt;code&gt;queue-timeout&lt;/code&gt; and a hard cost kill switch. Lambda bills wall-clock including idle I/O.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The scaffold gives you none of them. You are responsible for the safety and reliability of your queues.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Using an AI coding agent?&lt;/strong&gt; Review the prompts below and copy them into your tool (Claude Code, Cursor, Windsurf, Copilot, or similar) to audit and apply the spec across your jobs.&lt;/p&gt;
&lt;p&gt;This post documents 23 queue safety findings -- 14 from my audit, 9 from the community. Verified against Laravel Framework v13.4.0 through v13.6.0 with links to real GitHub issues spanning 2015 to 2026.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Any changes you make to your application are your responsibility. Do your own research first.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;How I found these&lt;/h2&gt;
&lt;p&gt;After two Vapor billing incidents ($140 in 2019, $218.90 in 2023), I traced my original application&apos;s git history commit by commit, cross-referenced support emails, and compared &lt;code&gt;Worker.php&lt;/code&gt; across Laravel v10, v12, and v13. My code had gaps, but so did the framework and platform. That led to a broader question: how many other places in the queue system have the same pattern?&lt;/p&gt;
&lt;p&gt;Using Claude Code, I audited the full queue system for unbounded behaviour, counters bypassed by process death, and unsafe defaults.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Packages examined:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;laravel/framework&lt;/strong&gt; (v13.4.0 original audit; spot-checks against v13.6.0): &lt;code&gt;src/Illuminate/Queue/&lt;/code&gt;, &lt;code&gt;src/Illuminate/Bus/&lt;/code&gt;, &lt;code&gt;src/Illuminate/Pipeline/&lt;/code&gt;, &lt;code&gt;src/Illuminate/Console/Scheduling/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;laravel/vapor-core&lt;/strong&gt; (v2.43.3): &lt;code&gt;VaporWorkCommand.php&lt;/code&gt;, &lt;code&gt;QueueHandler.php&lt;/code&gt;, &lt;code&gt;VaporWorker.php&lt;/code&gt;, &lt;code&gt;VaporJob.php&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;laravel/vapor-cli&lt;/strong&gt;, &lt;strong&gt;laravel/cloud-cli&lt;/strong&gt;, &lt;strong&gt;laravel/forge-cli&lt;/strong&gt;, &lt;strong&gt;laravel/forge-sdk&lt;/strong&gt;: checked for comparison&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also compared defaults across Sidekiq, BullMQ, Celery, Google Cloud Tasks, and AWS SQS native to find suitable guardrails and recommendations.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What likely caused my billing incidents&lt;/h2&gt;
&lt;p&gt;After investigating the original application&apos;s git history, support emails, and &lt;code&gt;vapor.yml&lt;/code&gt; at the time of the incidents, the root cause of the August 2023 bill ($218.90 in 7 days) became clear.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This was not a literal infinite retry loop.&lt;/strong&gt; The OOM bug in &lt;a href=&quot;#1-maxexceptions-counter-only-increments-inside-the-catch-block&quot;&gt;Finding #1&lt;/a&gt; is a real framework bug and can produce unbounded retries in its specific trigger conditions, but the 2023 incident is a different mechanism. On Vapor, &lt;code&gt;QueueHandler&lt;/code&gt; invokes &lt;code&gt;vapor:work --tries=$SQS_TRIES ?? 3&lt;/code&gt;, so each problem email got &lt;strong&gt;up to 3 SQS redeliveries, each running to the full 15-minute &lt;code&gt;queue-timeout: 900&lt;/code&gt; ceiling&lt;/strong&gt; because of uncapped &lt;code&gt;file_get_contents()&lt;/code&gt; hanging on slow image origins. That&apos;s up to 45 minutes of billed 2048MB Lambda time per problem message (3 attempts x 900s x 2GB x $0.0000166667/GB-s ~= $0.09 per message). Multiply by roughly 350 problem emails per day over 7 days and the bill arrives at $218.90 without anything retrying forever. Same class of queue-safety failure as Finding #1, different mechanism. I cannot prove the OOM bug caused this specific bill, and on the Lambda pricing math I no longer think it did.&lt;/p&gt;
&lt;h3&gt;What was a skill issue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;I set &lt;code&gt;queue-timeout: 900&lt;/code&gt; in &lt;code&gt;vapor.yml&lt;/code&gt; &quot;to be safe.&quot;&lt;/strong&gt; This was the dominant cost multiplier. Lambda bills wall-clock time including idle I/O waits, and at 2048MB x 900s x $0.0000166667/GB-s each timed-out attempt cost roughly $0.03. With the same mistakes below but a default 60s &lt;code&gt;queue-timeout&lt;/code&gt;, the same incident would have cost around $15 instead of $218. &quot;To be safe&quot; was the thing that wasn&apos;t.&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;file_get_contents()&lt;/code&gt; on arbitrary URLs with no timeout inside a queue job. One slow origin pinned the worker at the Lambda ceiling for the full 15 minutes per attempt.&lt;/li&gt;
&lt;li&gt;I didn&apos;t set &lt;code&gt;$tries&lt;/code&gt; on my job classes. That&apos;s in the docs. On Vapor the effective retry count was &lt;code&gt;SQS_TRIES ?? 3&lt;/code&gt; (bounded, not infinite), but three attempts at $0.03 each times inbound email volume is how the 2023 bill accrued in a week.&lt;/li&gt;
&lt;li&gt;I didn&apos;t set up AWS budget alerts. The spend was invisible until the invoice arrived.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are real mistakes and I own them.&lt;/p&gt;
&lt;h3&gt;What wasn&apos;t a skill issue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The tries configuration has three layers that contradict each other (see &lt;a href=&quot;#4-tries-defaults-to-null-unlimited-in-job-payload&quot;&gt;finding #4&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;There is no global &lt;code&gt;default_tries&lt;/code&gt; config. One missed property on one job class and the behaviour depends on which layer wins.&lt;/li&gt;
&lt;li&gt;Retries are completely silent. No warning, no log entry, no event.&lt;/li&gt;
&lt;li&gt;Support responded to both incidents with &quot;check your AWS invoice&quot; and &quot;set up budget alerts.&quot; Neither response mentioned &lt;code&gt;$tries&lt;/code&gt;, &lt;code&gt;queue-timeout&lt;/code&gt;, retry limits, or the Vapor tries default. Even a basic triage question in 2019 (&quot;what is your &lt;code&gt;queue-timeout&lt;/code&gt;, do your jobs have &lt;code&gt;$tries&lt;/code&gt; and &lt;code&gt;$timeout&lt;/code&gt; set, do your HTTP calls have timeouts?&quot;) would have turned the 2023 incident into a background-noise bill instead of an alarm-bell one.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Whose fault is it?&lt;/h3&gt;
&lt;p&gt;In the &lt;a href=&quot;https://joshsalway.com/articles/why-your-laravel-jobs-might-retry-forever-after-an-oom/&quot;&gt;previous post&lt;/a&gt; I wrote that the framework, hosting platform, and application code should all have seatbelts in place to reduce the likelihood of infinite retries in serverless environments. All three failed:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mine:&lt;/strong&gt; A 15-minute &lt;code&gt;queue-timeout&lt;/code&gt; (the dominant cost multiplier on the evidence), unbounded HTTP calls inside jobs, and jobs without &lt;code&gt;$tries&lt;/code&gt;. In that order of impact.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The framework:&lt;/strong&gt; Unsafe defaults -- &lt;code&gt;make:job&lt;/code&gt; stub is bare, &lt;code&gt;$tries&lt;/code&gt; null (see &lt;a href=&quot;#4-tries-defaults-to-null-unlimited-in-job-payload&quot;&gt;#4&lt;/a&gt;), backoff 0 (see &lt;a href=&quot;#2-default-backoff-is-0-seconds&quot;&gt;#2&lt;/a&gt;), &lt;code&gt;WithoutOverlapping&lt;/code&gt; lock infinite (see &lt;a href=&quot;#3-withoutoverlapping-middleware-defaults-to-an-infinite-lock&quot;&gt;#3&lt;/a&gt;), &lt;code&gt;maxExceptions&lt;/code&gt; bypassed by OOM (see &lt;a href=&quot;#1-maxexceptions-counter-only-increments-inside-the-catch-block&quot;&gt;#1&lt;/a&gt;) -- meant my mistakes had no guardrails at the layer I actually wrote.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The platform (Vapor):&lt;/strong&gt; Three layers of tries configuration that contradict each other, none clearly documented (see &lt;a href=&quot;#4-tries-defaults-to-null-unlimited-in-job-payload&quot;&gt;#4&lt;/a&gt;). Support didn&apos;t flag the root cause in either incident.&lt;/p&gt;
&lt;p&gt;The git log from August 20, 2023 (private repository) tells the story: 11 commits in a single day, reverts of reverts, and the first &lt;code&gt;$tries = 5&lt;/code&gt; added mid-crisis. The fix that actually turned off the money hose was the &lt;code&gt;file_get_contents&lt;/code&gt; HTTP timeout added the next day (commit &lt;code&gt;e6f7cb5&lt;/code&gt;, Aug 21), which stopped attempts from hanging to the Lambda ceiling; that change alone dropped per-attempt cost by roughly 30x. The &lt;code&gt;$tries = 5&lt;/code&gt; + idempotency guard + &lt;code&gt;queue-timeout&lt;/code&gt; reduction landed the night before were supporting fixes, not the dominant one. No single layer caused this alone. My bad code, combined with unsafe framework defaults, on a platform with inconsistent and undocumented tries configuration, turned a coding mistake into $358.90 across two separate incidents four years apart.&lt;/p&gt;
&lt;h3&gt;Lambda is for short bursts, not long-running work&lt;/h3&gt;
&lt;p&gt;The same code, same bugs, same SQS redeliveries would have cost very different amounts on different platforms. The 2023 mechanism was 3 attempts x up to 15 minutes x 2GB x pay-per-millisecond billing. Run the same scenario on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Non-serverless environments&lt;/strong&gt; (Forge, Ploi, Laravel Cloud, DigitalOcean droplet): $0 incremental. The worker was already paid for. You&apos;d see a backed-up queue, a slow server, and possibly dropped jobs. No excessive bill.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Laravel Cloud&lt;/strong&gt; with a capped queue worker: bounded to the worker&apos;s provisioned compute regardless of retry behaviour. Slow queue, predictable ceiling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt;: CPU-time-gated (10 seconds on free, configurable up to 5 minutes on paid). Tasks are tied to the HTTP request lifecycle -- client disconnect cancels them with a 30-second &lt;code&gt;waitUntil&lt;/code&gt; grace. Pay-per-request plus CPU time keeps costs predictable. Caveat: a hung &lt;code&gt;fetch()&lt;/code&gt; does not count against CPU time, so slow-origin I/O can still wait longer than the CPU cap implies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel Functions&lt;/strong&gt;: 10 seconds default on Hobby, 60 seconds on Pro, configurable up to 800 seconds on Pro with Fluid Compute. The low default is the safety net; raising &lt;code&gt;maxDuration&lt;/code&gt; near 800s recreates the Lambda shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lambda with a default 60 second &lt;code&gt;queue-timeout&lt;/code&gt;&lt;/strong&gt; (same Vapor, different config): about $15 instead of $218 on the same code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This shape of problem isn&apos;t framework-specific; the same unsafe patterns cause problems on every platform. The lesson is that &lt;strong&gt;Lambda&apos;s pricing model rewards short bursts and punishes long-running work&lt;/strong&gt;. A 15-minute &lt;code&gt;queue-timeout&lt;/code&gt; inverts Lambda&apos;s value proposition: per-millisecond billing is great for the 30-second happy path and catastrophic on the 15-minute unhappy path.&lt;/p&gt;
&lt;p&gt;For background work that&apos;s inherently quick -- parsing an inbound email, saving an attachment to storage, rendering a small PDF, calling an API -- &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; or &lt;strong&gt;Vercel Functions&lt;/strong&gt; give you low default timeout caps and pay-per-request billing that keeps costs predictable. The low default is the safety net; raise the cap and you give it up. For longer or memory-heavy tasks, non-serverless environments (Forge, Ploi, Laravel Cloud) absorb the mistake class entirely: bugs produce slow queues, not excessive bills. Lambda via Vapor is best reserved for bursty request-response work, not for queues where one hung HTTP call means minutes of billed idle time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Do we actually need seatbelts or guardrails?&lt;/h2&gt;
&lt;p&gt;Nobody plans to have a car accident. You don&apos;t put on a seatbelt because you expect to crash. You put it on because if something goes wrong, the seatbelt is the difference between a bad day and a catastrophic one.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Fun fact:&lt;/strong&gt; When Volvo invented the three-point seatbelt in 1959, they made the patent available to every car manufacturer for free because they believed safety should be a shared standard, not a competitive advantage. It still took decades for seatbelts to become mandatory worldwide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Queue safety is the same. Most jobs work fine. Most deployments don&apos;t have runaway billing. Most developers go years without a serious queue incident. But when one hits, the difference between a job with the eight properties set and a job without them is the difference between a failed job in your &lt;code&gt;failed_jobs&lt;/code&gt; table and a surprise bill on the invoice.&lt;/p&gt;
&lt;p&gt;Guardrails on a road don&apos;t slow you down. They&apos;re invisible until the moment you need them. &lt;code&gt;$tries&lt;/code&gt;, &lt;code&gt;$backoff&lt;/code&gt;, &lt;code&gt;$timeout&lt;/code&gt;, and &lt;code&gt;failed()&lt;/code&gt; are the same. They cost nothing in normal operation. They save you when something unexpected happens: an API goes down, a payload is larger than expected, a memory limit is hit, a deploy goes wrong.&lt;/p&gt;
&lt;p&gt;On serverless platforms, a runaway job shows up as a bill. In non-serverless environments (Forge, Ploi, Laravel Cloud, etc.), there&apos;s no excessive bill to trigger an investigation. The signal is quieter: a job retrying in a tight loop consumes CPU and memory, your server gets slower, other jobs back up, and some jobs may be dropped. Without monitoring or logging, you might never know it&apos;s happening. Queue safety is a shared discipline across every platform. Serverless just surfaces the cost quickly in dollars, while non-serverless hides it in slower throughput and dropped work.&lt;/p&gt;
&lt;p&gt;If you&apos;ve never had a queue incident, that&apos;s great. Add the guardrails anyway. They&apos;re free insurance. Regardless of the safety angle, these recommendations will likely make your queue jobs more reliable. And if you already knew all of this, pat yourself on the back.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Recommendations you can do today&lt;/h2&gt;
&lt;p&gt;Even after writing this audit, I checked my own applications and found jobs without &lt;code&gt;$tries&lt;/code&gt; set -- including the exact job that caused my billing incident. That&apos;s how easy this is to miss. Safe defaults would catch it for everyone.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;

class ProcessEmailJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $maxExceptions = 0;
    public $backoff = [30, 60, 300];
    public $timeout = 120;

    public function retryUntil()
    {
        return now()-&gt;addHours(2);
    }

    public function failed(Throwable $exception)
    {
        // Log, notify, or alert. Don&apos;t let failures be silent.
        Log::error(&apos;ProcessEmailJob permanently failed&apos;, [
            &apos;exception&apos; =&gt; $exception-&gt;getMessage(),
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pick one retry policy, not both.&lt;/strong&gt; Laravel&apos;s Worker treats &lt;code&gt;$tries&lt;/code&gt; and &lt;code&gt;retryUntil()&lt;/code&gt; as mutually exclusive: if &lt;code&gt;retryUntil()&lt;/code&gt; returns a timestamp, &lt;code&gt;$tries&lt;/code&gt; is ignored completely (&lt;a href=&quot;https://github.com/laravel/framework/blob/13.x/src/Illuminate/Queue/Worker.php#L612&quot;&gt;Worker.php line 612 on 13.x&lt;/a&gt;, behaviour introduced in &lt;a href=&quot;https://github.com/laravel/framework/pull/35214&quot;&gt;framework PR #35214&lt;/a&gt;, Nov 2020). So pick one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Count-based&lt;/strong&gt; -- &lt;code&gt;$tries&lt;/code&gt; + &lt;code&gt;$backoff&lt;/code&gt; + &lt;code&gt;$maxExceptions&lt;/code&gt;, no &lt;code&gt;retryUntil()&lt;/code&gt;. Bounds by attempt count.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time-based&lt;/strong&gt; -- &lt;code&gt;retryUntil()&lt;/code&gt; + &lt;code&gt;$maxExceptions&lt;/code&gt;, no &lt;code&gt;$tries&lt;/code&gt;. Bounds by wall-clock. Taylor&apos;s own guidance on &lt;a href=&quot;https://github.com/laravel/framework/issues/35199&quot;&gt;#35199&lt;/a&gt;: &lt;em&gt;&quot;When using &lt;code&gt;retryUntil&lt;/code&gt; I would use &lt;code&gt;maxExceptions&lt;/code&gt; if you want to determine how many uncaught exceptions are allowed.&quot;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The example above sets both so you can see the properties in one place, but in production use one or the other. See Finding #11 for the source-level proof.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why each property matters:&lt;/strong&gt; five properties, set explicitly on every queued job. The caveats under each item explain scope and edge cases, they don&apos;t weaken the rule.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;$tries = 3&lt;/code&gt; -- hard cap on total attempts (count-based policy). Don&apos;t rely on platform defaults, set this explicitly on every job. Use 3 if multiple tries are useful and don&apos;t cause side effects, or 1 if you want to be strict and only run once. Don&apos;t use &lt;code&gt;$tries = 0&lt;/code&gt;: in Laravel that means infinite retries, not zero. Use a positive integer. Ignored when &lt;code&gt;retryUntil()&lt;/code&gt; is set. &lt;code&gt;$tries&lt;/code&gt; on the job class wins over every worker-level default, which matters because those defaults differ by platform (&lt;code&gt;queue:work&lt;/code&gt; uses 1, &lt;code&gt;vapor:work&lt;/code&gt; uses 0 but is runtime-overridden to &lt;code&gt;SQS_TRIES ?? 3&lt;/code&gt;). See &lt;a href=&quot;#4-tries-defaults-to-null-unlimited-in-job-payload&quot;&gt;Finding #4&lt;/a&gt; for the three-layer diagnosis.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$maxExceptions = 0&lt;/code&gt; -- fails the job on the first catchable exception. See note below. Works with either policy.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$backoff = [30, 60, 300]&lt;/code&gt; -- exponential delays between retries. Without this, retries are immediate (0 seconds). Applies when exceptions propagate to the worker&apos;s &lt;code&gt;handleJobException&lt;/code&gt;. Middleware that catches and releases internally (&lt;code&gt;ThrottlesExceptions&lt;/code&gt;, &lt;code&gt;RateLimited&lt;/code&gt;) bypasses &lt;code&gt;$backoff&lt;/code&gt; and uses its own delay parameter; chain &lt;code&gt;-&gt;backoff($minutes)&lt;/code&gt; on the middleware in that case. See Finding #8 for the source-level proof.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;$maxExceptions = 0&lt;/code&gt;?&lt;/strong&gt; This is deliberately aggressive because of finding #1: the &lt;code&gt;maxExceptions&lt;/code&gt; counter never increments during OOM, so any value above 0 allows infinite retries when OOM and catchable exceptions alternate. Setting it to 0 means the first catchable exception stops the loop. Retry tolerance is handled by &lt;code&gt;$tries&lt;/code&gt; and &lt;code&gt;$backoff&lt;/code&gt; instead, which work across process restarts.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;$timeout = 120&lt;/code&gt; -- kills the job after 2 minutes. Without this, jobs can run until Lambda&apos;s 15-minute ceiling. &lt;code&gt;$timeout&lt;/code&gt; must be shorter than the connection&apos;s &lt;code&gt;retry_after&lt;/code&gt; config value (default &lt;code&gt;90&lt;/code&gt; on both database and Redis); if &lt;code&gt;$timeout&lt;/code&gt; exceeds &lt;code&gt;retry_after&lt;/code&gt;, the same job can be picked up and executed twice. See Finding #10 for the duplicate-execution scenario.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;retryUntil()&lt;/code&gt; -- time-based circuit breaker (time-based policy). Negates &lt;code&gt;$tries&lt;/code&gt; completely when set. Use alongside &lt;code&gt;$maxExceptions&lt;/code&gt;, not &lt;code&gt;$tries&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;failed()&lt;/code&gt; -- get notified when a job permanently fails. Silent failures are what cause $200 bills.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;On WithoutOverlapping middleware&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;public function middleware()
{
    return [
        (new WithoutOverlapping($this-&gt;key))
            -&gt;expireAfter(minutes: 30)
            -&gt;releaseAfter(seconds: 30),
    ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always set &lt;code&gt;expireAfter&lt;/code&gt;. The default is 0 (never expires). If your worker crashes while holding the lock, the job is permanently blocked without this.&lt;/p&gt;
&lt;h3&gt;On ShouldBeUnique jobs&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class ImportDataJob implements ShouldQueue, ShouldBeUnique
{
    public $uniqueFor = 3600; // seconds
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always set &lt;code&gt;$uniqueFor&lt;/code&gt;. The default is 0, which means the lock never expires (same pattern as WithoutOverlapping). On a normal exception, &lt;code&gt;CallQueuedHandler::failed()&lt;/code&gt; releases the lock via &lt;code&gt;ensureUniqueJobLockIsReleased()&lt;/code&gt; (&lt;a href=&quot;https://github.com/laravel/framework/blob/13.x/src/Illuminate/Queue/CallQueuedHandler.php#L334&quot;&gt;source&lt;/a&gt;). The leak case is when &lt;code&gt;failed()&lt;/code&gt; never runs: the worker is terminated mid-&lt;code&gt;handle()&lt;/code&gt; (OOM, SIGKILL, container eviction), or the &lt;code&gt;$deleteWhenMissingModels = true&lt;/code&gt; + missing-model path from &lt;a href=&quot;https://github.com/laravel/framework/issues/49890&quot;&gt;Issue #49890&lt;/a&gt; reported by @naquad (closed as &quot;no-fix for us&quot;). In those cases the lock persists until cache TTL, blocking all future dispatches of that unique key.&lt;/p&gt;
&lt;p&gt;If you want the lock released as soon as the job starts processing rather than when it completes or fails, implement &lt;code&gt;ShouldBeUniqueUntilProcessing&lt;/code&gt; instead. Narrower window, less leak risk, but allows the job to run concurrently with another dispatch of the same unique key after processing begins.&lt;/p&gt;
&lt;p&gt;Laravel 13.6.0 introduced &lt;code&gt;#[DebounceFor]&lt;/code&gt; as an attribute-driven alternative (&lt;a href=&quot;https://github.com/laravel/framework/pull/59507&quot;&gt;PR #59507&lt;/a&gt;, merged April 2026). It uses last-writer-wins cache-token semantics over a debounce window, no interface required, and fires a &lt;code&gt;JobDebounced&lt;/code&gt; event when a dispatch is superseded. Use &lt;code&gt;#[DebounceFor]&lt;/code&gt; when you want to deduplicate a burst of dispatches and run only the last one; use &lt;code&gt;ShouldBeUnique&lt;/code&gt; when you want the first to run and the rest to be silently dropped while the lock is held.&lt;/p&gt;
&lt;h3&gt;Prune failed jobs&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// In app/Console/Kernel.php or routes/console.php
Schedule::command(&apos;queue:prune-failed --hours=168&apos;)-&gt;daily();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;failed_jobs&lt;/code&gt; table grows unbounded. At 300k+ records, &lt;code&gt;queue:retry --all&lt;/code&gt; will OOM (&lt;a href=&quot;https://github.com/laravel/framework/issues/49185&quot;&gt;Issue #49185&lt;/a&gt; reported by @arharp). Prune weekly.&lt;/p&gt;
&lt;h3&gt;On external HTTP calls inside jobs&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// Bad: no timeout, no size limit
$content = file_get_contents($url);

// Good: bounded timeout, exception on failure
$response = Http::timeout(10)-&gt;get($url);
$response-&gt;throw();
$content = $response-&gt;body();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every external call inside a queue job should have a timeout. One slow or unresponsive endpoint can hold a Lambda invocation running for minutes.&lt;/p&gt;
&lt;h3&gt;Hard limits&lt;/h3&gt;
&lt;p&gt;These catch problems regardless of whether individual jobs are configured correctly, ordered by leverage:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;queue-memory&lt;/code&gt; and &lt;code&gt;queue-timeout&lt;/code&gt; in &lt;code&gt;vapor.yml&lt;/code&gt;&lt;/strong&gt; -- these set your per-attempt cost ceiling on Lambda: &lt;code&gt;memory-gb x timeout-seconds x $0.0000166667/GB-s&lt;/code&gt;. At 2048MB and &lt;code&gt;queue-timeout: 900&lt;/code&gt; that is roughly $0.03 per stuck attempt, multiplied by your retry count and your volume. Keep both as low as your jobs actually need. If you take one thing from this section, take this: the dominant cost control on serverless queues is how long each failed attempt is allowed to burn, not how many retries you allow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;queue:work --tries=3&lt;/code&gt;&lt;/strong&gt; -- set this on your worker command or supervisor config. Acts as a floor even if a job class forgets &lt;code&gt;$tries&lt;/code&gt;. On Vapor, set &lt;code&gt;SQS_TRIES=3&lt;/code&gt; in your environment. If you run an SQS dead letter queue with &lt;code&gt;maxReceiveCount&lt;/code&gt; as your retry ceiling, set &lt;code&gt;SQS_TRIES=0&lt;/code&gt; instead so Laravel releases the message back and the DLQ handles the cap at the infrastructure layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQS dead letter queue&lt;/strong&gt; -- configure a redrive policy with &lt;code&gt;maxReceiveCount&lt;/code&gt; in AWS. After N receive attempts, SQS moves the message to a DLQ automatically, even if your application crashes. This is your infrastructure-level circuit breaker and works independently of Laravel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ideally, a cost-based kill switch&lt;/strong&gt; -- a soft limit sends you an email when spend hits a threshold. A hard limit actually stops the workload. Vapor and most serverless platforms only offer soft limits today. A hard limit that pauses queue processing when spend exceeds a configurable amount (e.g. $30/month) would have prevented both of my incidents entirely. The alert told me the house was on fire. A hard limit would have put it out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS budget alerts&lt;/strong&gt; -- won&apos;t stop the spend, but tells you early. Set via the Vapor UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor queue depth&lt;/strong&gt; -- as of Laravel 13.4.0, &lt;code&gt;Queue::pendingJobs()&lt;/code&gt;, &lt;code&gt;Queue::delayedJobs()&lt;/code&gt;, and &lt;code&gt;Queue::reservedJobs()&lt;/code&gt; (&lt;a href=&quot;https://github.com/laravel/framework/pull/59511&quot;&gt;PR #59511&lt;/a&gt;) let you inspect queue state natively. On AWS, you can also use CloudWatch alarms on &lt;code&gt;ApproximateNumberOfMessagesVisible&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lambda concurrency limits&lt;/strong&gt; -- set reserved concurrency on your queue Lambda to cap how many concurrent invocations can run. Limits the burn rate during a runaway.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;A base job class&lt;/h3&gt;
&lt;p&gt;If you want to apply safe defaults across all your jobs without repeating yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;

abstract class SafeJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $maxExceptions = 0;
    public $backoff = [30, 60, 300];
    public $timeout = 120;

    public function failed(Throwable $exception)
    {
        Log::error(static::class . &apos; permanently failed&apos;, [
            &apos;exception&apos; =&gt; $exception-&gt;getMessage(),
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then extend it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class ProcessEmailJob extends SafeJob
{
    // Inherits all safe defaults
    // Override any property if this job needs different limits
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Findings&lt;/h2&gt;
&lt;h3&gt;Critical: Unbounded retries and permanent resource locks&lt;/h3&gt;
&lt;p&gt;These findings can cause infinite retry loops, permanent job lockout, or runaway costs on serverless platforms. They are the highest priority for anyone running queues in production.&lt;/p&gt;
&lt;h4&gt;1. maxExceptions counter only increments inside the catch block&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;process()&lt;/code&gt; and &lt;code&gt;markJobAsFailedIfWillExceedMaxExceptions()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This is the bug documented in my &lt;a href=&quot;https://joshsalway.com/articles/why-your-laravel-jobs-might-retry-forever-after-an-oom/&quot;&gt;previous blog post&lt;/a&gt;. The &lt;code&gt;maxExceptions&lt;/code&gt; counter is incremented inside &lt;code&gt;handleJobException()&lt;/code&gt;, which is called from the &lt;code&gt;catch (Throwable)&lt;/code&gt; block. When the process is killed by an out-of-memory error, the catch block never executes. The counter is never incremented. The job retries with a counter of zero indefinitely.&lt;/p&gt;
&lt;p&gt;There is a pre-fire check for &lt;code&gt;maxTries&lt;/code&gt; via &lt;code&gt;markJobAsFailedIfAlreadyExceedsMaxAttempts&lt;/code&gt;, but no equivalent pre-fire check for &lt;code&gt;maxExceptions&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;Issue #58207&lt;/a&gt; (Dec 2025) reported by &lt;a href=&quot;https://github.com/pingencom&quot;&gt;@pingencom&lt;/a&gt; -- 31 comments from production users independently building workarounds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Increment the exception counter before &lt;code&gt;fire()&lt;/code&gt;, decrement after successful completion. If the worker dies during &lt;code&gt;fire()&lt;/code&gt;, the increment persists. On the next pickup, a pre-fire check can fail the job if the counter meets the threshold.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;2. Default backoff is 0 seconds&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;calculateBackoff()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When a job fails and has no &lt;code&gt;backoff&lt;/code&gt; property, the default is 0 seconds. The job is immediately re-queued. Combined with a high or unlimited retry count, this creates a tight failure loop where the same job fails and retries as fast as the worker can process it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/44680&quot;&gt;Issue #44680&lt;/a&gt; (Oct 2022) reported by &lt;a href=&quot;https://github.com/hjeldin&quot;&gt;@hjeldin&lt;/a&gt; -- backoff ignored after timeout kill, job retries immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Default backoff to a small positive value (e.g. 3 seconds) instead of 0.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;3. WithoutOverlapping middleware defaults to an infinite lock&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Middleware/WithoutOverlapping.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;$expiresAfter&lt;/code&gt; is unset by default (null). When passed to &lt;code&gt;Cache::lock()&lt;/code&gt; with no TTL, most drivers (Redis, Database) produce a lock that never expires. If a job using this middleware is killed (OOM, SIGKILL, server crash), the lock is never released. All future instances of that job are permanently blocked, either released back to the queue indefinitely or silently dropped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/37060&quot;&gt;Issue #37060&lt;/a&gt; (Apr 2021) reported by &lt;a href=&quot;https://github.com/lasselehtinen&quot;&gt;@lasselehtinen&lt;/a&gt; -- lock not released on failed jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Default &lt;code&gt;$expiresAfter&lt;/code&gt; to a reasonable value (e.g. 3600 seconds) instead of leaving it unset.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;4. $tries defaults to null (unlimited) in job payload&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Files:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Queue.php&lt;/code&gt; - &lt;code&gt;getJobTries()&lt;/code&gt;, &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;markJobAsFailedIfAlreadyExceedsMaxAttempts()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When a job class does not define a &lt;code&gt;$tries&lt;/code&gt; property, the payload contains &lt;code&gt;maxTries: null&lt;/code&gt;. &lt;strong&gt;Null does not itself mean unlimited&lt;/strong&gt;: &lt;a href=&quot;https://github.com/laravel/framework/blob/13.x/src/Illuminate/Queue/Worker.php#L578&quot;&gt;&lt;code&gt;Worker.php&lt;/code&gt; line 578&lt;/a&gt; replaces null with the worker&apos;s &lt;code&gt;--tries&lt;/code&gt; argument before the unlimited-if-zero check at line 586. So null delegates to the worker layer, and unlimited only happens when that layer also resolves to &lt;code&gt;0&lt;/code&gt;. The &lt;code&gt;queue:work&lt;/code&gt; command defaults to &lt;code&gt;--tries=1&lt;/code&gt; (safe). On Laravel Vapor, &lt;code&gt;VaporWorkCommand&lt;/code&gt; defines &lt;code&gt;--tries=0&lt;/code&gt; (unlimited) in its command signature, but the &lt;code&gt;QueueHandler&lt;/code&gt; runtime that invokes it passes &lt;code&gt;$_ENV[&apos;SQS_TRIES&apos;] ?? 3&lt;/code&gt;, so in practice the default is 3 unless explicitly overridden. This inconsistency between the command definition and the runtime invocation is confusing and not documented.&lt;/p&gt;
&lt;p&gt;The interaction between job-level, command-level, and runtime-level tries configuration is complex. A global default in &lt;code&gt;config/queue.php&lt;/code&gt; would provide a single, visible safety net.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/pull/29385&quot;&gt;PR #29385&lt;/a&gt; (Aug 2019) by &lt;a href=&quot;https://github.com/SjorsO&quot;&gt;@SjorsO&lt;/a&gt; -- changed the &lt;code&gt;queue:work&lt;/code&gt; default from 0 to 1 in Laravel 6.0. The PR states: &quot;Changing the default solves the problem of broken jobs getting stuck in an infinite loop when you forget to pass the queue worker a --tries flag.&quot; This partially addressed the issue but didn&apos;t unify the other layers: job-level &lt;code&gt;$tries&lt;/code&gt; still defaults to null, &lt;code&gt;VaporWorkCommand&lt;/code&gt; still defines &lt;code&gt;--tries=0&lt;/code&gt; in its signature, and &lt;code&gt;SQS_TRIES&lt;/code&gt; is a separate runtime concern. &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;Issue #58207&lt;/a&gt; (Dec 2025) reported by &lt;a href=&quot;https://github.com/pingencom&quot;&gt;@pingencom&lt;/a&gt; -- jobs retried endlessly with $tries=0. &lt;a href=&quot;https://github.com/laravel/framework/pull/59718&quot;&gt;PR #59718&lt;/a&gt; (Apr 2026, merged in Laravel 13.6.0) -- a developer hit the TINYINT 255-attempts limit on a unique job retrying every minute for a full day; the column was widened to SMALLINT. The schema fix addresses the symptom; the underlying gap (unbounded retries running to the column&apos;s range) remains.&lt;/p&gt;
&lt;p&gt;The real gap is the scaffold. &lt;code&gt;php artisan make:job&lt;/code&gt; generates from &lt;code&gt;job.queued.stub&lt;/code&gt;. That template has been touched nine times since 2020 -- formatting, imports, PHP type declarations, &lt;code&gt;ShouldBeUnique&lt;/code&gt; added and removed. Never &lt;code&gt;$tries&lt;/code&gt;. Never &lt;code&gt;$backoff&lt;/code&gt;. Never &lt;code&gt;$timeout&lt;/code&gt;. Never &lt;code&gt;failed()&lt;/code&gt;. Every queued job generated in every new Laravel app since 2020 ships with nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Add a &lt;code&gt;default_tries&lt;/code&gt; option to &lt;code&gt;config/queue.php&lt;/code&gt; that applies when neither the job class nor the command line specifies a value. And update &lt;code&gt;job.queued.stub&lt;/code&gt; to include &lt;code&gt;$tries&lt;/code&gt;, &lt;code&gt;$backoff&lt;/code&gt;, and &lt;code&gt;$timeout&lt;/code&gt; -- commented out if you want, just so the developer sees them.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;5. Race condition in maxExceptions counter initialization&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;markJobAsFailedIfWillExceedMaxExceptions()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The counter initialization uses a non-atomic &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;put&lt;/code&gt; sequence. When multiple workers process retries of the same job UUID simultaneously, both workers can see a missing key and reset the counter, causing the exception count to be lost. An atomic &lt;code&gt;Cache::add()&lt;/code&gt; call would prevent this race condition.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; Silent bug -- users see the symptom (jobs retrying forever) without understanding the cause. Compounds &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;Issue #58207&lt;/a&gt; (Dec 2025) reported by &lt;a href=&quot;https://github.com/pingencom&quot;&gt;@pingencom&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Replace the &lt;code&gt;Cache::get()&lt;/code&gt; / &lt;code&gt;Cache::put()&lt;/code&gt; initialization sequence with &lt;code&gt;Cache::add()&lt;/code&gt;, which only sets the key if it doesn&apos;t already exist. The subsequent &lt;code&gt;Cache::increment()&lt;/code&gt; is already atomic.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;6. handleJobException releases when failure checks are bypassed&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;handleJobException()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The finally block in &lt;code&gt;handleJobException&lt;/code&gt; releases the job back to the queue if it hasn&apos;t been deleted, released, or marked as failed. These three guards are correct and work well when &lt;code&gt;maxTries&lt;/code&gt; or &lt;code&gt;maxExceptions&lt;/code&gt; properly mark the job as failed. However, when those checks are bypassed (because maxTries is 0 or maxExceptions fails to increment due to OOM), the job is never marked as failed, and the release always proceeds. This amplifies the unlimited retry issue in those specific scenarios.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/pull/45876&quot;&gt;PR #45876&lt;/a&gt; (Jan 2023) by &lt;a href=&quot;https://github.com/khepin&quot;&gt;@khepin&lt;/a&gt;, closed without merging -- &quot;jobs that fail because of high memory usage all stay on the queue and accumulate there. If enough of them have accumulated, the workers keep spinning on jobs that can never go through.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; Resolving finding #1 (pre-fire maxExceptions check) would address this for jobs with &lt;code&gt;maxExceptions&lt;/code&gt; set. For the broader case, consider logging a warning when a job is released back to the queue with a high attempt count and no &lt;code&gt;maxTries&lt;/code&gt; or &lt;code&gt;maxExceptions&lt;/code&gt; configured. This wouldn&apos;t change behaviour but would make the problem visible instead of silent.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Important: Unsafe defaults and resource exhaustion risks&lt;/h3&gt;
&lt;p&gt;These findings involve default values or missing limits that could cause problems under load, after outages, or with specific configuration combinations. They are less likely to cause immediate damage but represent gaps that production systems can hit.&lt;/p&gt;
&lt;h4&gt;7. Redis migrationBatchSize defaults to unlimited&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/RedisQueue.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;migrationBatchSize&lt;/code&gt; defaults to -1, which means unlimited. The Lua script that migrates expired delayed and reserved jobs fetches all of them in a single call. If a large number of delayed jobs expire simultaneously (for example, after an outage or server restart), this single Lua operation can block Redis for all clients, consume significant memory in the Lua execution context, and potentially trigger the &lt;code&gt;lua-time-limit&lt;/code&gt; threshold.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/pull/43310&quot;&gt;PR #43310&lt;/a&gt; (Jul 2022) by &lt;a href=&quot;https://github.com/AbiriAmir&quot;&gt;@AbiriAmir&lt;/a&gt; -- &quot;scheduling a large number of jobs for a specific time causes Redis to halt since migrate script is a heavy script.&quot; This PR added the &lt;code&gt;migrationBatchSize&lt;/code&gt; config but defaulted it to -1 (unlimited).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Default &lt;code&gt;migrationBatchSize&lt;/code&gt; to a bounded value (e.g. 1000).&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;8. ThrottlesExceptions retryAfterMinutes defaults to 0&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Middleware/ThrottlesExceptions.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When an exception triggers the throttle, the job is released with a delay of &lt;code&gt;retryAfterMinutes * 60&lt;/code&gt;. The default for &lt;code&gt;retryAfterMinutes&lt;/code&gt; is 0, meaning the job is immediately re-queued after an exception. Combined with a high retry count, this creates a tight failure loop similar to the 0-second backoff issue.&lt;/p&gt;
&lt;p&gt;The middleware&apos;s &lt;code&gt;handle()&lt;/code&gt; method catches the exception and calls &lt;code&gt;$job-&gt;release($this-&gt;retryAfterMinutes * 60)&lt;/code&gt; directly, then returns. The exception never propagates out, so the worker&apos;s &lt;code&gt;handleJobException&lt;/code&gt; never runs and &lt;code&gt;calculateBackoff()&lt;/code&gt; never consults the job&apos;s &lt;code&gt;$backoff&lt;/code&gt; property. A job-level &lt;code&gt;$backoff&lt;/code&gt; is ignored on this path. Setting &lt;code&gt;-&gt;backoff($minutes)&lt;/code&gt; on the middleware construction is the only way to pace these retries (e.g. &lt;code&gt;(new ThrottlesExceptions(1, 600))-&gt;backoff(10)&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/36637&quot;&gt;Issue #36637&lt;/a&gt; (Mar 2021) reported by &lt;a href=&quot;https://github.com/tairau&quot;&gt;@tairau&lt;/a&gt; -- backoff docblock says seconds but value is used as minutes. &lt;a href=&quot;https://github.com/laravel/framework/issues/56087&quot;&gt;Issue #56087&lt;/a&gt; (Jun 2025) reported by &lt;a href=&quot;https://github.com/michaeldzjap&quot;&gt;@michaeldzjap&lt;/a&gt; -- ThrottlesExceptions overrides FailOnException, causing jobs to retry despite being told to fail.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Default &lt;code&gt;retryAfterMinutes&lt;/code&gt; to a small positive value (e.g. 5).&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;9. RateLimited middleware release loop&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Middleware/RateLimited.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When a job is rate-limited, it is released back to the queue. Each release counts as an attempt. In high-concurrency environments with many workers competing for the same rate limit, a job can be released and re-attempted many times without ever executing its actual logic. If the job has a high or unlimited retry count, this consumes worker capacity without doing useful work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/53157&quot;&gt;Issue #53157&lt;/a&gt; (Oct 2024) reported by &lt;a href=&quot;https://github.com/amir9480&quot;&gt;@amir9480&lt;/a&gt; -- &lt;code&gt;RateLimiter&lt;/code&gt; &lt;code&gt;perSecond&lt;/code&gt; not working as expected for queue jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; Consider not counting rate-limited releases as attempts, or providing an option to distinguish between &quot;failed&quot; and &quot;deferred&quot; releases.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;10. Database queue reserved-but-expired can cause duplicate execution&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/DatabaseQueue.php&lt;/code&gt; - &lt;code&gt;getNextAvailableJob()&lt;/code&gt; + &lt;code&gt;isReservedButExpired()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Jobs where &lt;code&gt;reserved_at&lt;/code&gt; is older than &lt;code&gt;retry_after&lt;/code&gt; seconds are treated as available. The default &lt;code&gt;retry_after&lt;/code&gt; is 90 seconds. If a job legitimately takes longer than 90 seconds to process, another worker can pick it up while it is still running. The same job executes concurrently in two workers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/8577&quot;&gt;Issue #8577&lt;/a&gt; (Apr 2015) reported by &lt;a href=&quot;https://github.com/m4tthumphrey&quot;&gt;@m4tthumphrey&lt;/a&gt; -- multiple Redis workers picking up the same job. &lt;a href=&quot;https://github.com/laravel/framework/issues/7046&quot;&gt;Issue #7046&lt;/a&gt; (Jan 2015) reported by &lt;a href=&quot;https://github.com/easmith&quot;&gt;@easmith&lt;/a&gt; -- database queue deadlocks from concurrent execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; Document this interaction clearly and consider a longer default &lt;code&gt;retry_after&lt;/code&gt;, or add a mechanism for long-running jobs to extend their reservation.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;11. retryUntil can override maxTries&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;markJobAsFailedIfAlreadyExceedsMaxAttempts()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;retryUntil()&lt;/code&gt; returns a future timestamp, the &lt;code&gt;maxTries&lt;/code&gt; check is skipped entirely. A job with &lt;code&gt;retryUntil()&lt;/code&gt; returning a far-future date (e.g. one year) will retry for the entire window regardless of how many times it has failed. Combined with a 0-second backoff, this is a sustained failure loop for the duration of the window.&lt;/p&gt;
&lt;p&gt;I tested this. Job with &lt;code&gt;retryUntil(10s)&lt;/code&gt; and no &lt;code&gt;$tries&lt;/code&gt;, run against &lt;code&gt;queue:work --tries=1&lt;/code&gt;: 275 retries in 3 seconds. The worker&apos;s &lt;code&gt;--tries&lt;/code&gt; flag is ignored when &lt;code&gt;retryUntil()&lt;/code&gt; is set. Setting job-level &lt;code&gt;$tries&lt;/code&gt; alongside &lt;code&gt;retryUntil()&lt;/code&gt; does not help either: the &lt;code&gt;!$job-&gt;retryUntil()&lt;/code&gt; guard in &lt;a href=&quot;https://github.com/laravel/framework/blob/13.x/src/Illuminate/Queue/Worker.php#L612&quot;&gt;&lt;code&gt;Worker.php&lt;/code&gt; line 612 on 13.x&lt;/a&gt; short-circuits the attempt check whenever &lt;code&gt;retryUntil()&lt;/code&gt; is set. Worker-level safety is no protection here, and neither is job-level &lt;code&gt;$tries&lt;/code&gt;. When &lt;code&gt;retryUntil()&lt;/code&gt; is set, the only bound is wall-clock time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/35199&quot;&gt;Issue #35199&lt;/a&gt; (Nov 2020) reported by &lt;a href=&quot;https://github.com/trevorgehman&quot;&gt;@trevorgehman&lt;/a&gt; -- &quot;Queue worker ignores job&apos;s maxTries setting if using retryUntil().&quot; Closed by &lt;a href=&quot;https://github.com/laravel/framework/pull/35214&quot;&gt;PR #35214&lt;/a&gt; which unified the behaviour: if &lt;code&gt;retryUntil&lt;/code&gt; is set, &lt;code&gt;maxAttempts&lt;/code&gt; is ignored in every path. Taylor&apos;s position on the issue: &lt;em&gt;&quot;&lt;code&gt;retryUntil&lt;/code&gt; and &lt;code&gt;maxTries&lt;/code&gt; are sort of mutually exclusive. When using &lt;code&gt;retryUntil&lt;/code&gt; I would use &lt;code&gt;maxExceptions&lt;/code&gt; if you want to determine how many uncaught exceptions are allowed.&quot;&lt;/em&gt; Follow-up comments in 2022 and 2024 show users still hitting this and filing it as broken, which suggests the docs gap remains.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; Surface this interaction prominently in the queues docs. A one-line note under &lt;code&gt;$tries&lt;/code&gt; (&quot;ignored when &lt;code&gt;retryUntil()&lt;/code&gt; is set&quot;) and under &lt;code&gt;retryUntil()&lt;/code&gt; (&quot;pair with &lt;code&gt;$maxExceptions&lt;/code&gt;, not &lt;code&gt;$tries&lt;/code&gt;&quot;) would close the comprehension gap. The behaviour itself is deliberate and shipped in Laravel 8.x (November 2020), so changing it would break every app that depends on the current semantics.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;12. Pipeline memory retention between jobs&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Pipeline/Pipeline.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The Pipeline retains references to &lt;code&gt;$passable&lt;/code&gt; and &lt;code&gt;$pipes&lt;/code&gt; after execution. In long-running queue workers, this means the previous job&apos;s data is held in memory until the next job overwrites it. While the retention is bounded to one job&apos;s worth of memory, it contributes to gradual memory growth in workers, increasing the likelihood of OOM events.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/56395&quot;&gt;Issue #56395&lt;/a&gt; (Jul 2025, OPEN) reported by &lt;a href=&quot;https://github.com/momala454&quot;&gt;@momala454&lt;/a&gt; -- job objects retain large data in memory after processing. A related issue I filed, &lt;a href=&quot;https://github.com/laravel/framework/issues/59402&quot;&gt;Issue #59402&lt;/a&gt;, was closed once I verified that the &lt;code&gt;$passable&lt;/code&gt;/&lt;code&gt;$pipes&lt;/code&gt; cleanup in &lt;a href=&quot;https://github.com/laravel/framework/pull/59415&quot;&gt;PR #59415&lt;/a&gt; and &lt;a href=&quot;https://github.com/laravel/framework/pull/59330&quot;&gt;PR #59330&lt;/a&gt; makes the &lt;code&gt;Job::$instance&lt;/code&gt; chain GC-eligible indirectly -- but those Pipeline PRs were themselves closed, so the underlying retention still ships in v13.4.0.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested fix:&lt;/strong&gt; Null &lt;code&gt;$passable&lt;/code&gt; and &lt;code&gt;$pipes&lt;/code&gt; after pipeline execution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Improvement: Edge cases and documentation gaps&lt;/h3&gt;
&lt;p&gt;These findings are lower risk but represent real gaps that specific configurations can hit.&lt;/p&gt;
&lt;h4&gt;13. Reserved job migration ignores attempt count&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/RedisQueue.php&lt;/code&gt; - &lt;code&gt;migrate()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When reserved jobs expire (worker died mid-processing), they are moved back to the ready queue without checking their attempt count. The attempt check only happens when the worker next pops and processes the job. This means a job that has already exceeded its max tries can be re-enqueued and picked up before being failed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/32103&quot;&gt;Issue #32103&lt;/a&gt; (Mar 2020) reported by &lt;a href=&quot;https://github.com/mfn&quot;&gt;@mfn&lt;/a&gt; -- job retried despite still running, reserved timeout expired mid-execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; The pre-fire check via &lt;code&gt;markJobAsFailedIfAlreadyExceedsMaxAttempts&lt;/code&gt; already handles this when the worker pops the job. The gap is the brief window where an over-limit job sits in the &quot;available&quot; queue before being popped. Checking attempts inside the Lua migration script would be expensive. A lighter approach: log or emit an event when a reserved job is migrated back, so monitoring tools can flag jobs that are cycling.&lt;/p&gt;
&lt;hr&gt;
&lt;h4&gt;14. No timeout enforcement on Windows&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;src/Illuminate/Queue/Worker.php&lt;/code&gt; - &lt;code&gt;registerTimeoutHandler()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The timeout handler relies on &lt;code&gt;pcntl_alarm&lt;/code&gt;, which is only available on systems with the pcntl extension (Linux/Mac). On Windows, Laravel deliberately throws an error if you set a timeout, forcing you to pass &lt;code&gt;--timeout 0&lt;/code&gt; to acknowledge you&apos;re running without timeout protection. This is the right design choice (silently ignoring the timeout would be worse), but it means Windows workers have no timeout enforcement at all. A job that enters an infinite loop or deadlock will block the worker process forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world:&lt;/strong&gt; &lt;a href=&quot;https://github.com/laravel/framework/issues/15002&quot;&gt;Issue #15002&lt;/a&gt; (Aug 2016) reported by &lt;a href=&quot;https://github.com/StevenBock&quot;&gt;@StevenBock&lt;/a&gt; -- queues require explicit &lt;code&gt;--timeout 0&lt;/code&gt; on Windows. &lt;a href=&quot;https://github.com/laravel/framework/issues/14909&quot;&gt;Issue #14909&lt;/a&gt; (Aug 2016) reported by &lt;a href=&quot;https://github.com/ac1982&quot;&gt;@ac1982&lt;/a&gt; -- PHP requires &lt;code&gt;--enable-pcntl&lt;/code&gt; for queue timeouts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Suggested improvement:&lt;/strong&gt; Add a fallback timeout mechanism for environments without pcntl support.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What the community found&lt;/h2&gt;
&lt;p&gt;The following issues were reported by other developers. I didn&apos;t find these in my audit -- they found them first. I&apos;m including them here so everything is in one place.&lt;/p&gt;
&lt;h4&gt;Worker enters infinite silent loop on non-database exceptions&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/59517&quot;&gt;Issue #59517&lt;/a&gt; (Apr 2026, OPEN) reported by &lt;a href=&quot;https://github.com/thuggins-engrain&quot;&gt;@thuggins-engrain&lt;/a&gt;. &lt;code&gt;stopWorkerIfLostConnection()&lt;/code&gt; only checks for database connection errors. If SQS SDK, Redis auth, or HTTP errors occur in &lt;code&gt;getNextJob()&lt;/code&gt;, the worker catches the exception, sleeps 1 second, and retries forever with no exit condition. &lt;a href=&quot;https://github.com/laravel/framework/pull/59553&quot;&gt;PR #59553&lt;/a&gt; by &lt;a href=&quot;https://github.com/webpatser&quot;&gt;@webpatser&lt;/a&gt; proposed &lt;code&gt;--max-pop-exceptions&lt;/code&gt; to address this; closed by maintainers.&lt;/p&gt;
&lt;h4&gt;Batch deadlocks under high concurrency&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/39722&quot;&gt;Issue #39722&lt;/a&gt; (Nov 2021) reported by &lt;a href=&quot;https://github.com/gm-lunatix&quot;&gt;@gm-lunatix&lt;/a&gt;. &lt;a href=&quot;https://github.com/laravel/framework/issues/36478&quot;&gt;Issue #36478&lt;/a&gt; (Mar 2021) reported by &lt;a href=&quot;https://github.com/murphatron&quot;&gt;@murphatron&lt;/a&gt;. &lt;a href=&quot;https://github.com/laravel/framework/issues/40574&quot;&gt;Issue #40574&lt;/a&gt; (Jan 2022) reported by &lt;a href=&quot;https://github.com/walkonthemarz&quot;&gt;@walkonthemarz&lt;/a&gt;. The &lt;code&gt;job_batches&lt;/code&gt; table uses SELECT FOR UPDATE, causing row-level lock contention with high-concurrency workers.&lt;/p&gt;
&lt;h4&gt;Batch never finishes when jobs fail&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/36180&quot;&gt;Issue #36180&lt;/a&gt; (Feb 2021) reported by &lt;a href=&quot;https://github.com/stephenstack&quot;&gt;@stephenstack&lt;/a&gt;. &lt;a href=&quot;https://github.com/laravel/framework/issues/35711&quot;&gt;Issue #35711&lt;/a&gt; (Dec 2020) reported by &lt;a href=&quot;https://github.com/nalingia&quot;&gt;@nalingia&lt;/a&gt;. When a batch job fails, &lt;code&gt;pending_jobs&lt;/code&gt; may never reach 0, so &lt;code&gt;then&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt; callbacks never fire. Batch hangs forever. Closed as completed but no linked fix PR found.&lt;/p&gt;
&lt;h4&gt;ShouldBeUnique lock not released&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/49890&quot;&gt;Issue #49890&lt;/a&gt; (Jan 2024) reported by &lt;a href=&quot;https://github.com/naquad&quot;&gt;@naquad&lt;/a&gt; -- lock not released when dependent model is deleted before processing. Closed with &quot;this is a no-fix for us right now.&quot; &lt;a href=&quot;https://github.com/laravel/framework/issues/37729&quot;&gt;Issue #37729&lt;/a&gt; (Jun 2021) reported by &lt;a href=&quot;https://github.com/rflatt-reassured&quot;&gt;@rflatt-reassured&lt;/a&gt; -- lock only releases after timeout, not after successful completion.&lt;/p&gt;
&lt;h4&gt;Failed jobs table causes OOM when retrying&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/49185&quot;&gt;Issue #49185&lt;/a&gt; (Nov 2023) reported by &lt;a href=&quot;https://github.com/arharp&quot;&gt;@arharp&lt;/a&gt;. &lt;a href=&quot;https://github.com/laravel/framework/issues/52129&quot;&gt;Issue #52129&lt;/a&gt; (Jul 2024) reported by &lt;a href=&quot;https://github.com/godwin-loyaltek&quot;&gt;@godwin-loyaltek&lt;/a&gt;. &lt;code&gt;RetryCommand&lt;/code&gt; and &lt;code&gt;FailedJobProviderInterface::all()&lt;/code&gt; load the entire &lt;code&gt;failed_jobs&lt;/code&gt; table into memory. At 300k+ records, it OOMs.&lt;/p&gt;
&lt;h4&gt;Chain jobs silently terminate on queue restart&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/45426&quot;&gt;Issue #45426&lt;/a&gt; (Dec 2022) reported by &lt;a href=&quot;https://github.com/Monilsh&quot;&gt;@Monilsh&lt;/a&gt;. If &lt;code&gt;queue:restart&lt;/code&gt; is issued mid-chain, remaining jobs are silently dropped. No error, no failed job record. Closed as &quot;expected behavior.&quot;&lt;/p&gt;
&lt;h4&gt;Timed-out worker kill leaks resources&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/30351&quot;&gt;Issue #30351&lt;/a&gt; (Oct 2019) reported by &lt;a href=&quot;https://github.com/halaei&quot;&gt;@halaei&lt;/a&gt;. &lt;code&gt;Worker::kill()&lt;/code&gt; sends SIGKILL, which prevents cleanup of temp files, connections, and locks.&lt;/p&gt;
&lt;h4&gt;No backpressure on dispatch&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/57787&quot;&gt;PR #57787&lt;/a&gt; (Nov 2025) by &lt;a href=&quot;https://github.com/yousefkadah&quot;&gt;@yousefkadah&lt;/a&gt; -- community attempt to add queue depth notifications via a &lt;code&gt;maxPendingJobs&lt;/code&gt; property. Not merged. There is no queue depth checking in the dispatch path. If the dispatch rate exceeds the consumption rate, the queue grows without limit.&lt;/p&gt;
&lt;h4&gt;Failed job providers crash on corrupted payload&lt;/h4&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/59635&quot;&gt;Issue #59635&lt;/a&gt; (Apr 2026, OPEN) reported by &lt;a href=&quot;https://github.com/ruttydm&quot;&gt;@ruttydm&lt;/a&gt;. UUID-based failed job providers use &lt;code&gt;json_decode($payload, true)[&apos;uuid&apos;]&lt;/code&gt; without null check. Corrupted payloads crash the provider and the failure record is permanently lost.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Current status&lt;/h2&gt;
&lt;p&gt;Across the 14 findings in this audit and the 9 community-reported issues above, the resolution status as of April 2026:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;3 partially addressed:&lt;/strong&gt; migrationBatchSize config added but defaults to unlimited (&lt;a href=&quot;#7-redis-migrationbatchsize-defaults-to-unlimited&quot;&gt;#7&lt;/a&gt;, &lt;a href=&quot;https://github.com/laravel/framework/pull/43310&quot;&gt;PR #43310&lt;/a&gt;), ThrottlesExceptions docblock corrected but default remains 0 (&lt;a href=&quot;#8-throttlesexceptions-retryafterminutes-defaults-to-0&quot;&gt;#8&lt;/a&gt;, &lt;a href=&quot;https://github.com/laravel/framework/pull/36642&quot;&gt;PR #36642&lt;/a&gt;), Windows timeout workaround documented but no auto-detection (&lt;a href=&quot;https://github.com/laravel/framework/issues/15002&quot;&gt;#15002&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 closed as intentional design decisions:&lt;/strong&gt; ShouldBeUnique lock behaviour (&lt;a href=&quot;https://github.com/laravel/framework/issues/49890&quot;&gt;#49890&lt;/a&gt;, &quot;no-fix for us&quot;), chain jobs dropped on restart (&lt;a href=&quot;https://github.com/laravel/framework/issues/45426&quot;&gt;#45426&lt;/a&gt;, &quot;expected behavior&quot;), retryUntil/maxTries mutual exclusion (&lt;a href=&quot;https://github.com/laravel/framework/issues/35199&quot;&gt;#35199&lt;/a&gt;, by design)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4 currently open:&lt;/strong&gt; OOM infinite retry (&lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;#58207&lt;/a&gt;), worker silent loop (&lt;a href=&quot;https://github.com/laravel/framework/issues/59517&quot;&gt;#59517&lt;/a&gt;), corrupted payload crash (&lt;a href=&quot;https://github.com/laravel/framework/issues/59635&quot;&gt;#59635&lt;/a&gt;), job memory not released (&lt;a href=&quot;https://github.com/laravel/framework/issues/56395&quot;&gt;#56395&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;13 closed&lt;/strong&gt; without framework code changes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some of the closed issues are from older Laravel versions and may have been addressed indirectly through major version changes. Some reflect intentional design trade-offs that reasonable people can disagree on. I&apos;ve included them because the safety implications exist regardless of whether the behaviour is intentional.&lt;/p&gt;
&lt;p&gt;I verified all 14 findings and 9 community reports against v13.4.0 source, with spot-checks against v13.6.0 (April 2026). The oldest linked issues date back to January 2015 (&lt;a href=&quot;https://github.com/laravel/framework/issues/7046&quot;&gt;#7046&lt;/a&gt;) and August 2016 (&lt;a href=&quot;https://github.com/laravel/framework/issues/15002&quot;&gt;#15002&lt;/a&gt;). The same code patterns, same defaults, and same behaviours are still present.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What the docs and support could improve&lt;/h2&gt;
&lt;h3&gt;Documentation&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Add a &quot;Queue Safety&quot; section&lt;/strong&gt; to the queue docs. The eight job-level properties (retry bounds, timeout, backoff, &lt;code&gt;failed()&lt;/code&gt; handler, HTTP timeouts, idempotency guard, lock expiry, platform cost ceiling) are scattered across different sections. A single page showing them together with a recommended safe configuration would help.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the Vapor tries configuration.&lt;/strong&gt; &lt;code&gt;VaporWorkCommand&lt;/code&gt; defines &lt;code&gt;--tries=0&lt;/code&gt;, but the &lt;code&gt;QueueHandler&lt;/code&gt; runtime passes &lt;code&gt;SQS_TRIES ?? 3&lt;/code&gt;. &lt;code&gt;queue:work&lt;/code&gt; defaults to &lt;code&gt;--tries=1&lt;/code&gt;. Three layers, three different values, none documented together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the &lt;code&gt;queue-timeout&lt;/code&gt; cost model on Vapor.&lt;/strong&gt; On pay-per-millisecond Lambda billing, &lt;code&gt;queue-timeout&lt;/code&gt; is the cost ceiling per failed attempt: &lt;code&gt;memory-gb x timeout-seconds x $0.0000166667/GB-s&lt;/code&gt;. At 2048MB x 900s that is ~$0.03 per stuck attempt, multiplied by retry count and volume. The Vapor docs name &lt;code&gt;queue-timeout&lt;/code&gt; as a configuration option but don&apos;t explain the cost model, and setting it high &quot;to be safe&quot; is the dominant shape of runaway bills.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the &lt;code&gt;$timeout&lt;/code&gt; vs &lt;code&gt;retry_after&lt;/code&gt; interaction.&lt;/strong&gt; When a job&apos;s &lt;code&gt;$timeout&lt;/code&gt; exceeds its connection&apos;s &lt;code&gt;retry_after&lt;/code&gt; (default 90s on both database and Redis), the reservation can expire mid-flight and a second worker can pick up the same job. This is the single most-cited queue gotcha in community writeups and is not explained in the queue docs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the &lt;code&gt;retryUntil&lt;/code&gt; / &lt;code&gt;maxTries&lt;/code&gt; mutual exclusion.&lt;/strong&gt; When &lt;code&gt;retryUntil()&lt;/code&gt; returns a future timestamp, &lt;code&gt;maxTries&lt;/code&gt; is skipped entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the &lt;code&gt;maxExceptions&lt;/code&gt; OOM limitation.&lt;/strong&gt; It only works for catchable exceptions. Fatal errors bypass the counter entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document the &lt;code&gt;ShouldBeUnique&lt;/code&gt; lock lifecycle.&lt;/strong&gt; &lt;code&gt;$uniqueFor&lt;/code&gt; is unset by default, which on Redis and Database drivers produces a lock with no TTL that persists until manually released. &lt;a href=&quot;https://github.com/laravel/framework/issues/49890&quot;&gt;Issue #49890&lt;/a&gt; was closed as completed, but the safety implication of the default remains. Laravel 13.6.0 added &lt;code&gt;#[DebounceFor]&lt;/code&gt; (&lt;a href=&quot;https://github.com/laravel/framework/pull/59507&quot;&gt;PR #59507&lt;/a&gt;) as an attribute-driven alternative with last-writer-wins semantics; document it alongside the interface-based options.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a serverless queue checklist&lt;/strong&gt; to the Vapor docs covering the eight job-level properties above, the &lt;code&gt;queue-timeout&lt;/code&gt; cost model, AWS budget alerts, an SQS dead letter queue with &lt;code&gt;maxReceiveCount&lt;/code&gt;, and a cost-based kill switch.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Support&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Offer a job review when customers report unexpected queue costs.&lt;/strong&gt; Ask: &quot;Can we review your jobs to see if they are producing long-running expensive invocations that will rack up an excessive bill?&quot; Walk through them in order of cost impact: &lt;code&gt;queue-timeout&lt;/code&gt; first (the dominant cost multiplier, commonly set high &quot;to be safe&quot;), HTTP timeouts inside job bodies second (a hung call pins the worker at the ceiling), and the eight-property checklist at the top of this post third. Note that Lambda is built for short bursty request-response work, not long-running queue jobs; a 15-minute &lt;code&gt;queue-timeout&lt;/code&gt; on pay-per-millisecond billing inverts Lambda&apos;s value proposition. If the work is inherently slow or I/O-heavy, Lambda is the wrong shape for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Link to safe configuration examples&lt;/strong&gt; in cost-related support replies -- the &lt;code&gt;SafeJob&lt;/code&gt; base class and the apply-spec prompt at the top of this post are both drop-in references.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consider proactive dashboard warnings&lt;/strong&gt; for the cost-shape signals, not just one property. Useful alerts: a job&apos;s SQS &lt;code&gt;ApproximateReceiveCount&lt;/code&gt; crossing N without a class-level &lt;code&gt;$tries&lt;/code&gt;; &lt;code&gt;queue-timeout&lt;/code&gt; multiplied by attempt count exceeding a per-job cost threshold; &lt;code&gt;$timeout&lt;/code&gt; greater than the connection&apos;s &lt;code&gt;retry_after&lt;/code&gt;. A single-property alert catches some incidents; the shape of the 2023 billing incident required several layers to align.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Disclaimer&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;This audit was AI-assisted using Claude Code against Laravel Framework v13.4.0 with later spot-checks against v13.6.0. Every finding includes the exact file path and method name so you can verify it yourself.&lt;/li&gt;
&lt;li&gt;I have not tested every suggested fix in production. Some may have trade-offs or edge cases I haven&apos;t considered.&lt;/li&gt;
&lt;li&gt;Some findings may have been addressed in ways I haven&apos;t identified. I have not used Laravel Vapor since 2023 and do not currently have an account, so Vapor-specific observations are based on the public vapor-core source code, not runtime testing. Things may have changed.&lt;/li&gt;
&lt;li&gt;These are starting points, not finished PRs. Published in good faith for the benefit of the community.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is a reasonable design philosophy where the framework intentionally leaves these guardrails to the developer. The tools exist (&lt;code&gt;$tries&lt;/code&gt;, &lt;code&gt;$maxExceptions&lt;/code&gt;, &lt;code&gt;$backoff&lt;/code&gt;, &lt;code&gt;$timeout&lt;/code&gt;) and it&apos;s the developer&apos;s responsibility to configure them. Changing defaults is a breaking change for anyone relying on current behaviour, and some of these suggestions would need careful migration paths.&lt;/p&gt;
&lt;p&gt;Where I respectfully disagree is on the scaffold. Sidekiq ships with 25 retries and exponential backoff. Symfony Messenger, Go Asynq, and Google Cloud Tasks all bake in retry config by default. Laravel&apos;s &lt;code&gt;make:job&lt;/code&gt; generates a class with nothing. The tools exist. The scaffold doesn&apos;t tell you to use them.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;If any of these findings are inaccurate or have already been addressed, I&apos;m happy to update this post with corrections.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator><atom:updated>2026-04-22T00:00:00.000Z</atom:updated></item><item><title>Why Your Laravel Jobs Might Retry Forever After an OOM</title><link>https://joshsalway.com/articles/why-your-laravel-jobs-might-retry-forever-after-an-oom/</link><guid isPermaLink="true">https://joshsalway.com/articles/why-your-laravel-jobs-might-retry-forever-after-an-oom/</guid><description>Laravel&apos;s maxExceptions feature doesn&apos;t work when the failure mode is OOM. The exception counter lives in the catch block, which never executes during a fatal error. End-to-end proof on Laravel 13.4.0.</description><pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In December 2019, I deployed an app on Laravel Vapor and received a bill for $140 USD after one week. I contacted support. The response was that the issue was on my end.&lt;/p&gt;
&lt;p&gt;In August 2023, it happened again. $218.90 USD in 7 days. The support emails referenced queued jobs as the cause, but the exact root cause was never identified. Same response from support.&lt;/p&gt;
&lt;p&gt;Both times I accepted it and moved on. I didn&apos;t have the tools or the time to investigate what actually went wrong.&lt;/p&gt;
&lt;p&gt;In March 2026, while reviewing the Laravel org repos and codebase, I came across an issue that described a bug that could contribute to exactly this kind of problem. I can&apos;t say for certain it caused my specific billing incidents, but the symptoms match: runaway queued jobs with no circuit breaker.&lt;/p&gt;
&lt;h2&gt;The Bug&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;Issue #58207&lt;/a&gt; on the Laravel framework repository, filed in December 2025, describes the problem: jobs with &lt;code&gt;$maxExceptions&lt;/code&gt; retry infinitely when killed by an out-of-memory error.&lt;/p&gt;
&lt;p&gt;The reason is straightforward. Laravel&apos;s exception counter for &lt;code&gt;maxExceptions&lt;/code&gt; is only incremented inside &lt;code&gt;markJobAsFailedIfWillExceedMaxExceptions()&lt;/code&gt;, which is called from &lt;code&gt;handleJobException()&lt;/code&gt;, which is called from the &lt;code&gt;catch (Throwable)&lt;/code&gt; block in &lt;code&gt;Worker::process()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;When PHP hits its memory limit, the process is killed by a fatal error. Fatal errors are not catchable exceptions. The catch block never executes. The counter is never incremented. The job goes back to the queue with a counter of zero. The next worker picks it up and the cycle repeats.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Worker starts -&gt; fire() -&gt; OOM -&gt; process killed
Worker restarts -&gt; same job -&gt; counter still 0 -&gt; fire() -&gt; OOM
Worker restarts -&gt; counter still 0 -&gt; fire() -&gt; OOM
... forever
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On serverless platforms where you pay per invocation, each retry can cost money. As far as I&apos;m aware, there is no circuit breaker for this scenario.&lt;/p&gt;
&lt;h2&gt;The Evidence&lt;/h2&gt;
&lt;p&gt;I verified this against the actual source code in Laravel Framework 13.4.0:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;markJobAsFailedIfWillExceedMaxExceptions()&lt;/code&gt; is the only place the &lt;code&gt;job-exceptions:{uuid}&lt;/code&gt; counter is incremented (Worker.php, line 636)&lt;/li&gt;
&lt;li&gt;It is called from &lt;code&gt;handleJobException()&lt;/code&gt; (line 538)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;handleJobException()&lt;/code&gt; is called from the &lt;code&gt;catch&lt;/code&gt; block (line 505)&lt;/li&gt;
&lt;li&gt;There is no pre-fire check on the exception counter anywhere in the codebase&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I then ran a subprocess test that allocates memory until OOM at 32M. The result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SHUTDOWN FUNCTION: OOM detected
CATCH BLOCK EXECUTED: NO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PHP&apos;s &lt;code&gt;register_shutdown_function&lt;/code&gt; runs after OOM. The catch block does not.&lt;/p&gt;
&lt;h2&gt;End-to-End Reproduction&lt;/h2&gt;
&lt;p&gt;To prove this isn&apos;t theoretical, I built a full end-to-end test:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Created a fresh Laravel 13.4.0 app with SQLite database queue&lt;/li&gt;
&lt;li&gt;Dispatched an &lt;code&gt;OomJob&lt;/code&gt; with &lt;code&gt;$tries = 0&lt;/code&gt; and &lt;code&gt;$maxExceptions = 3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Ran &lt;code&gt;php artisan queue:work --once&lt;/code&gt; with a 64M memory limit, 10 times&lt;/li&gt;
&lt;li&gt;Checked the job state and exception counter after each run&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Stock Laravel 13.4.0&lt;/h3&gt;
&lt;p&gt;| Run | Pending | Failed | Exception Counter | DB Attempts |
|-----|---------|--------|-------------------|-------------|
| 1 | 1 | 0 | not set | 1 |
| 2 | 1 | 0 | not set | 2 |
| 3 | 1 | 0 | not set | 3 |
| ... | 1 | 0 | not set | ... |
| 10 | 1 | 0 | not set | 10 |&lt;/p&gt;
&lt;p&gt;The job retried 10 times. The exception counter was never set. The job would continue retrying forever.&lt;/p&gt;
&lt;h3&gt;With Fix Applied&lt;/h3&gt;
&lt;p&gt;| Run | Pending | Failed | Exception Counter | DB Attempts |
|-----|---------|--------|-------------------|-------------|
| 1 | 1 | 0 | 1 | 1 |
| 2 | 1 | 0 | 2 | 2 |
| 3 | 1 | 0 | 3 | 3 |
| 4 | 0 | 1 | cleared | n/a |&lt;/p&gt;
&lt;p&gt;The job was correctly failed after 4 runs with &lt;code&gt;MaxExceptionsExceededException&lt;/code&gt;. The workers were freed.&lt;/p&gt;
&lt;h2&gt;The Fix&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59329&quot;&gt;PR #59329&lt;/a&gt; uses an optimistic increment pattern:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Increment the exception counter &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;$job-&gt;fire()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Decrement it &lt;strong&gt;after&lt;/strong&gt; successful completion&lt;/li&gt;
&lt;li&gt;Add a pre-fire check that fails the job if the counter already meets &lt;code&gt;maxExceptions&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If the worker dies during &lt;code&gt;fire()&lt;/code&gt;, the increment persists in cache. On the next pickup, the pre-fire check catches it.&lt;/p&gt;
&lt;p&gt;This is the same approach that community members independently built as a job middleware and validated in production, as discussed in the &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;issue thread&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A companion fix (&lt;a href=&quot;https://github.com/laravel/framework/pull/59330&quot;&gt;PR #59330&lt;/a&gt;) addresses the upstream cause: &lt;code&gt;Pipeline&lt;/code&gt; retains references to completed job data between jobs, inflating worker memory and increasing the likelihood of OOM in the first place. Together, one reduces the chance of OOM and the other ensures graceful failure when it does happen.&lt;/p&gt;
&lt;h2&gt;A Note on Responsibility&lt;/h2&gt;
&lt;p&gt;No framework can guardrail against every possible runaway job caused by application code. There will always be edge cases where a developer&apos;s code consumes more resources than expected. That&apos;s the nature of software.&lt;/p&gt;
&lt;p&gt;But the framework, hosting platform, and application code should all have seatbelts in place to reduce the likelihood of infinite retries in serverless environments. The &lt;code&gt;maxExceptions&lt;/code&gt; feature exists specifically for this purpose. It just doesn&apos;t work when the failure mode is OOM, because of where the counter lives in the code. This fix doesn&apos;t solve every possible cause of runaway bills, but it closes one gap that the framework can reasonably address.&lt;/p&gt;
&lt;h2&gt;Current Status&lt;/h2&gt;
&lt;p&gt;The issue was filed in December 2025. It has &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;31 comments&lt;/a&gt; from multiple production users discussing the bug and independently developing workarounds.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59329&quot;&gt;PR #59329&lt;/a&gt; was closed with the reason: &quot;To preserve our ability to adequately maintain the framework, we need to be very careful regarding the amount of code we include.&quot;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59330&quot;&gt;PR #59330&lt;/a&gt; (the companion Pipeline memory fix) was closed as a breaking change, because calls to &lt;code&gt;getResolvedJob&lt;/code&gt; would return &lt;code&gt;null&lt;/code&gt; post &lt;code&gt;fire&lt;/code&gt;. A follow-up &lt;a href=&quot;https://github.com/laravel/framework/pull/59415&quot;&gt;PR #59415&lt;/a&gt; was submitted that removed the breaking change and only nulled &lt;code&gt;$passable&lt;/code&gt; and &lt;code&gt;$pipes&lt;/code&gt;. It was closed with no maintainer response.&lt;/p&gt;
&lt;p&gt;The bug still exists in Laravel Framework 13.4.0.&lt;/p&gt;
&lt;h2&gt;Reproducibility&lt;/h2&gt;
&lt;p&gt;All benchmark scripts, reproduction code, and the end-to-end test app are publicly available:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/JoshSalway/laravel-memory-benchmarks&quot;&gt;github.com/JoshSalway/laravel-memory-benchmarks&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The repository includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;reproduce_oom_infinite_retry.php&lt;/code&gt; -- three-step proof of the bug&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oom-retry-test/&lt;/code&gt; -- full end-to-end test app with setup instructions&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_oom_128.php&lt;/code&gt; -- proves OOM occurs with realistic workloads at 128M&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_oom_prevention.php&lt;/code&gt; -- proves Pipeline memory retention triggers OOM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_memory_real.php&lt;/code&gt; -- real-world queue workload benchmarks&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_memory_usecases.php&lt;/code&gt; -- PDF, image, CSV, and API sync benchmarks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Anyone can clone the repo, follow the instructions, and verify the results independently.&lt;/p&gt;
&lt;p&gt;This fix has been tested end-to-end but has not been through a formal code review process. The reproduction scripts are publicly available for independent verification.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Rebuilding joshsalway.com: From Statamic to Astro</title><link>https://joshsalway.com/articles/rebuilding-joshsalway-com-from-statamic-to-astro/</link><guid isPermaLink="true">https://joshsalway.com/articles/rebuilding-joshsalway-com-from-statamic-to-astro/</guid><description>I rebuilt my personal site from Statamic SSG to Astro 6 with React islands. Here&apos;s what changed, what I added, and why static pages should ship zero JavaScript.</description><pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I rebuilt this site from the ground up. The old stack was Statamic CMS running as a static site generator with Alpine.js, Tailwind CSS, and Vite, deployed on Netlify. The new stack is Astro 6 with React islands, Tailwind CSS 4, and the same Netlify deployment.&lt;/p&gt;
&lt;p&gt;The core design is identical: same WebGL hero, same 8 colour themes, same Spotify-inspired dark palette. But the architecture is fundamentally different, and the site picked up a lot of new features along the way.&lt;/p&gt;
&lt;h2&gt;Why the rebuild&lt;/h2&gt;
&lt;p&gt;Three reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Statamic is overkill for a static blog.&lt;/strong&gt; I was running a full Laravel application with a CMS control panel, PHP runtime, and Composer dependencies just to generate HTML files. The content is markdown files in a folder.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;JavaScript budget.&lt;/strong&gt; The old site loaded Alpine.js on every page even though most pages had no interactivity. The About page doesn&apos;t need a JavaScript framework.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Developer experience.&lt;/strong&gt; I wanted type-safe content schemas, better markdown handling, and a faster build pipeline.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The new stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Astro 6&lt;/strong&gt; with islands architecture&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React&lt;/strong&gt; only for interactive components (theme switcher, search, contact form)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS 4&lt;/strong&gt; via the Vite plugin, no PostCSS config needed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Astro Content Collections&lt;/strong&gt; with type-safe Zod schemas&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Netlify&lt;/strong&gt; same host, same domain, same form handling&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What ships zero JavaScript&lt;/h2&gt;
&lt;p&gt;This is the key architectural win. In Astro, pages are static HTML by default. JavaScript only loads for components that explicitly opt in with &lt;code&gt;client:&lt;/code&gt; directives.&lt;/p&gt;
&lt;p&gt;Pages that ship &lt;strong&gt;zero JavaScript&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;About&lt;/li&gt;
&lt;li&gt;Projects&lt;/li&gt;
&lt;li&gt;Articles index&lt;/li&gt;
&lt;li&gt;404 page&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pages that ship &lt;strong&gt;only the JavaScript they need&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Home: WebGL canvas + theme initializer&lt;/li&gt;
&lt;li&gt;Article pages: reading progress bar, table of contents, code copy button&lt;/li&gt;
&lt;li&gt;Contact: form submission handler&lt;/li&gt;
&lt;li&gt;Every page: nav (search modal, theme picker, dark mode toggle)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The nav components hydrate with &lt;code&gt;client:load&lt;/code&gt; (immediately) while the Konami code easter egg uses &lt;code&gt;client:idle&lt;/code&gt; (when the browser is idle). This is granular control you don&apos;t get with a full SPA framework.&lt;/p&gt;
&lt;h2&gt;View Transitions&lt;/h2&gt;
&lt;p&gt;The site uses Astro&apos;s &lt;code&gt;ClientRouter&lt;/code&gt; for SPA-like navigation between pages. Page transitions are smooth with no full reload flash. The inline &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt; script handles theme persistence before paint, so accent colours never flicker between pages, even on Windows where the scrollbar gutter is reserved with &lt;code&gt;scrollbar-gutter: stable&lt;/code&gt; to prevent layout shifts.&lt;/p&gt;
&lt;h2&gt;New features&lt;/h2&gt;
&lt;h3&gt;Command palette search (Ctrl+K)&lt;/h3&gt;
&lt;p&gt;A Fuse.js-powered search modal accessible via &lt;code&gt;Ctrl+K&lt;/code&gt;, &lt;code&gt;Cmd+K&lt;/code&gt;, or the search icon in the nav. Searches across all pages, articles, and topic tags with fuzzy matching. Clickable tag filter chips let you browse by topic (Laravel, React, Tutorial, etc.). Shows all content on open with keyboard navigation (arrow keys + enter).&lt;/p&gt;
&lt;h3&gt;Article metadata&lt;/h3&gt;
&lt;p&gt;Each article card shows topic tags, depth and length bars (1-5 scale), and reading time. The depth bar indicates how technical the article is, the length bar shows how long it is. Inspired by the complexity bars on the projects page.&lt;/p&gt;
&lt;h3&gt;Reading progress bar&lt;/h3&gt;
&lt;p&gt;A thin accent-coloured bar fixed to the top of the viewport that tracks scroll position on article pages. Uses a passive scroll listener for performance.&lt;/p&gt;
&lt;h3&gt;Table of contents&lt;/h3&gt;
&lt;p&gt;Auto-generated from article headings (h2/h3). Only appears when an article has 3 or more headings. Highlights the current section using IntersectionObserver.&lt;/p&gt;
&lt;h3&gt;Code copy button&lt;/h3&gt;
&lt;p&gt;Hover over any code block to reveal a copy button. Provides visual feedback (&quot;Copied!&quot; / &quot;Failed&quot;) and matches the dark code block aesthetic. Inline code elements break words on mobile instead of overflowing the page.&lt;/p&gt;
&lt;h3&gt;Previous/next article navigation&lt;/h3&gt;
&lt;p&gt;Links to adjacent articles at the bottom of each article page, ordered chronologically.&lt;/p&gt;
&lt;h3&gt;RSS feed&lt;/h3&gt;
&lt;p&gt;Available at &lt;a href=&quot;/feed.xml&quot;&gt;&lt;code&gt;/feed.xml&lt;/code&gt;&lt;/a&gt;. RSS readers auto-detect it via the &lt;code&gt;&amp;#x3C;link rel=&quot;alternate&quot;&gt;&lt;/code&gt; tag in the HTML head. There&apos;s an RSS link inline with the Articles heading on the articles page.&lt;/p&gt;
&lt;h3&gt;Sitemap&lt;/h3&gt;
&lt;p&gt;Auto-generated by &lt;code&gt;@astrojs/sitemap&lt;/code&gt; at &lt;code&gt;/sitemap-index.xml&lt;/code&gt; with priority weighting for each page type.&lt;/p&gt;
&lt;h3&gt;JSON-LD structured data&lt;/h3&gt;
&lt;p&gt;Every article page includes Schema.org Article markup for better search engine results.&lt;/p&gt;
&lt;h3&gt;Dynamic text selection colour&lt;/h3&gt;
&lt;p&gt;Text selection colour in dark mode now matches the active theme accent. Switch to Ocean theme and your selection highlight turns blue. Switch to Gold and it turns yellow.&lt;/p&gt;
&lt;h3&gt;Custom 404 page&lt;/h3&gt;
&lt;p&gt;A styled error page matching the site design with links back to the home page and articles.&lt;/p&gt;
&lt;h3&gt;Short links&lt;/h3&gt;
&lt;p&gt;Every article has a short URL for sharing. For example, this post-mortem can be shared as &lt;code&gt;joshsalway.com/post-mortem-pr-59323&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Projects page redesign&lt;/h3&gt;
&lt;p&gt;Project cards now show the project icon (FragHub&apos;s yellow PUBG helmet, Barcoder&apos;s barcode scan icon, Poker Timer&apos;s clock), full tech stack tags, a complexity bar, hosting info, pill-style action buttons, and &quot;Read Article&quot; links to the matching blog post. FragHub gets a rotating rainbow border as the featured project. Non-featured cards have a green border, background lift, and shadow glow on hover. The whole card is clickable. Tech filter pills at the top let you filter projects by stack (React, Laravel, Tailwind CSS, etc.).&lt;/p&gt;
&lt;h3&gt;Animated logo&lt;/h3&gt;
&lt;p&gt;The &quot;JS&quot; logo in the nav has a blinking cursor. Hover over it and it deletes &quot;JS&quot;, then types a random word from a list of 180+ options (Zigzagging, Brewing, Flibbertigibbeting...) in red with a spinning asterisk. Move away and it types &quot;JS&quot; back.&lt;/p&gt;
&lt;h3&gt;Clickable tags&lt;/h3&gt;
&lt;p&gt;Topic tags on article cards and article headers open the search modal pre-filtered to that tag. The articles page has a tag filter bar that filters articles in place without a page reload. Same for the tech filter on the projects page.&lt;/p&gt;
&lt;h3&gt;About page&lt;/h3&gt;
&lt;p&gt;Profile photo pulled from GitHub, open source contribution mention, and links to get in touch.&lt;/p&gt;
&lt;h3&gt;Konami code&lt;/h3&gt;
&lt;p&gt;There&apos;s a hidden easter egg on the site. Press &lt;code&gt;Up, Up, Down, Down, Left, Right, Left, Right, B, A&lt;/code&gt; on your keyboard (the classic Konami code from 1986) and watch what happens. You&apos;ll get a confetti cannon in all 8 theme colours, followed by a playable Space Invaders game. Use arrow keys to move, space to shoot, ESC to close. A small nod to the gaming roots that got me into programming.&lt;/p&gt;
&lt;h2&gt;SEO and redirects&lt;/h2&gt;
&lt;p&gt;The old Statamic site served articles at root-level URLs (&lt;code&gt;/laravel-pr-59323-post-mortem&lt;/code&gt;). The new site uses &lt;code&gt;/articles/&lt;/code&gt; as a prefix. All 14 old URLs have 301 permanent redirects configured in &lt;code&gt;netlify.toml&lt;/code&gt; so existing links from X, Google, and backlinks resolve correctly.&lt;/p&gt;
&lt;p&gt;Canonical URLs, Open Graph tags, X/Twitter cards, and Google Analytics carry over from the old site.&lt;/p&gt;
&lt;h2&gt;Build performance&lt;/h2&gt;
&lt;p&gt;The full site builds in under 2 seconds. The old Statamic SSG pipeline took 8-10 seconds including the PHP runtime, Vite asset compilation, and static generation step.&lt;/p&gt;
&lt;h2&gt;Performance and accessibility&lt;/h2&gt;
&lt;p&gt;Lighthouse scores: 91 performance, 95 accessibility, 100 best practices, 100 SEO on the home page.&lt;/p&gt;
&lt;p&gt;A few things that helped get there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lazy-loaded highlight.js&lt;/strong&gt;: instead of bundling the full 969KB library on every page, it only loads via dynamic &lt;code&gt;import()&lt;/code&gt; when the page actually has code blocks. Non-article pages ship zero highlight.js.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Touch targets&lt;/strong&gt;: tag filter pills and nav buttons meet the 44px minimum on touch devices for WCAG compliance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Service worker&lt;/strong&gt;: the site works offline. A network-first strategy caches pages as you browse. Core pages (home, about, articles, projects, contact) are precached on first visit. If you lose connection, cached pages still load. Useful on dodgy mobile connections.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Image preloads&lt;/strong&gt;: profile photo and project icons are preloaded in the HTML head so they render instantly without a pop-in flash.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;No CMS, better security&lt;/h2&gt;
&lt;p&gt;There&apos;s no CMS. No admin panel, no database-backed editor, no login screen. When I want to write or edit an article, I open Claude Code and work directly with &lt;code&gt;.md&lt;/code&gt; files. It commits to git, Netlify auto-deploys, and the article is live. The entire content workflow is the terminal.&lt;/p&gt;
&lt;p&gt;Statamic&apos;s control panel was nice, but I never used it. I was always editing markdown files directly anyway. Removing the CMS removed an entire category of complexity and security surface: no PHP runtime, no authentication, no session management, no database, no admin panel to exploit, no plugins to patch. The attack surface is a CDN serving static HTML files. The attack surface is about as small as it gets.&lt;/p&gt;
&lt;h2&gt;What I&apos;d do differently&lt;/h2&gt;
&lt;p&gt;If I were starting from scratch today, I&apos;d skip the intermediate step. I initially rebuilt in Next.js before realising a static blog doesn&apos;t need a full React SPA framework. The Astro rebuild was the right call: it&apos;s the purpose-built tool for content sites with selective interactivity.&lt;/p&gt;
&lt;p&gt;The Next.js version still exists on the &lt;code&gt;master&lt;/code&gt; branch if I ever need it. The Astro version is on &lt;code&gt;feat/astro-rebuild&lt;/code&gt;.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Blameless Post-Mortem: Laravel SessionManager Clone Removal</title><link>https://joshsalway.com/articles/laravel-pr-59323-post-mortem/</link><guid isPermaLink="true">https://joshsalway.com/articles/laravel-pr-59323-post-mortem/</guid><description>A blameless post-mortem on a reverted Laravel framework PR. What happened, what we learned, and the path forward.</description><pubDate>Wed, 08 Apr 2026 13:59:00 GMT</pubDate><content:encoded>&lt;p&gt;Not every PR lands cleanly. This one got reverted, and it was worth understanding why.&lt;/p&gt;
&lt;h2&gt;The change&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59323&quot;&gt;PR #59323&lt;/a&gt; targeted a reported performance problem (&lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;#58377&lt;/a&gt;): &lt;code&gt;SessionManager::createCacheHandler()&lt;/code&gt; was cloning the entire cache Repository on every request. For Redis, that clone triggered &lt;code&gt;Repository::__clone()&lt;/code&gt;, which deep-cloned the underlying Store. The original issue reported that this was creating duplicate Redis TCP connections.&lt;/p&gt;
&lt;p&gt;The fix removed the &lt;code&gt;clone&lt;/code&gt; and added conditional Store cloning only when &lt;code&gt;session.connection&lt;/code&gt; was explicitly configured. The tests passed. The reasoning looked sound.&lt;/p&gt;
&lt;p&gt;It was in &lt;code&gt;13.x&lt;/code&gt; for eight days before it was &lt;a href=&quot;https://github.com/laravel/framework/pull/59542&quot;&gt;reverted&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What broke&lt;/h2&gt;
&lt;p&gt;Two bugs, both caused by the same fundamental mistake.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug 1: Sessions silently switched Redis databases.&lt;/strong&gt; When &lt;code&gt;SESSION_CONNECTION&lt;/code&gt; is null (the default), the old clone inherited the &lt;code&gt;default&lt;/code&gt; Redis connection (DB 0). By removing the clone and sharing the cache Repository directly, sessions started using the &lt;code&gt;cache&lt;/code&gt; Redis connection (DB 1) instead. Session data moved to the wrong database. Any user with an existing session in DB 0 would appear logged out after upgrading.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug 2: Cache contamination.&lt;/strong&gt; When &lt;code&gt;SESSION_CONNECTION&lt;/code&gt; is explicitly configured, &lt;code&gt;setStore()&lt;/code&gt; mutated the shared cache Repository singleton. After session middleware ran, every &lt;code&gt;Cache::get()&lt;/code&gt; call in the application was reading from the session database instead of the cache database. Rate limiting, job deduplication, cache locks, everything that depends on cache would return &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Both bugs were reported by the community within days. &lt;a href=&quot;https://github.com/stancl&quot;&gt;stancl&lt;/a&gt; filed &lt;a href=&quot;https://github.com/laravel/framework/issues/59515&quot;&gt;issue #59515&lt;/a&gt; identifying the connection switch, and &lt;a href=&quot;https://github.com/webpatser&quot;&gt;webpatser&lt;/a&gt; identified the cache contamination in the same thread and provided a workaround. The PR was reverted shortly after.&lt;/p&gt;
&lt;h3&gt;Worst case and real-world impact&lt;/h3&gt;
&lt;p&gt;For apps using Redis sessions with separate &lt;code&gt;default&lt;/code&gt; and &lt;code&gt;cache&lt;/code&gt; connections, Bug 1 would cause existing sessions in DB 0 to become invisible when the app reads from DB 1, along with any session data including CSRF tokens, flash messages, and cart contents. For apps with an explicit &lt;code&gt;session.connection&lt;/code&gt;, Bug 2 would break any feature that depends on cache: rate limiting would stop working, queue job deduplication would fail, cache-based locks would break, and any cached config or feature flags would return null.&lt;/p&gt;
&lt;p&gt;The bug shipped in a tagged release:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;v13.2.0&lt;/strong&gt; (Mar 24) - last clean release&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #59323 merged&lt;/strong&gt; (Mar 27) - the broken code enters &lt;code&gt;13.x&lt;/code&gt; branch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v13.3.0&lt;/strong&gt; (Apr 1) - released with the bug. Anyone who ran &lt;code&gt;composer update&lt;/code&gt; got it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Issue #59515 filed&lt;/strong&gt; (Apr 3) - stancl reports it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR #59542 reverts&lt;/strong&gt; (Apr 5) - fix merged to &lt;code&gt;13.x&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v13.4.0&lt;/strong&gt; (Apr 7) - clean release with the revert&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practice, only two people reported the regression during the six days v13.3.0 was the latest release. No other issues were filed describing symptoms consistent with it.&lt;/p&gt;
&lt;h2&gt;The root cause&lt;/h2&gt;
&lt;p&gt;The concept that best describes this mistake is &lt;strong&gt;Chesterton&apos;s Fence&lt;/strong&gt;: don&apos;t remove something until you understand why it was put there.&lt;/p&gt;
&lt;p&gt;I looked at the &lt;code&gt;clone&lt;/code&gt; and asked the wrong question. I asked: &quot;Does &lt;code&gt;CacheBasedSessionHandler&lt;/code&gt; mutate the Repository?&quot; The answer was no, it only calls &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;put&lt;/code&gt;, and &lt;code&gt;forget&lt;/code&gt;. So I concluded the clone was unnecessary.&lt;/p&gt;
&lt;p&gt;The right question was: &quot;What invariant does this clone maintain?&quot; The answer: &lt;strong&gt;isolation between subsystems&lt;/strong&gt;. The session needs its own Repository instance so that operations on it (like &lt;code&gt;setStore()&lt;/code&gt; for connection rebinding) don&apos;t contaminate the cache singleton that the rest of the application depends on.&lt;/p&gt;
&lt;p&gt;The clone wasn&apos;t protecting against mutation of the Repository&apos;s data. It was protecting against mutation of the Repository &lt;em&gt;as a shared object in the service container&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;What the PR description got wrong&lt;/h2&gt;
&lt;p&gt;My PR stated &quot;No behavior change&quot; for the common case. That was wrong. I reasoned about it instead of proving it. The tests I wrote validated that sessions could read and write (the happy path), but never asserted &lt;em&gt;which Redis connection&lt;/em&gt; was being used or whether the cache singleton remained clean after session initialization.&lt;/p&gt;
&lt;p&gt;If I&apos;d spun up Redis with separate &lt;code&gt;default&lt;/code&gt; (DB 0) and &lt;code&gt;cache&lt;/code&gt; (DB 1) connections and written a test that checked which database the session was actually writing to, both bugs would have been caught before the PR was ever opened.&lt;/p&gt;
&lt;h2&gt;What I changed in my process&lt;/h2&gt;
&lt;p&gt;I use AI to help find issues with my fixes, and I verify its analysis myself before submitting. That means asking questions, thinking about edge cases, and looking for concerns that could break the code. The goal is to fix it without introducing regressions, while making minimal changes that are impactful and useful, without taking shortcuts.&lt;/p&gt;
&lt;p&gt;I maintain a PR Preflight checklist that I run through before marking any Laravel PR ready for review. After this revert, I added six new items. These are the kinds of checks AI can help with, as long as you verify the answers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reproduce the problem first.&lt;/strong&gt; Before writing any fix, reproduce the reported issue with a test and confirm it is an actual problem. If you can&apos;t reproduce it, the fix may not be needed. This PR was based on a reported performance problem that could not be reproduced with real connection counts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Chesterton&apos;s Fence check.&lt;/strong&gt; For every removal (clone, copy, guard, wrapper), answer: &quot;What invariant does this maintain? What breaks if two subsystems share state without it?&quot; If I can&apos;t answer confidently, the removal isn&apos;t safe.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&quot;Prove, don&apos;t claim.&quot;&lt;/strong&gt; If a PR description says &quot;no behavior change&quot;, there must be a test that would fail if behavior &lt;em&gt;did&lt;/em&gt; change. Reasoning is not proof.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Test with real multi-service configs.&lt;/strong&gt; Single-connection test setups hide routing bugs. For anything touching sessions, cache, or Redis, test with configs that use separate connections on different databases.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Singleton contamination audit.&lt;/strong&gt; When removing isolation around a shared singleton, trace every code path that could mutate the shared object after the change. The blast radius of singleton mutation is the entire request.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verify reasoning empirically.&lt;/strong&gt; Confident analysis can be wrong. Any claim about behavior needs to be tested, not reasoned about.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Does the original problem actually exist?&lt;/h2&gt;
&lt;p&gt;The original issue (&lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;#58377&lt;/a&gt;) claimed that the clone creates duplicate Redis TCP connections. After the revert, I looked more carefully at what the clone actually does.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Repository::__clone()&lt;/code&gt; deep-clones the Store, creating a new &lt;code&gt;RedisStore&lt;/code&gt; object. But &lt;code&gt;RedisStore&lt;/code&gt; doesn&apos;t hold a connection directly. It holds a reference to the &lt;code&gt;RedisManager&lt;/code&gt; factory, which is shared by reference (not cloned). When the cloned Store calls &lt;code&gt;$this-&gt;redis-&gt;connection($this-&gt;connection)&lt;/code&gt;, it hits the same &lt;code&gt;RedisManager&lt;/code&gt; singleton:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// RedisManager::connection() - caches by name
public function connection($name = null)
{
    $name = enum_value($name) ?: &apos;default&apos;;

    if (isset($this-&gt;connections[$name])) {
        return $this-&gt;connections[$name];
    }

    return $this-&gt;connections[$name] = $this-&gt;configure(
        $this-&gt;resolve($name), $name
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The manager caches connections by name. Both the original and cloned Store resolve to the same cached connection. No duplicate TCP connection is created.&lt;/p&gt;
&lt;p&gt;To confirm this, I tested connection counts against a real Redis instance. Session alone opens 1 connection (&lt;code&gt;default&lt;/code&gt;). Cache adds 1 more (&lt;code&gt;cache&lt;/code&gt;). Total: 2 connections, one per named connection, both necessary. The clone doesn&apos;t add a third. I could not reproduce the duplicate connections that the original issue reported. The actual cost of the clone is one extra PHP object per request, not a TCP connection.&lt;/p&gt;
&lt;p&gt;I also used the same &lt;code&gt;CLIENT INFO&lt;/code&gt; approach that &lt;a href=&quot;https://github.com/stancl&quot;&gt;stancl&lt;/a&gt; used in his &lt;a href=&quot;https://github.com/laravel/framework/issues/59515&quot;&gt;issue report&lt;/a&gt; to independently verify the connection routing. All states produced identical results: correct routing, no contamination, no duplicate connections. I could only work that out by trying to reproduce the original bug and testing it properly. The code analysis suggested the connections were cached. The real-world tests proved it.&lt;/p&gt;
&lt;p&gt;This changes the risk-reward calculus. The clone provides valuable isolation between the session and cache subsystems, as this revert demonstrated. The performance cost is one object allocation per request. Removing it risks the kind of regression we saw here, for a gain that doesn&apos;t appear to exist.&lt;/p&gt;
&lt;p&gt;I&apos;ve drafted an &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/23&quot;&gt;alternative approach&lt;/a&gt; that preserves the clone for isolation while sharing the Store. Benchmarking shows it offers no measurable performance benefit over stock Laravel for Redis sessions, which reinforces the conclusion that the original issue may not be a real problem. The value of the draft PR is the regression tests and documentation it adds, which may be useful as a reference for anyone revisiting this area in the future.&lt;/p&gt;
&lt;p&gt;Between stancl&apos;s findings and the testing here, the duplicate connection claim in the &lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;original issue&lt;/a&gt; does not appear to be reproducible. The issue may no longer need to remain open.&lt;/p&gt;
&lt;p&gt;Not every attempt lands on the first try. What matters is understanding what happened, improving the process, and applying those lessons to the next contribution. Sometimes solving a hard problem takes multiple attempts - each one reveals something the previous approach missed. That&apos;s not necessarily a complete failure, that&apos;s how difficult problems get solved. The best response is better work next time.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>14 More PRs Merged Into Laravel -- Serializable Closure, Cloud CLI, and More</title><link>https://joshsalway.com/articles/14-more-prs-merged-into-laravel-serializable-closure-cloud-cli-and-more/</link><guid isPermaLink="true">https://joshsalway.com/articles/14-more-prs-merged-into-laravel-serializable-closure-cloud-cli-and-more/</guid><description>Another round of contributions: 14 PRs merged across 5 Laravel repos, with a deep dive into serializable-closure bug fixes.</description><pubDate>Wed, 08 Apr 2026 02:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Since my &lt;a href=&quot;https://joshsalway.com/20-prs-merged-into-laravel-in-12-days/&quot;&gt;last post&lt;/a&gt;, another &lt;strong&gt;14 pull requests have been merged across 5 Laravel repositories&lt;/strong&gt;. That brings the total to &lt;strong&gt;34 merged PRs across 16 repos&lt;/strong&gt; since mid-March.&lt;/p&gt;
&lt;p&gt;This time the focus shifted to a single package that needed serious attention: &lt;code&gt;laravel/serializable-closure&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Serializable Closure (9 merged)&lt;/h2&gt;
&lt;p&gt;I found a chain of bugs in the v2.x closure serialization engine. Each fix uncovered the next one. Here&apos;s the full list:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/129&quot;&gt;#129&lt;/a&gt; -- Fix v2.0.9 regression: Bus::chain breaks with nested closures&lt;/strong&gt; &lt;code&gt;[severity: critical, size: medium]&lt;/code&gt;
This was the critical one. The v2.0.9 release introduced a regression that broke &lt;code&gt;Bus::chain()&lt;/code&gt; when using nested closures. Queue jobs using closure chains were failing silently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/128&quot;&gt;#128&lt;/a&gt; -- Fix crash with method-only attributes on serialized closures&lt;/strong&gt; &lt;code&gt;[severity: high, size: small]&lt;/code&gt;
PHP 8 attributes that target only methods were causing serialization to crash when applied to closures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/135&quot;&gt;#135&lt;/a&gt; -- Fix SerializableClosure as class property being unwrapped during deserialization&lt;/strong&gt; &lt;code&gt;[severity: high, size: small]&lt;/code&gt;
When a &lt;code&gt;SerializableClosure&lt;/code&gt; was stored as a class property, deserialization was unwrapping it prematurely, breaking the expected type.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/137&quot;&gt;#137&lt;/a&gt; -- Fix operator precedence bug in scope detection for class keywords&lt;/strong&gt; &lt;code&gt;[severity: medium, size: tiny]&lt;/code&gt;
An operator precedence issue in the AST analysis was causing incorrect scope detection when class keywords like &lt;code&gt;self&lt;/code&gt;, &lt;code&gt;static&lt;/code&gt;, and &lt;code&gt;parent&lt;/code&gt; appeared in certain expressions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/140&quot;&gt;#140&lt;/a&gt; -- Fix instanceof with parenthesized expressions&lt;/strong&gt; &lt;code&gt;[severity: medium, size: small]&lt;/code&gt;
&lt;code&gt;instanceof&lt;/code&gt; checks with parenthesized expressions were being parsed incorrectly during serialization.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/141&quot;&gt;#141&lt;/a&gt; -- Add &lt;code&gt;true&lt;/code&gt; to builtin types list for PHP 8.2+&lt;/strong&gt; &lt;code&gt;[severity: medium, size: small]&lt;/code&gt;
PHP 8.2 added &lt;code&gt;true&lt;/code&gt; as a standalone type. The serializer didn&apos;t recognize it, causing type resolution failures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/146&quot;&gt;#146&lt;/a&gt; -- Bypass property hooks during serialization traversal on PHP 8.4+&lt;/strong&gt; &lt;code&gt;[severity: high, size: medium]&lt;/code&gt;
PHP 8.4 property hooks were interfering with the serialization traversal. This fix bypasses them safely.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/139&quot;&gt;#139&lt;/a&gt; -- Remove dead code and fix docblock errors. &lt;code&gt;[severity: low, size: tiny]&lt;/code&gt;
&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/143&quot;&gt;#143&lt;/a&gt; -- Register missing test in phpunit.xml.dist. &lt;code&gt;[severity: low, size: tiny]&lt;/code&gt;
&lt;a href=&quot;https://github.com/laravel/serializable-closure/pull/153&quot;&gt;#153&lt;/a&gt; -- Update README caveats. &lt;code&gt;[severity: low, size: tiny]&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Cloud CLI (2 merged, more in progress)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/111&quot;&gt;#111&lt;/a&gt; -- Fix Font::load crash on Windows&lt;/strong&gt; &lt;code&gt;[severity: high, size: tiny]&lt;/code&gt;
The Cloud CLI was crashing on Windows because font loading assumed Unix paths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/117&quot;&gt;#117&lt;/a&gt; -- Fix browser and file manager commands on Windows and Linux&lt;/strong&gt; &lt;code&gt;[severity: medium, size: small]&lt;/code&gt;
&lt;code&gt;cloud open&lt;/code&gt; and &lt;code&gt;cloud browse&lt;/code&gt; weren&apos;t working on Windows or Linux. Now they use the correct platform-specific commands.&lt;/p&gt;
&lt;p&gt;I&apos;ve put a lot of work into Cloud CLI. I originally opened a bunch of individual PRs, then consolidated them into one larger PR. A lot of the fixes were interconnected and didn&apos;t make sense in isolation, and having them together made it possible to run proper live integration tests against the Cloud API with all the changes in place. That turned out to be too much to review in one go, which is fair. So now I&apos;m going back through the individual issues and PRs one by one, finding where I can help the maintainers with what they&apos;re already working on without getting in the way of their direction. It&apos;s their project and they know best how they want to build it. I&apos;m just happy to see some of the same problems I flagged getting addressed in recent merges.&lt;/p&gt;
&lt;h2&gt;Telescope (1 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/telescope/pull/1710&quot;&gt;#1710&lt;/a&gt; -- Fix npm audit vulnerabilities (lodash, axios, rollup)&lt;/strong&gt; &lt;code&gt;[severity: medium, size: large]&lt;/code&gt;
Security dependency updates for the Telescope frontend assets.&lt;/p&gt;
&lt;h2&gt;Installer (1 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/installer/pull/497&quot;&gt;#497&lt;/a&gt; -- Fix Boost install on Windows: quote version constraint to protect caret&lt;/strong&gt; &lt;code&gt;[severity: high, size: tiny]&lt;/code&gt;
The &lt;code&gt;^&lt;/code&gt; in version constraints was being interpreted as an escape character by the Windows shell. Quoting it fixes the install.&lt;/p&gt;
&lt;h2&gt;The bigger picture&lt;/h2&gt;
&lt;p&gt;34 merged PRs, 16 repos, three weeks. What started as a handful of bug fixes turned into a deep audit of &lt;code&gt;serializable-closure&lt;/code&gt; -- a package that underpins queued jobs, event listeners, and anything that serializes closures in Laravel. The v2.0.9 regression fix alone was affecting anyone using &lt;code&gt;Bus::chain()&lt;/code&gt; with closures.&lt;/p&gt;
&lt;p&gt;Beyond what&apos;s merged, there&apos;s still a lot in the pipeline. Right now I have &lt;strong&gt;&lt;a href=&quot;https://github.com/pulls?q=is%3Apr+is%3Aopen+author%3AJoshSalway+org%3Alaravel+draft%3Afalse&quot;&gt;29 PRs open for review&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href=&quot;https://github.com/pulls?q=is%3Apr+is%3Aopen+author%3AJoshSalway+org%3Alaravel+draft%3Atrue&quot;&gt;43 drafts&lt;/a&gt;&lt;/strong&gt; across repos including cloud-cli, forge-cli, serializable-closure, framework, installer, reverb, horizon, echo, ai, cashier-paddle, and cashier-stripe. Each draft needs a fresh look against the latest code, updated tests, and sometimes a complete rework based on how the package has changed since I first opened it. I&apos;m working through them one by one. It&apos;s not fast work, but it&apos;s worth doing right.&lt;/p&gt;
&lt;p&gt;If you&apos;ve got feedback on any of my PRs, good or bad, I genuinely appreciate it. And if any of my drafts cover something you want to take on yourself, go for it. I&apos;d rather see the fix land than hold onto it. I&apos;ve already got plenty to work through.&lt;/p&gt;
&lt;p&gt;I&apos;m going to keep contributing. There&apos;s always more to fix.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Windows vs Mac for Claude Code: The Benchmark That Changed How I Work</title><link>https://joshsalway.com/articles/windows-vs-mac-for-claude-code-the-benchmark-that-changed-how-i-work/</link><guid isPermaLink="true">https://joshsalway.com/articles/windows-vs-mac-for-claude-code-the-benchmark-that-changed-how-i-work/</guid><description>I benchmarked my Ryzen 9950X3D against my M1 MacBook for real Laravel + Claude Code work. The results were not what I expected.</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I use Claude Code heavily - it&apos;s become central to how I work on Laravel contributions, building apps, and exploring codebases. I have two machines: a Ryzen 9 9950X3D desktop with 64GB RAM running Windows 11, and an M1 MacBook Pro with 16GB. The desktop has significantly more raw power, but something always felt off. The Mac &lt;em&gt;felt&lt;/em&gt; snappier when running Claude Code.&lt;/p&gt;
&lt;p&gt;To put the hardware gap in perspective, here are the Geekbench 6 scores. The &quot;My&quot; columns are from my actual machines (&lt;a href=&quot;https://browser.geekbench.com/v6/cpu/17519916&quot;&gt;9950X3D result&lt;/a&gt;, &lt;a href=&quot;https://browser.geekbench.com/v6/cpu/17520144&quot;&gt;M1 Pro result&lt;/a&gt;), and the &quot;Typical&quot; columns are published averages:&lt;/p&gt;
&lt;p&gt;| Benchmark | Typical 9950X3D | My 9950X3D | My M1 Pro | Typical M1 | 9950X3D vs M1 Pro |
|---|---|---|---|---|---|
| Geekbench 6 Single-Core | ~3,200 | 3,380 | 2,254 | ~2,400 | 1.5x |
| Geekbench 6 Multi-Core | ~23,000 | 22,160 | 10,422 | ~7,700 | 2.1x |&lt;/p&gt;
&lt;p&gt;The 9950X3D is roughly &lt;strong&gt;2x faster&lt;/strong&gt; in multi-core versus the M1 Pro I actually use. On paper, it should demolish the Mac at everything. That&apos;s what made the next part so surprising.&lt;/p&gt;
&lt;p&gt;So I built a benchmark to find out why.&lt;/p&gt;
&lt;h2&gt;What I tested&lt;/h2&gt;
&lt;p&gt;Not synthetic CPU scores - the actual operations that happen hundreds of times during a Claude Code session:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Process spawning&lt;/strong&gt; - every Read, Grep, Glob, and Bash call spawns a new process&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PHP execution&lt;/strong&gt; - running tests, artisan commands, composer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disk I/O&lt;/strong&gt; - reading and writing thousands of small files (like vendor/)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File search&lt;/strong&gt; - ripgrep and git grep across the Laravel framework&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git operations&lt;/strong&gt; - status, log, diff, branch listing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Node.js&lt;/strong&gt; - JSON processing and startup (Claude Code&apos;s internals)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I ran the benchmark three ways: Windows with Git Bash (what I&apos;d been using), WSL2 on the same machine, and the Mac.&lt;/p&gt;
&lt;h2&gt;The results&lt;/h2&gt;
&lt;p&gt;| Environment | Total Time |
|---|---|
| Windows Git Bash | &lt;strong&gt;11.9 seconds&lt;/strong&gt; |
| WSL2 (same PC) | &lt;strong&gt;2.8 seconds&lt;/strong&gt; |
| Mac M1 | &lt;strong&gt;2.6 seconds&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;The Windows PC - with a 16-core, 32-thread 9950X3D and 64GB of RAM - was &lt;strong&gt;4.3x slower&lt;/strong&gt; than the same hardware running through WSL.&lt;/p&gt;
&lt;h2&gt;The smoking gun: process spawning&lt;/h2&gt;
&lt;p&gt;The single biggest bottleneck was process creation. Claude Code shells out constantly - git, php, grep, find, node - sometimes hundreds of times in a single conversation. Here&apos;s what 50 PHP process spawns looked like:&lt;/p&gt;
&lt;p&gt;| Environment | 50x PHP Spawn |
|---|---|
| Windows Git Bash | &lt;strong&gt;3,244ms&lt;/strong&gt; |
| WSL2 | &lt;strong&gt;457ms&lt;/strong&gt; |
| Mac M1 | &lt;strong&gt;31ms&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;That&apos;s not a typo. Windows was &lt;strong&gt;100x slower&lt;/strong&gt; than the Mac at spawning PHP processes, and &lt;strong&gt;7x slower&lt;/strong&gt; than WSL on the same CPU.&lt;/p&gt;
&lt;p&gt;This isn&apos;t a CPU problem - it&apos;s fundamental to how Windows creates processes. Linux and macOS use &lt;code&gt;fork()&lt;/code&gt;, which is nearly free because it shares the parent process&apos;s memory pages until they&apos;re modified. Windows uses &lt;code&gt;CreateProcess()&lt;/code&gt;, which has to load DLLs, set up security tokens, and initialize the environment from scratch every time. Add the Herd &lt;code&gt;.bat&lt;/code&gt; wrapper on top and each PHP invocation carries real overhead.&lt;/p&gt;
&lt;p&gt;Git spawning showed the same pattern - 791ms on Git Bash, 36ms on WSL. That&apos;s a &lt;strong&gt;22x difference&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Where WSL actually beats the Mac&lt;/h2&gt;
&lt;p&gt;Once I switched to WSL, the PC didn&apos;t just match the Mac - it won in several categories:&lt;/p&gt;
&lt;p&gt;| Test | WSL | Mac M1 | Winner |
|---|---|---|---|
| Write 2000 files | 24ms | 215ms | &lt;strong&gt;WSL (9x)&lt;/strong&gt; |
| Read 2000 files | 17ms | 50ms | &lt;strong&gt;WSL (3x)&lt;/strong&gt; |
| 50x git spawn | 36ms | 371ms | &lt;strong&gt;WSL (10x)&lt;/strong&gt; |
| 50x node spawn | 576ms | 1,575ms | &lt;strong&gt;WSL (2.7x)&lt;/strong&gt; |
| Node JSON 50k | 76ms | 92ms | &lt;strong&gt;WSL&lt;/strong&gt; |
| Composer validate | 105ms | N/A | - |
| PHPUnit (2 suites) | 123ms | N/A | - |&lt;/p&gt;
&lt;p&gt;WSL&apos;s ext4 filesystem crushed APFS on small file operations. Git spawning was 10x faster. The 9950X3D&apos;s raw power finally showed through once the Windows overhead was removed.&lt;/p&gt;
&lt;p&gt;The Mac still wins on raw PHP execution - 2ms vs 33ms for the workload test. ARM silicon is remarkably efficient at this. But PHP execution speed matters less for Claude Code since it&apos;s the process &lt;em&gt;spawning&lt;/em&gt;, not the execution, that&apos;s the bottleneck.&lt;/p&gt;
&lt;h2&gt;The RAM advantage&lt;/h2&gt;
&lt;p&gt;This is the other half of the story. The M1 MacBook has 16GB. My desktop has 64GB.&lt;/p&gt;
&lt;p&gt;While running the benchmark, I checked task manager. I had &lt;strong&gt;three Claude Code instances&lt;/strong&gt; running alongside Chrome, Discord, Spotify, NVIDIA Broadcast, and Steam - using 20GB total with &lt;strong&gt;41GB still free&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;On the Mac, running three Claude Code sessions simultaneously would mean swapping to disk. On the PC, I&apos;m at 33% utilisation. I could run 4-5 Claude Code sessions in parallel without thinking about it. For multi-repo work - fixing a framework PR in one terminal while building a feature in another and running tests in a third - that headroom matters.&lt;/p&gt;
&lt;h2&gt;What I changed&lt;/h2&gt;
&lt;p&gt;The bottleneck was never the hardware. It was Windows.&lt;/p&gt;
&lt;p&gt;For those unfamiliar, &lt;a href=&quot;https://learn.microsoft.com/en-us/windows/wsl/&quot;&gt;WSL (Windows Subsystem for Linux)&lt;/a&gt; is a feature built into Windows that lets you run a full Linux environment directly on your machine - no virtual machine setup, no dual booting. You can access it from any terminal on Windows: open PowerShell, Windows Terminal, or even Git Bash and type &lt;code&gt;wsl&lt;/code&gt; to drop into a Linux shell. From there, you get native Linux performance for all your CLI tools.&lt;/p&gt;
&lt;p&gt;I now default to WSL for all CLI-heavy operations - not just git (which I was already doing for CRLF reasons). Tests, composer, file search, anything that involves repeated process spawns. The key detail: keep repos on WSL&apos;s &lt;strong&gt;native ext4 filesystem&lt;/strong&gt; (&lt;code&gt;~/Herd/framework&lt;/code&gt;), not on the mounted Windows drive (&lt;code&gt;/mnt/c/...&lt;/code&gt;). The 9P filesystem bridge between Windows and WSL adds its own overhead.&lt;/p&gt;
&lt;p&gt;I also updated Claude Code&apos;s persistent memory with recommendations based on these benchmarks - telling it to prefer WSL for all CLI-heavy operations on Windows. Now when I start a new session, Claude Code already knows to route commands through WSL where possible, without me having to remind it every time. It&apos;s a small thing, but it means the performance gains carry forward automatically across every future conversation.&lt;/p&gt;
&lt;h2&gt;Build your own benchmark&lt;/h2&gt;
&lt;p&gt;The benchmark script is portable - it auto-detects your OS, finds PHP/Composer/Node/ripgrep, and tests against a Laravel framework checkout if one exists. If you want to test your own setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone git@github.com:JoshSalway/dev-workflow-benchmark.git
cd dev-workflow-benchmark
bash dev-workflow-benchmark.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It saves results to a file, and there&apos;s a &lt;code&gt;compare.sh&lt;/code&gt; script for side-by-side comparison with colour-coded output.&lt;/p&gt;
&lt;h2&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;If you&apos;re a developer on Windows using Claude Code (or any tool that shells out frequently), and things feel slower than they should - try WSL. The CPU isn&apos;t the problem. Windows process creation is. WSL gives you Linux-native speeds on the same hardware, and if your machine has decent RAM, you get parallelism that a MacBook can&apos;t match.&lt;/p&gt;
&lt;p&gt;My 9950X3D went from the slowest machine in this test to effectively tied for fastest - just by changing which shell I run things in.&lt;/p&gt;
&lt;h2&gt;Update: a second speedup, same bottleneck&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Added 2026-04-21.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The finding above is about process creation. Every shell-out Claude Code makes pays the Windows &lt;code&gt;CreateProcess&lt;/code&gt; tax, and WSL removes it. &lt;a href=&quot;https://github.com/dmtrKovalenko/fff.nvim&quot;&gt;fff-mcp&lt;/a&gt; attacks the same bottleneck from the other side: keep a single long-lived process alive, and stop shelling out for search entirely.&lt;/p&gt;
&lt;p&gt;fff is a Rust MCP server that indexes a repo once, then answers grep and file-find queries from a warm in-memory cache. Claude Code spawns it at session start and keeps it alive for the whole conversation. Every search after the first is a round-trip to a warm daemon, not a fresh &lt;code&gt;rg&lt;/code&gt; invocation.&lt;/p&gt;
&lt;p&gt;I re-ran a focused benchmark on the same Windows machine, still under Git Bash so the original Windows spawn penalty is still in play. Test repo: the Filament framework, 7,794 git-tracked files. Workload: five bare-identifier grep queries (&lt;code&gt;Filament&lt;/code&gt;, &lt;code&gt;TableAction&lt;/code&gt;, &lt;code&gt;HasForms&lt;/code&gt;, &lt;code&gt;Livewire&lt;/code&gt;, &lt;code&gt;Authorize&lt;/code&gt;), median of three runs.&lt;/p&gt;
&lt;p&gt;| Metric | ripgrep (before) | fff-mcp (after) |
|---|---|---|
| Per-query, warm | 91.4ms | 6.6ms |
| 5-query session total | 458ms | 83ms |&lt;/p&gt;
&lt;p&gt;That is &lt;strong&gt;14.0x faster per warm query&lt;/strong&gt; and &lt;strong&gt;5.5x faster over the full 5-query session&lt;/strong&gt;, after including fff&apos;s one-time startup cost (10ms handshake plus 48ms initial scan). Startup amortises after the second query. A real session makes dozens.&lt;/p&gt;
&lt;p&gt;The two fixes stack. WSL gives you Linux-native process creation, so every remaining shell-out is cheap. fff removes the shell-out entirely for search, so the cheapest launch is the one that never happens. Combined, agent search feels interactive rather than batched.&lt;/p&gt;
&lt;p&gt;Install is one line plus one registration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
claude mcp add -s user fff -- ~/.local/bin/fff-mcp --no-update-check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--no-update-check&lt;/code&gt; flag keeps the server from phoning GitHub on startup. Source is MIT, over 5,000 stars, and actively maintained (stable v0.6.1 shipped two days before I ran this benchmark, nightly the day before).&lt;/p&gt;
&lt;p&gt;If you want a video walkthrough of the same tool, this one covers the install and rationale in more depth:&lt;/p&gt;
&lt;p&gt;On Mac (M1, macOS), the gap is narrower but still real. I re-ran the same five-query workload against a larger repo (a private project, 36,347 files). The win is smaller because macOS process creation is already fast (Unix fork vs Windows CreateProcess), but over a full session with dozens of searches the savings still add up.&lt;/p&gt;
&lt;p&gt;| | Windows (Git Bash, 7,794 files) | Mac (M1, 36,347 files) |
|---|---|---|
| ripgrep per query | 91.4ms | ~950ms |
| fff-mcp per query (warm) | 6.6ms | ~229ms |
| Speedup | &lt;strong&gt;14x&lt;/strong&gt; | &lt;strong&gt;~4x&lt;/strong&gt; |
| 5-query session | 458ms vs 83ms | ~4,750ms vs ~1,150ms |&lt;/p&gt;
&lt;p&gt;The Windows speedup is larger because &lt;code&gt;CreateProcess&lt;/code&gt; is the bottleneck there, and fff removes it entirely. On Mac, process creation is cheap already, so the gain comes from skipping the disk scan rather than avoiding spawn overhead.&lt;/p&gt;
&lt;p&gt;Same pattern as the WSL change above: I updated Claude Code&apos;s persistent memory so future sessions default to fff for content search in git repos. The speedup carries forward without me having to think about it.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator><atom:updated>2026-04-21T00:00:00.000Z</atom:updated></item><item><title>20 PRs Merged Into Laravel In 12 Days</title><link>https://joshsalway.com/articles/20-prs-merged-into-laravel-in-12-days/</link><guid isPermaLink="true">https://joshsalway.com/articles/20-prs-merged-into-laravel-in-12-days/</guid><description>A reflection on contributing 20 merged pull requests across 12 Laravel repositories in under two weeks.</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Over the past couple of weeks I went deep into the Laravel ecosystem -- hunting bugs, fixing CI pipelines, patching Windows compatibility, and squashing memory leaks. &lt;strong&gt;20 pull requests were merged across 12 Laravel repositories&lt;/strong&gt; between March 18-29. Around 1,000 lines of code added.&lt;/p&gt;
&lt;p&gt;Here&apos;s everything that landed.&lt;/p&gt;
&lt;h2&gt;Framework (4 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59376&quot;&gt;#59376&lt;/a&gt; -- Fix incrementEach/decrementEach to scope to model instance&lt;/strong&gt;
This was the big one. &lt;code&gt;incrementEach&lt;/code&gt; and &lt;code&gt;decrementEach&lt;/code&gt; weren&apos;t scoping to the model instance -- they were updating every row in the table. A data corruption bug hiding in plain sight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59331&quot;&gt;#59331&lt;/a&gt; -- Fix sub-minute scheduling skips at minute boundaries&lt;/strong&gt;
A Carbon mutation bug. &lt;code&gt;endOfMinute()&lt;/code&gt; was mutating the shared &lt;code&gt;$startedAt&lt;/code&gt; instance instead of a copy, causing sub-minute scheduled tasks to skip executions at minute boundaries. A minimal fix with full test coverage, zero breaking changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59323&quot;&gt;#59323&lt;/a&gt; -- Remove unnecessary clone in SessionManager to prevent duplicate Redis connections&lt;/strong&gt;
An unnecessary &lt;code&gt;clone&lt;/code&gt; in &lt;code&gt;SessionManager&lt;/code&gt; was silently creating duplicate Redis connections.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/pull/59309&quot;&gt;#59309&lt;/a&gt; -- Bound error page query listener to prevent memory bloat in Octane&lt;/strong&gt;
The debug error page&apos;s query listener was storing unbounded SQL strings and bindings. A single bulk insert could inflate an Octane worker&apos;s memory by 120MB+. Only affects local dev (&lt;code&gt;APP_DEBUG=true&lt;/code&gt;), but three previous PRs had tried and failed to fix it.&lt;/p&gt;
&lt;h2&gt;Windows Compatibility (2 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/prompts/pull/232&quot;&gt;Prompts #232&lt;/a&gt; -- Fix trailing newline calculation for Windows PHP_EOL&lt;/strong&gt;
Windows uses &lt;code&gt;\r\n&lt;/code&gt; -- the newline calculation wasn&apos;t accounting for that. 87 lines added with proper test coverage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/installer/pull/478&quot;&gt;Installer #478&lt;/a&gt; -- Fix interactive subprocess stdin on native Windows&lt;/strong&gt;
The Laravel installer&apos;s interactive subprocesses weren&apos;t working on native Windows. This makes the onboarding experience work properly for Windows devs.&lt;/p&gt;
&lt;h2&gt;Dusk (3 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/dusk/pull/1189&quot;&gt;#1189&lt;/a&gt; -- Fix findButtonByText to prefer exact text match over contains&lt;/strong&gt;
When you have buttons with similar text, Dusk was matching the wrong one. Now it prefers exact matches.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/laravel/dusk/pull/1192&quot;&gt;#1192&lt;/a&gt; -- Fix typo in teardown method name.
&lt;a href=&quot;https://github.com/laravel/dusk/pull/1193&quot;&gt;#1193&lt;/a&gt; -- Fix docblock typo in assertVueContains.&lt;/p&gt;
&lt;h2&gt;Reverb (1 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/reverb/pull/374&quot;&gt;#374&lt;/a&gt; -- Include server path in broadcasting connection config&lt;/strong&gt;
Important for anyone running Reverb behind a reverse proxy with a path prefix.&lt;/p&gt;
&lt;h2&gt;CI &amp;#x26; Test Compatibility (9 merged)&lt;/h2&gt;
&lt;p&gt;Several packages needed their CI pipelines and test suites updated for Laravel 13, Livewire v4, and PHPUnit 12:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pulse&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/pulse/pull/499&quot;&gt;#499&lt;/a&gt;, &lt;a href=&quot;https://github.com/laravel/pulse/pull/500&quot;&gt;#500&lt;/a&gt;, &lt;a href=&quot;https://github.com/laravel/pulse/pull/501&quot;&gt;#501&lt;/a&gt;, &lt;a href=&quot;https://github.com/laravel/pulse/pull/502&quot;&gt;#502&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Telescope&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/telescope/pull/1703&quot;&gt;#1703&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Socialite&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/socialite/pull/769&quot;&gt;#769&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Envoy&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/envoy/pull/291&quot;&gt;#291&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cashier Paddle&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/cashier-paddle/pull/315&quot;&gt;#315&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vapor Core&lt;/strong&gt; -- &lt;a href=&quot;https://github.com/laravel/vapor-core/pull/201&quot;&gt;#201&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Sentinel (1 merged)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/sentinel/pull/4&quot;&gt;#4&lt;/a&gt; -- Fix typo in CI workflow exclusion matrix&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Reflecting on it&lt;/h2&gt;
&lt;p&gt;What I love about this batch is the range -- from a data corruption bug in the framework core, to Windows compatibility fixes, broadcasting config, and CI pipelines across a dozen repos. The big framework fixes all have proper tests, and I tested the Windows-specific fixes across both Windows 11 and macOS because the Laravel team is predominantly on Mac and some platform edge cases slip through.&lt;/p&gt;
&lt;p&gt;20 PRs, 12 repos, 12 days. I use Laravel every single day and being able to give back to the framework and help the team out is genuinely one of the most rewarding things I do.&lt;/p&gt;
&lt;p&gt;Still got a few more open -- keeping at it.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Day 2 Update: Unblocked -- Full PR List, Confidence Ratings, and What&apos;s Next</title><link>https://joshsalway.com/articles/update-unblocked-25-laravel-fixes-and-whats-next/</link><guid isPermaLink="true">https://joshsalway.com/articles/update-unblocked-25-laravel-fixes-and-whats-next/</guid><description>I was unblocked from the Laravel GitHub org yesterday. Here&apos;s the full list of 91 PRs across 27 repos, confidence percentages, and what still needs review.</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is a follow-up to &lt;a href=&quot;/25-laravel-bug-fixes-tested-verified-ready-to-merge&quot;&gt;yesterday&apos;s post&lt;/a&gt;. I was unblocked from the Laravel GitHub organisation yesterday -- thank you.&lt;/p&gt;
&lt;p&gt;Since being unblocked, I&apos;ve been submitting the fixes I had queued up. The scope has grown well beyond the original 25 patches. Here&apos;s the full accounting of everything that&apos;s been submitted, what still lives on forks/gists, confidence levels for each fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Master tracking gist:&lt;/strong&gt; &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;GitHub Gist&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The numbers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;91 PRs&lt;/strong&gt; submitted to upstream laravel/* repos&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;39 open&lt;/strong&gt;, 45 closed, &lt;strong&gt;1 merged&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;26 repos&lt;/strong&gt; touched&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;30 gists&lt;/strong&gt; with standalone patches&lt;/li&gt;
&lt;li&gt;Plus &lt;strong&gt;100 PRs&lt;/strong&gt; on my personal forks (many overlap with upstream submissions)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I know that&apos;s a lot. Apologies for the influx -- I&apos;ve been trying to submit gradually and push to my own forks first to avoid overwhelming maintainers. Many of the closed ones were closed by maintainers (understandably), and the fixes for those still live on my forks if anyone wants to pick them up.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Context&lt;/h2&gt;
&lt;p&gt;I&apos;ve been using &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt; to systematically work through open issues across the Laravel ecosystem. Every fix targets an existing open issue that already has community reports. The process was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find issues with multiple reports or long comment threads&lt;/li&gt;
&lt;li&gt;Have Claude Code analyse the root cause and write a fix&lt;/li&gt;
&lt;li&gt;Run the repo&apos;s full test suite&lt;/li&gt;
&lt;li&gt;Test on my Windows machine (and macOS where applicable)&lt;/li&gt;
&lt;li&gt;Document before/after results&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I develop sometimes on &lt;strong&gt;Windows 11 Pro&lt;/strong&gt; (AMD Ryzen 9 9950X3D, PHP 8.4 via Herd, WSL2, Docker Desktop). A lot of the Windows-specific issues I have experienced first hand from using it. The Laravel team is predominantly on macOS, so some Windows edge cases are easy to miss --  it is a different testing surface that I happen to cover. I&apos;ve also verified the cross-platform fixes on &lt;strong&gt;macOS&lt;/strong&gt; (Darwin 25.3.0, PHP 8.4.19) Mac 2021 M1 16gb which is getting old now but still good.&lt;/p&gt;
&lt;p&gt;These PRs may still need proper review and testing and need to consider edge cases that I may not be aware of. Laravel team also have to consider if they want to support the features and changes I have added. I&apos;m not expecting anything to be merged on faith. Some may need adjustments, and I&apos;m happy to iterate. Anyone can replicate what I&apos;ve done by pointing Claude Code at these same issues -- the fixes themselves aren&apos;t special, but hopefully the testing and documentation saves some review time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Already merged (1)&lt;/h2&gt;
&lt;p&gt;This one made it in:&lt;/p&gt;
&lt;p&gt;| Repo | PR | Title |
|------|-----|-------|
| laravel/dusk | &lt;a href=&quot;https://github.com/laravel/dusk/pull/1189&quot;&gt;#1189&lt;/a&gt; | Fix findButtonByText to prefer exact text match over contains |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Open upstream PRs -- by repo&lt;/h2&gt;
&lt;h3&gt;laravel/cloud-cli (29 open)&lt;/h3&gt;
&lt;p&gt;This was the deepest dive. The cloud CLI is relatively new and had a lot of low-hanging fruit -- bug fixes, missing features, and test coverage. It also adds some additional features (nice to haves). I have combined all these efforts into 1 big PR on my own fork and will be testing and reviewing the forked cloud-cli and reporting on any bugs or issues I find from actual real world testing.&lt;/p&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/25&quot;&gt;#25&lt;/a&gt; | Fix token deduplication and stale token accumulation | 85% | Straightforward data cleanup |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/32&quot;&gt;#32&lt;/a&gt; | Add --hide-secrets flag to redact env var values | 80% | New feature, clean implementation |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/33&quot;&gt;#33&lt;/a&gt; | Add --token flag and LARAVEL_CLOUD_API_TOKEN env var support | 80% | Standard CLI auth pattern |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/34&quot;&gt;#34&lt;/a&gt; | Fix inconsistent error handling between commands and resolvers | 75% | Touches many files -- needs careful review |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/35&quot;&gt;#35&lt;/a&gt; | Fix token validation performance with cached org names | 80% | Performance improvement |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/36&quot;&gt;#36&lt;/a&gt; | Enable commented-out application command tests | 70% | Tests may have been disabled for a reason |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/37&quot;&gt;#37&lt;/a&gt; | Improve resolver error messages with specific failure context | 85% | UX improvement, additive |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/42&quot;&gt;#42&lt;/a&gt; | Fix wrong RequestException import in 7 commands | 90% | Clear bug -- wrong class imported |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/43&quot;&gt;#43&lt;/a&gt; | Comprehensive test coverage -- 87 files, 378 tests, 3% to 100% | 65% | Massive -- needs thorough review |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/52&quot;&gt;#52&lt;/a&gt; | Fix 5 bugs found during test coverage work | 85% | Found while writing tests |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/53&quot;&gt;#53&lt;/a&gt; | Add missing CLI options for non-interactive create commands | 75% | Feature addition |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/54&quot;&gt;#54&lt;/a&gt; | Refactor: decompose Ship.php from 788 to 152 lines | 60% | Major refactor -- may not match team&apos;s vision |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/58&quot;&gt;#58&lt;/a&gt; | Fix database command bugs -- restore, snapshot, cluster update | 80% | Multiple targeted fixes |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/63&quot;&gt;#63&lt;/a&gt; | Add --hide-secrets flag to redact connection credentials | 80% | Security feature |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/64&quot;&gt;#64&lt;/a&gt; | Add global --application and --environment flags | 70% | Opinionated UX change |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/70&quot;&gt;#70&lt;/a&gt; | Add environment:start and environment:stop commands | 70% | New commands -- depends on API support |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/71&quot;&gt;#71&lt;/a&gt; | Add deployment:logs command | 75% | New command |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/72&quot;&gt;#72&lt;/a&gt; | Add env:variables delete and metrics commands | 70% | New commands |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/74&quot;&gt;#74&lt;/a&gt; | Fix 8 reliability issues in cloud ship command | 80% | Bug fixes in critical path |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/75&quot;&gt;#75&lt;/a&gt; | Auto-show deployment logs on failure | 85% | UX improvement |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/80&quot;&gt;#80&lt;/a&gt; | Add command aliases and cloud use context command | 70% | Opinionated UX |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/81&quot;&gt;#81&lt;/a&gt; | Add env:pull and env:push commands for .env file sync | 75% | New feature |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/82&quot;&gt;#82&lt;/a&gt; | Add --dry-run flag for deploy and ship commands | 80% | Safety feature |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/87&quot;&gt;#87&lt;/a&gt; | Fix credential leak in DatabaseOpen and remove dead code | 90% | Security fix |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/90&quot;&gt;#90&lt;/a&gt; | Fix InstanceDelete return constant and AuthToken empty tokens guard | 85% | Small targeted fixes |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/92&quot;&gt;#92&lt;/a&gt; | Fix incorrect documentation in markdown files | 90% | Docs only |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/95&quot;&gt;#95&lt;/a&gt; | Fix hardcoded URLs for private Cloud compatibility | 85% | Makes self-hosted work |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/101&quot;&gt;#101&lt;/a&gt; | Add cloud status command | 75% | New command |
| &lt;a href=&quot;https://github.com/laravel/cloud-cli/pull/102&quot;&gt;#102&lt;/a&gt; | Add debug, health check, and ci:setup commands | 70% | New commands |&lt;/p&gt;
&lt;h3&gt;laravel/forge-cli (4 open)&lt;/h3&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/forge-cli/pull/109&quot;&gt;#109&lt;/a&gt; | Fix critical bugs: missing DB types, null references, path logic | 85% | Multiple clear bugs |
| &lt;a href=&quot;https://github.com/laravel/forge-cli/pull/110&quot;&gt;#110&lt;/a&gt; | Fix typo, password exposure, version check error handling | 85% | Security + UX |
| &lt;a href=&quot;https://github.com/laravel/forge-cli/pull/111&quot;&gt;#111&lt;/a&gt; | Hide unimplemented command, centralise DB types, null safety | 75% | Code quality |
| &lt;a href=&quot;https://github.com/laravel/forge-cli/pull/125&quot;&gt;#125&lt;/a&gt; | Fix php_uname() ValueError on PHP 8.4+ | 95% | Clear PHP version compat fix |&lt;/p&gt;
&lt;h3&gt;laravel/forge-sdk (2 open)&lt;/h3&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/forge-sdk/pull/206&quot;&gt;#206&lt;/a&gt; | Fix missing return values, URL inconsistencies, comparison operators | 80% | Multiple small fixes |
| &lt;a href=&quot;https://github.com/laravel/forge-sdk/pull/207&quot;&gt;#207&lt;/a&gt; | Fix incorrect and inconsistent docstrings | 75% | Docs |&lt;/p&gt;
&lt;h3&gt;laravel/forge-monitor (2 open)&lt;/h3&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/forge-monitor/pull/76&quot;&gt;#76&lt;/a&gt; | Fix critical bugs: incorrect import, null returns, SQL interpolation | 85% | Security + correctness |
| &lt;a href=&quot;https://github.com/laravel/forge-monitor/pull/77&quot;&gt;#77&lt;/a&gt; | Improve error handling in commands, notifications, config validation | 75% | Hardening |&lt;/p&gt;
&lt;h3&gt;laravel/forge-database-backups (1 open)&lt;/h3&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/forge-database-backups/pull/10&quot;&gt;#10&lt;/a&gt; | Fix security vulnerabilities and improve reliability | 80% | Security fixes in backup script |&lt;/p&gt;
&lt;h3&gt;laravel/larachat (1 open)&lt;/h3&gt;
&lt;p&gt;| PR | Title | Confidence | Notes |
|----|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/laravel/larachat/pull/6&quot;&gt;#6&lt;/a&gt; | Migrate to Laravel AI SDK with conversation memory | 60% | Large migration -- may not match direction |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Fixes on forks (not yet submitted upstream)&lt;/h2&gt;
&lt;p&gt;These are ready to submit gradually. All live on my GitHub forks with full PR descriptions and test results.&lt;/p&gt;
&lt;h3&gt;Windows Fixes&lt;/h3&gt;
&lt;p&gt;| Fork PR | Issue | Title | Confidence | Notes |
|---------|-------|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/1&quot;&gt;JoshSalway/sail #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/sail/issues/850&quot;&gt;sail #850&lt;/a&gt; | Windows/WSL detection in sail script | 85% | Tested on Git Bash + Windows PHP |
| &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/2&quot;&gt;JoshSalway/sail #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/sail/issues/843&quot;&gt;sail #843&lt;/a&gt; | .sh -&gt; .sql for testing DB init | 95% | Matches existing PostgreSQL approach |
| &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/3&quot;&gt;JoshSalway/sail #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/sail/issues/815&quot;&gt;sail #815&lt;/a&gt; | xvfb-run for headed browser tests | 80% | Works in Docker, transparent for non-browser |
| &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/4&quot;&gt;JoshSalway/sail #4&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/sail/issues/809&quot;&gt;sail #809&lt;/a&gt; | Update sail share default host/port | 90% | Config fix |
| &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/1&quot;&gt;JoshSalway/octane #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/octane/issues/1100&quot;&gt;octane #1100&lt;/a&gt; | POSIX signal constant fallbacks for Windows | 90% | &lt;code&gt;defined()&lt;/code&gt; checks are safe, zero behaviour change on Linux/macOS |
| &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/3&quot;&gt;JoshSalway/octane #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/octane/issues/1077&quot;&gt;octane #1077&lt;/a&gt; | Propagate #[Singleton] attribute bindings | 75% | Needs real Octane testing |
| &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/4&quot;&gt;JoshSalway/octane #4&lt;/a&gt; | octane | Fix null worker crash in Swoole during reload | 80% | Edge case fix |
| &lt;a href=&quot;https://github.com/JoshSalway/prompts/pull/1&quot;&gt;JoshSalway/prompts #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/prompts/issues/201&quot;&gt;prompts #201&lt;/a&gt; | Static spinner for --no-ansi | 85% | Fallback rendering |
| &lt;a href=&quot;https://github.com/JoshSalway/prompts/pull/2&quot;&gt;JoshSalway/prompts #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/prompts/issues/189&quot;&gt;prompts #189&lt;/a&gt; | Fix nested Symfony style tags | 80% | Regex fix |
| &lt;a href=&quot;https://github.com/JoshSalway/installer/pull/1&quot;&gt;JoshSalway/installer #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/installer/issues/410&quot;&gt;installer #410&lt;/a&gt; | Only run post-root-package-install if exists | 90% | Guard check |
| &lt;a href=&quot;https://github.com/JoshSalway/installer/pull/2&quot;&gt;JoshSalway/installer #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/installer/issues/465&quot;&gt;installer #465&lt;/a&gt; | Apply package manager to GitHub workflows | 85% | Template fix |
| &lt;a href=&quot;https://github.com/JoshSalway/installer/pull/3&quot;&gt;JoshSalway/installer #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/installer/issues/441&quot;&gt;installer #441&lt;/a&gt; | Remove unsupported formatting tags | 90% | Simple removal |
| &lt;a href=&quot;https://github.com/JoshSalway/installer/pull/4&quot;&gt;JoshSalway/installer #4&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/installer/issues/471&quot;&gt;installer #471&lt;/a&gt; | Prevent infinite loop on self-update failure | 85% | Guard against edge case |
| &lt;a href=&quot;https://github.com/JoshSalway/wayfinder/pull/1&quot;&gt;JoshSalway/wayfinder #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/160&quot;&gt;wayfinder #160&lt;/a&gt; | Fix TypeError on inline variable assignment | 80% | Edge case in TS generation |
| &lt;a href=&quot;https://github.com/JoshSalway/wayfinder/pull/2&quot;&gt;JoshSalway/wayfinder #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/190&quot;&gt;wayfinder #190&lt;/a&gt; | Fix array_is_list TypeError on overlapping paths | 85% | Clear bug |
| &lt;a href=&quot;https://github.com/JoshSalway/wayfinder/pull/3&quot;&gt;JoshSalway/wayfinder #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/195&quot;&gt;wayfinder #195&lt;/a&gt; | Emit generic type parameters in TS conversion | 75% | TypeScript edge case |&lt;/p&gt;
&lt;h3&gt;Cross-Platform Fixes&lt;/h3&gt;
&lt;p&gt;| Fork PR | Issue | Title | Confidence | Notes |
|---------|-------|-------|:----------:|-------|
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/2&quot;&gt;JoshSalway/framework #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/56652&quot;&gt;framework #56652&lt;/a&gt; | Fix memory leak in query log for Octane workers | 85% | Clear leak |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/4&quot;&gt;JoshSalway/framework #4&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;framework #58377&lt;/a&gt; | Remove unnecessary clone in SessionManager | 95% | One-line fix, 93 tests pass |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/5&quot;&gt;JoshSalway/framework #5&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/57456&quot;&gt;framework #57456&lt;/a&gt; | Fix previousPath() for external referrers | 85% | URL handling fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/6&quot;&gt;JoshSalway/framework #6&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/59012&quot;&gt;framework #59012&lt;/a&gt; | Fix TypeError in Http::retry() with null callback | 90% | Type safety fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/7&quot;&gt;JoshSalway/framework #7&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/57961&quot;&gt;framework #57961&lt;/a&gt; | Catch UniqueConstraintViolationException in session | 85% | Race condition guard |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/8&quot;&gt;JoshSalway/framework #8&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/59024&quot;&gt;framework #59024&lt;/a&gt; | Locale-independent MySQL lost connection detection | 80% | Internationalisation fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/9&quot;&gt;JoshSalway/framework #9&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/framework/issues/53278&quot;&gt;framework #53278&lt;/a&gt; | Skip maintenance.php with cache driver | 85% | Logic fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/10&quot;&gt;JoshSalway/framework #10&lt;/a&gt; | framework | Fix File::types() discarding chain config | 80% | Validation fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/11&quot;&gt;JoshSalway/framework #11&lt;/a&gt; | framework | Fix inconsistent accessor attribute name conversion | 75% | Edge case |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/12&quot;&gt;JoshSalway/framework #12&lt;/a&gt; | framework | Add configurable key prefix to ThrottleRequestsWithRedis | 70% | Feature addition |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/13&quot;&gt;JoshSalway/framework #13&lt;/a&gt; | framework | Fix FileStore cache deserialization with sub-10-digit timestamps | 80% | Edge case fix |
| &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/14&quot;&gt;JoshSalway/framework #14&lt;/a&gt; | framework | Set working directory for schedule:work subprocess | 85% | Path resolution fix |
| &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/1&quot;&gt;JoshSalway/cashier-stripe #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1817&quot;&gt;cashier-stripe #1817&lt;/a&gt; | Webhook reconciliation for failed swap payments | 75% | Has a consistency window -- needs review |
| &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/3&quot;&gt;JoshSalway/cashier-stripe #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1773&quot;&gt;cashier-stripe #1773&lt;/a&gt; | Fix subscription type race condition | 80% | Concurrency fix |
| &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/4&quot;&gt;JoshSalway/cashier-stripe #4&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1824&quot;&gt;cashier-stripe #1824&lt;/a&gt; | Fix webhook failing to clear trial_ends_at | 90% | Clear null-handling bug |
| &lt;a href=&quot;https://github.com/JoshSalway/cashier-paddle/pull/1&quot;&gt;JoshSalway/cashier-paddle #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/cashier-paddle/issues/310&quot;&gt;cashier-paddle #310&lt;/a&gt; | Webhook race conditions in subscription creation | 80% | Same pattern as Stripe fix |
| &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/1&quot;&gt;JoshSalway/horizon #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/horizon/issues/1678&quot;&gt;horizon #1678&lt;/a&gt; | Release unique job locks when clearing queues | 85% | Lock cleanup |
| &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/2&quot;&gt;JoshSalway/horizon #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/horizon/issues/1704&quot;&gt;horizon #1704&lt;/a&gt; | Scope WaitTimeCalculator to supervisor queues | 80% | Metric accuracy |
| &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/3&quot;&gt;JoshSalway/horizon #3&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/horizon/issues/1698&quot;&gt;horizon #1698&lt;/a&gt; | Fix null access in JobPayload::id() for corrupt jobs | 90% | Null guard |
| &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/4&quot;&gt;JoshSalway/horizon #4&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/horizon/issues/1668&quot;&gt;horizon #1668&lt;/a&gt; | Fix &apos;delayed until&apos; showing wrong time | 85% | Display bug |
| &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/5&quot;&gt;JoshSalway/horizon #5&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/horizon/issues/1647&quot;&gt;horizon #1647&lt;/a&gt; | Fix AutoScaler not respecting minProcesses on startup | 80% | Scaling logic |
| &lt;a href=&quot;https://github.com/JoshSalway/telescope/pull/1&quot;&gt;JoshSalway/telescope #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/telescope/issues/1691&quot;&gt;telescope #1691&lt;/a&gt; | Fix 401 behind Docker/reverse proxy after Sentinel | 80% | Proxy detection |
| &lt;a href=&quot;https://github.com/JoshSalway/telescope/pull/2&quot;&gt;JoshSalway/telescope #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/telescope/issues/1693&quot;&gt;telescope #1693&lt;/a&gt; | Fix ExtractTags crash for listeners with event-typed tags() | 85% | Type handling |
| &lt;a href=&quot;https://github.com/JoshSalway/telescope/pull/3&quot;&gt;JoshSalway/telescope #3&lt;/a&gt; | telescope | Fix crash recording events for composite key models | 80% | Edge case |
| &lt;a href=&quot;https://github.com/JoshSalway/telescope/pull/4&quot;&gt;JoshSalway/telescope #4&lt;/a&gt; | telescope | Prevent duplicate migrations on telescope:install | 85% | Idempotency |
| &lt;a href=&quot;https://github.com/JoshSalway/echo/pull/1&quot;&gt;JoshSalway/echo #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/echo/issues/475&quot;&gt;echo #475&lt;/a&gt; | Fix WebSocket channel leak on React unmount | 85% | Resource cleanup |
| &lt;a href=&quot;https://github.com/JoshSalway/folio/pull/1&quot;&gt;JoshSalway/folio #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/folio/issues/148&quot;&gt;folio #148&lt;/a&gt; | Fix route() helper positional parameters | 80% | Routing fix |
| &lt;a href=&quot;https://github.com/JoshSalway/pail/pull/1&quot;&gt;JoshSalway/pail #1&lt;/a&gt; | pail | Fix crash on malformed JSON log lines | 90% | Error handling |
| &lt;a href=&quot;https://github.com/JoshSalway/pail/pull/2&quot;&gt;JoshSalway/pail #2&lt;/a&gt; | pail | Show deprecation warnings with deprecation logging | 80% | Feature |
| &lt;a href=&quot;https://github.com/JoshSalway/passport/pull/1&quot;&gt;JoshSalway/passport #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/passport/issues/1894&quot;&gt;passport #1894&lt;/a&gt; | Fix session serialisation with JSON format | 80% | Serialisation fix |
| &lt;a href=&quot;https://github.com/JoshSalway/socialite/pull/1&quot;&gt;JoshSalway/socialite #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/socialite/issues/754&quot;&gt;socialite #754&lt;/a&gt; | Fix PKCE session crash in stateless mode | 85% | Session/state handling |
| &lt;a href=&quot;https://github.com/JoshSalway/socialite/pull/2&quot;&gt;JoshSalway/socialite #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/socialite/issues/740&quot;&gt;socialite #740&lt;/a&gt; | Make redirect config key optional | 90% | Config flexibility |
| &lt;a href=&quot;https://github.com/JoshSalway/pulse/pull/1&quot;&gt;JoshSalway/pulse #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/pulse/issues/476&quot;&gt;pulse #476&lt;/a&gt; | Replace md5() with sha2() for MySQL 9.6 | 90% | Forward compat |
| &lt;a href=&quot;https://github.com/JoshSalway/scout/pull/1&quot;&gt;JoshSalway/scout #1&lt;/a&gt; | scout | Use model primary key for database queries | 80% | Query correctness |
| &lt;a href=&quot;https://github.com/JoshSalway/serializable-closure/pull/1&quot;&gt;JoshSalway/serializable-closure #1&lt;/a&gt; | serializable-closure | Fix crash with method-only attributes | 80% | Edge case |
| &lt;a href=&quot;https://github.com/JoshSalway/fortify/pull/1&quot;&gt;JoshSalway/fortify #1&lt;/a&gt; | fortify | Fix lowercase_usernames not respected on password reset | 85% | Consistency fix |
| &lt;a href=&quot;https://github.com/JoshSalway/jetstream/pull/1&quot;&gt;JoshSalway/jetstream #1&lt;/a&gt; | jetstream | Fix hasTeamRole() TypeError when pivot role is null | 90% | Null guard |
| &lt;a href=&quot;https://github.com/JoshSalway/nightwatch/pull/1&quot;&gt;JoshSalway/nightwatch #1&lt;/a&gt; | nightwatch | Fix duplicate assignment, double Clock, incorrect uInt32 max | 85% | Multiple small bugs |
| &lt;a href=&quot;https://github.com/JoshSalway/nightwatch/pull/8&quot;&gt;JoshSalway/nightwatch #8&lt;/a&gt; | nightwatch | Remove unused Number class | 90% | Dead code |
| &lt;a href=&quot;https://github.com/JoshSalway/nightwatch/pull/9&quot;&gt;JoshSalway/nightwatch #9&lt;/a&gt; | nightwatch | Add NotificationFailed event support | 75% | Feature addition |
| &lt;a href=&quot;https://github.com/JoshSalway/nightwatch/pull/10&quot;&gt;JoshSalway/nightwatch #10&lt;/a&gt; | nightwatch | Add unit tests for utility and infrastructure classes | 70% | Test coverage |
| &lt;a href=&quot;https://github.com/JoshSalway/mcp/pull/1&quot;&gt;JoshSalway/mcp #1&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/mcp/issues/138&quot;&gt;mcp #138&lt;/a&gt; | Prevent OAuth routes overriding existing routes | 85% | Route conflict |
| &lt;a href=&quot;https://github.com/JoshSalway/mcp/pull/2&quot;&gt;JoshSalway/mcp #2&lt;/a&gt; | &lt;a href=&quot;https://github.com/laravel/mcp/issues/143&quot;&gt;mcp #143&lt;/a&gt; | Render tool exceptions as MCP errors | 85% | Error handling |&lt;/p&gt;
&lt;h3&gt;Gist-only patches (original 25 from day 1)&lt;/h3&gt;
&lt;p&gt;These are the original patches from &lt;a href=&quot;/25-laravel-bug-fixes-tested-verified-ready-to-merge&quot;&gt;yesterday&apos;s post&lt;/a&gt;, all verified on both Windows and macOS with full test suites. Many have since been submitted as fork PRs above or are ready to submit upstream.&lt;/p&gt;
&lt;p&gt;| Issue | Title | Confidence | Gist | Fork PR |
|-------|-------|:----------:|------|---------|
| &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/128&quot;&gt;wayfinder #128&lt;/a&gt; | Replace &lt;code&gt;DIRECTORY_SEPARATOR&lt;/code&gt; with &lt;code&gt;/&lt;/code&gt; in TS imports | ~~95%~~ | &lt;a href=&quot;https://gist.github.com/JoshSalway/81854ebc04756e2d34608a09e00a4a93&quot;&gt;Patch&lt;/a&gt; | Fixed upstream (repo restructured) |
| &lt;a href=&quot;https://github.com/laravel/installer/issues/472&quot;&gt;installer #472&lt;/a&gt; | proc_open with inherited stdio for Windows TTY | 80% | &lt;a href=&quot;https://gist.github.com/JoshSalway/6ab1c148dba90749a47340bf82f01017&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/installer/pull/5&quot;&gt;Fork #5&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/vs-code-extension/issues/575&quot;&gt;vs-code-extension #575&lt;/a&gt; | Detect absolute paths before base_path() | 80% | &lt;a href=&quot;https://gist.github.com/JoshSalway/61208845ee8512451f41431568231123&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/vs-code-extension/pull/1&quot;&gt;Fork #1&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/prompts/issues/191&quot;&gt;prompts #191&lt;/a&gt; | Regex newline counting for Windows PHP_EOL | 95% | &lt;a href=&quot;https://gist.github.com/JoshSalway/37afa37003047d59b1cf1699ae9cd6e1&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/prompts/pull/3&quot;&gt;Fork #3&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/octane/issues/1034&quot;&gt;octane #1034&lt;/a&gt; | Respect --poll flag for FrankenPHP watcher | 85% | &lt;a href=&quot;https://gist.github.com/JoshSalway/0a1be675960307433dabf05e95f32e40&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/2&quot;&gt;Fork #2&lt;/a&gt; (closed) |
| &lt;a href=&quot;https://github.com/laravel/vite-plugin-wayfinder/issues/10&quot;&gt;vite-plugin-wayfinder #10&lt;/a&gt; | Pass Vite root as cwd, shell:true | 70% | &lt;a href=&quot;https://gist.github.com/JoshSalway/2283ffe6cbbe450d11ccaf68990da495&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/vite-plugin-wayfinder/pull/1&quot;&gt;Fork #1&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1759&quot;&gt;cashier-stripe #1759&lt;/a&gt; | Webhook race condition duplicate subscriptions | 85% | &lt;a href=&quot;https://gist.github.com/JoshSalway/410ee932634f56a1a4134bb116b1ea95&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/2&quot;&gt;Fork #2&lt;/a&gt; (closed) |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;framework #58207&lt;/a&gt; | OOM jobs retry infinitely with maxExceptions | 75% | &lt;a href=&quot;https://gist.github.com/JoshSalway/12f0d1386dd9362985cf3bb20b1d503b&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/16&quot;&gt;Fork #16&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/56395&quot;&gt;framework #56395&lt;/a&gt; | Pipeline memory leak -- two-line fix | 95% | &lt;a href=&quot;https://gist.github.com/JoshSalway/05c824429fd9dfe3ea8c0d9b5034a45d&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/17&quot;&gt;Fork #17&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/reverb/issues/344&quot;&gt;reverb #344&lt;/a&gt; | TypeError crash -- operator precedence bug | 90% | &lt;a href=&quot;https://gist.github.com/JoshSalway/54f791e8b95a9a776c80dd243271a82c&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/reverb/pull/1&quot;&gt;Fork #1&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/serializable-closure/issues/126&quot;&gt;serializable-closure #126&lt;/a&gt; | v2.0.9 regression breaks Bus::chain | 85% | &lt;a href=&quot;https://gist.github.com/JoshSalway/ea41bd667caf4df0e7263a9e463e80db&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/serializable-closure/pull/2&quot;&gt;Fork #2&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/horizon/issues/1535&quot;&gt;horizon #1535&lt;/a&gt; | Silent 60-second queue stalls -- add logging | 90% | &lt;a href=&quot;https://gist.github.com/JoshSalway/4f0f26f7d4d58aaebbe4899d23ed66cf&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/horizon/pull/6&quot;&gt;Fork #6&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1817&quot;&gt;cashier-stripe #1817&lt;/a&gt; | swapAndInvoice free upgrade -- webhook reconciliation | 75% | &lt;a href=&quot;https://gist.github.com/JoshSalway/11dd71bd0cd1ec1fb576c2c9bb58f5da&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/1&quot;&gt;Fork #1&lt;/a&gt; (open) |
| &lt;a href=&quot;https://github.com/laravel/pulse/issues/461&quot;&gt;pulse #461&lt;/a&gt; | Multi-server deadlocks -- atomic Redis lock | 80% | &lt;a href=&quot;https://gist.github.com/JoshSalway/cf02da6c132aab45d02252f2a00a0ed1&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/pulse/pull/2&quot;&gt;Fork #2&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/57070&quot;&gt;framework #57070&lt;/a&gt; | Sub-minute scheduling skips at boundaries | 90% | &lt;a href=&quot;https://gist.github.com/JoshSalway/952b49acbea4283556c53ea2879570cf&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/18&quot;&gt;Fork #18&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/reverb/issues/273&quot;&gt;reverb #273&lt;/a&gt; | Presence channels wrong user list at scale | 65% | &lt;a href=&quot;https://gist.github.com/JoshSalway/32ae75bb2b2d7afeaa8feaad8dce9134&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/reverb/pull/2&quot;&gt;Fork #2&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/octane/issues/1004&quot;&gt;octane #1004&lt;/a&gt; | Zero-downtime deployment with symlinks | 70% | &lt;a href=&quot;https://gist.github.com/JoshSalway/0a1be675960307433dabf05e95f32e40&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/5&quot;&gt;Fork #5&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/57262&quot;&gt;framework #57262&lt;/a&gt; | incrementEach() updates ALL rows -- data corruption | 90% | &lt;a href=&quot;https://gist.github.com/JoshSalway/fca18820e5f5feb15af924fcd4a17430&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/3&quot;&gt;Fork #3&lt;/a&gt; (closed) |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;framework #58377&lt;/a&gt; | SessionManager duplicate Redis connections | 95% | &lt;a href=&quot;https://gist.github.com/JoshSalway/2993447c7c6d9c0e3ed714eb073787f6&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/4&quot;&gt;Fork #4&lt;/a&gt; (open) |
| &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/161&quot;&gt;wayfinder #161&lt;/a&gt; | Dashed route names produce invalid TS identifiers | 75% | &lt;a href=&quot;https://gist.github.com/JoshSalway/653f5f0d7ac967d97b22b96a7a68f8d6&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/wayfinder/pull/4&quot;&gt;Fork #4&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/wayfinder/issues/178&quot;&gt;wayfinder #178,#159&lt;/a&gt; | Inertia imports + parameter shadowing | ~~75%~~ | &lt;a href=&quot;https://gist.github.com/JoshSalway/653f5f0d7ac967d97b22b96a7a68f8d6&quot;&gt;Patch&lt;/a&gt; | Fixed upstream (repo restructured) |
| &lt;a href=&quot;https://github.com/laravel/scout/issues/957&quot;&gt;scout #957&lt;/a&gt; | Timeout jobs block queue forever | 95% | &lt;a href=&quot;https://gist.github.com/JoshSalway/84def4e6a1a3f32cc499402c66bbd70e&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/scout/pull/2&quot;&gt;Fork #2&lt;/a&gt; |
| &lt;a href=&quot;https://github.com/laravel/framework/issues/56652&quot;&gt;framework #56652&lt;/a&gt; | Query log memory leak in Octane workers | 85% | &lt;a href=&quot;https://gist.github.com/JoshSalway/6a2b47fd0a78fef9b41a126c9125ed74&quot;&gt;Patch&lt;/a&gt; | &lt;a href=&quot;https://github.com/JoshSalway/framework/pull/2&quot;&gt;Fork #2&lt;/a&gt; (open) |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Last thoughts&lt;/h2&gt;
&lt;p&gt;If any maintainers are reading this -- sorry again for the volume. I try to ensure every fix is documented with before/after test results and links back to the original issue.&lt;/p&gt;
&lt;p&gt;If there are problems with any of these fixes or you&apos;d prefer a different approach, I&apos;m happy to adjust. The full details for every fix are in the &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;master gist&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Previous post:&lt;/strong&gt; &lt;a href=&quot;/25-laravel-bug-fixes-tested-verified-ready-to-merge&quot;&gt;25 Laravel Bug Fixes -- Tested, Verified, Ready to Merge&lt;/a&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>19 Laravel Bug Fixes -- Tested, Verified, Ready to Merge</title><link>https://joshsalway.com/articles/25-laravel-bug-fixes-tested-verified-ready-to-merge/</link><guid isPermaLink="true">https://joshsalway.com/articles/25-laravel-bug-fixes-tested-verified-ready-to-merge/</guid><description>19 bugs fixed across the Laravel ecosystem: 10 Windows-specific, 9 cross-platform. Every fix verified on Windows 11 and macOS with reproductions and full test runs. Patches linked.</description><pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Update (2026-03-19):&lt;/strong&gt; This has grown to 91 PRs across 27 repos. See the &lt;a href=&quot;/update-unblocked-25-laravel-fixes-and-whats-next&quot;&gt;Day 2 update&lt;/a&gt; for the full list with confidence ratings.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I found and fixed 19 bugs across the Laravel ecosystem -- 10 Windows-specific and 9 cross-platform. Every fix has been tested on Windows 11 Pro and independently verified on macOS (Darwin 25.3.0, PHP 8.4.19) with bug reproduction, concurrency testing, and full test suite runs.&lt;/p&gt;
&lt;p&gt;~~I&apos;m currently blocked from the Laravel GitHub organisation, so I can&apos;t submit PRs or comment on issues.~~&lt;/p&gt;
&lt;p&gt;All patches and PRs are linked below and in the &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;gist&lt;/a&gt; -- feel free to use them directly. I can&apos;t push these upstream because submitting too many at once gets you blocked, and the &lt;a href=&quot;https://laravel.com/docs/13.x/contributions#ai-generated-contributions&quot;&gt;contribution guide&lt;/a&gt; states that AI-generated pull requests will be closed without review. These are 100% AI-generated using &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt; -- every fix was found, written, and tested by AI on my machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Full details and patches:&lt;/strong&gt; &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;GitHub Gist&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Fixes&lt;/h2&gt;
&lt;h3&gt;Cross-Platform (Production Critical)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1759&quot;&gt;laravel/cashier-stripe #1759&lt;/a&gt; -- Webhook race condition creates duplicate subscriptions.&lt;/strong&gt; When &lt;code&gt;customer.subscription.created&lt;/code&gt; and &lt;code&gt;customer.subscription.updated&lt;/code&gt; webhooks arrive simultaneously, both handlers insert a new row. Active data corruption in production. Fix catches &lt;code&gt;UniqueConstraintViolationException&lt;/code&gt; and recovers gracefully. Verified with real concurrent processes on both SQLite and PostgreSQL -- race triggered 10/10 times, fix recovered every time. &lt;a href=&quot;https://gist.github.com/JoshSalway/410ee932634f56a1a4134bb116b1ea95&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/58207&quot;&gt;laravel/framework #58207&lt;/a&gt; -- OOM jobs retry infinitely.&lt;/strong&gt; When a job causes an out-of-memory kill, the exception counter never increments because the catch block never executes. Jobs with &lt;code&gt;$maxExceptions&lt;/code&gt; retry forever. 31 comments. Fix uses optimistic increment -- counter goes up before &lt;code&gt;fire()&lt;/code&gt;, down on success. Verified with real OOM kills (PHP exit code 255). &lt;a href=&quot;https://gist.github.com/JoshSalway/12f0d1386dd9362985cf3bb20b1d503b&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/56395&quot;&gt;laravel/framework #56395&lt;/a&gt; -- Pipeline memory leak.&lt;/strong&gt; &lt;code&gt;Pipeline::then()&lt;/code&gt; retains references to job objects after completion, preventing garbage collection. Workers hold 254MB instead of 4MB after processing 50 jobs. Two-line fix. 22 comments. &lt;a href=&quot;https://gist.github.com/JoshSalway/05c824429fd9dfe3ea8c0d9b5034a45d&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/reverb/issues/344&quot;&gt;laravel/reverb #344&lt;/a&gt; -- TypeError crash.&lt;/strong&gt; Operator precedence bug in &lt;code&gt;isWebSocketRequest()&lt;/code&gt; means any non-null Upgrade header is treated as a websocket request. Plus no guard when reverse proxies strip headers. 17 comments, users switching to Soketi. All 126 tests pass including Redis. &lt;a href=&quot;https://gist.github.com/JoshSalway/54f791e8b95a9a776c80dd243271a82c&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/serializable-closure/issues/126&quot;&gt;laravel/serializable-closure #126&lt;/a&gt; -- v2.0.9 regression breaks Bus::chain.&lt;/strong&gt; Objects with &lt;code&gt;__serialize&lt;/code&gt; skip closure wrapping entirely, causing serialization failures. Fix scopes the skip to only Closure-typed properties. 338 tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/ea41bd667caf4df0e7263a9e463e80db&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/cashier-stripe/issues/1817&quot;&gt;laravel/cashier-stripe #1817&lt;/a&gt; -- swapAndInvoice gives free upgrades.&lt;/strong&gt; When payment fails during a plan swap, users end up on the expensive plan for free. Fix adds &lt;code&gt;pendingIfPaymentFails()&lt;/code&gt; as the default. &lt;a href=&quot;https://gist.github.com/JoshSalway/11dd71bd0cd1ec1fb576c2c9bb58f5da&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/horizon/issues/1535&quot;&gt;laravel/horizon #1535&lt;/a&gt; -- Silent 60-second queue stalls.&lt;/strong&gt; Memory-exceeded workers restart, die again, then cooldown for 60 seconds with zero logging. Impossible to diagnose. Fix adds logging at 3 critical points. 169 tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/4f0f26f7d4d58aaebbe4899d23ed66cf&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/pulse/issues/461&quot;&gt;laravel/pulse #461&lt;/a&gt; -- Multi-server deadlocks.&lt;/strong&gt; Concurrent &lt;code&gt;pulse:work&lt;/code&gt; processes read the same Redis stream entries and deadlock on MySQL upserts. Fix adds atomic Redis lock around digest. &lt;a href=&quot;https://gist.github.com/JoshSalway/cf02da6c132aab45d02252f2a00a0ed1&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/57070&quot;&gt;laravel/framework #57070&lt;/a&gt; -- Sub-minute scheduling skips.&lt;/strong&gt; &lt;code&gt;endOfMinute()&lt;/code&gt; mutates the Carbon instance used in the loop condition. Fix uses &lt;code&gt;copy()-&gt;endOfMinute()&lt;/code&gt;. 68 scheduling tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/952b49acbea4283556c53ea2879570cf&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Windows-Specific&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/octane/issues/1100&quot;&gt;laravel/octane #1100&lt;/a&gt; -- FrankenPHP crashes on Windows.&lt;/strong&gt; POSIX signal constants don&apos;t exist. &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/1&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/wayfinder/issues/128&quot;&gt;laravel/wayfinder #128&lt;/a&gt; -- Backslash import paths.&lt;/strong&gt; &lt;code&gt;DIRECTORY_SEPARATOR&lt;/code&gt; produces &lt;code&gt;\&lt;/code&gt; in TypeScript. &lt;a href=&quot;https://gist.github.com/JoshSalway/81854ebc04756e2d34608a09e00a4a93&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/sail/issues/843&quot;&gt;laravel/sail #843&lt;/a&gt; -- Testing database never created.&lt;/strong&gt; Bash shebang fails in MySQL containers. &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/2&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/sail/issues/850&quot;&gt;laravel/sail #850&lt;/a&gt; -- Installation fails on Windows.&lt;/strong&gt; Unrecognised OS. &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/1&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/installer/issues/472&quot;&gt;laravel/installer #472&lt;/a&gt; -- &lt;code&gt;laravel new&lt;/code&gt; crashes with Boost.&lt;/strong&gt; No TTY support. &lt;a href=&quot;https://gist.github.com/JoshSalway/6ab1c148dba90749a47340bf82f01017&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/vs-code-extension/issues/575&quot;&gt;laravel/vs-code-extension #575&lt;/a&gt; -- Symlinked packages break.&lt;/strong&gt; Double path construction. &lt;a href=&quot;https://gist.github.com/JoshSalway/61208845ee8512451f41431568231123&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/prompts/issues/191&quot;&gt;laravel/prompts #191&lt;/a&gt; -- Terminal rendering glitches.&lt;/strong&gt; &lt;code&gt;PHP_EOL&lt;/code&gt; newline miscounting. &lt;a href=&quot;https://gist.github.com/JoshSalway/37afa37003047d59b1cf1699ae9cd6e1&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/octane/issues/1034&quot;&gt;laravel/octane #1034&lt;/a&gt; -- File watcher ignores --poll.&lt;/strong&gt; FrankenPHP overrides parent watcher. &lt;a href=&quot;https://github.com/JoshSalway/octane/pull/2&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/sail/issues/815&quot;&gt;laravel/sail #815&lt;/a&gt; -- Headed browser tests fail.&lt;/strong&gt; No virtual display. &lt;a href=&quot;https://github.com/JoshSalway/sail/pull/3&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/vite-plugin-wayfinder/issues/10&quot;&gt;laravel/vite-plugin-wayfinder #10&lt;/a&gt; -- Wayfinder fails in Vite.&lt;/strong&gt; Working directory resolution. &lt;a href=&quot;https://gist.github.com/JoshSalway/2283ffe6cbbe450d11ccaf68990da495&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Verification&lt;/h2&gt;
&lt;p&gt;Every fix was tested on Windows 11 Pro 26200 and macOS Darwin 25.3.0 (PHP 8.4.19). Cross-platform fixes include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bug reproduction with before/after proof&lt;/li&gt;
&lt;li&gt;Full test suite runs (2,500+ tests total across all repos)&lt;/li&gt;
&lt;li&gt;Concurrency testing with &lt;code&gt;pcntl_fork&lt;/code&gt; and barrier synchronisation&lt;/li&gt;
&lt;li&gt;Real OOM kills (PHP memory limit exhaustion, exit code 255)&lt;/li&gt;
&lt;li&gt;Memory leak measurement (WeakReference + memory profiling)&lt;/li&gt;
&lt;li&gt;PostgreSQL and Redis integration testing via Docker&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All patches, verification data, and test results are in the &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;gist&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;EDIT (2026-03-19):&lt;/strong&gt; The count has grown from 19 to 25 fixes across 11 Laravel repos. Here&apos;s what was added:&lt;/p&gt;
&lt;h3&gt;New Cross-Platform Fixes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/57262&quot;&gt;laravel/framework #57262&lt;/a&gt; -- &lt;code&gt;incrementEach()&lt;/code&gt; updates ALL rows in the table.&lt;/strong&gt; DATA CORRUPTION -- calling &lt;code&gt;$model-&gt;incrementEach()&lt;/code&gt; on an Eloquent model updates every row, not just that model. Multiple reports of near-production data loss. Fix adds explicit &lt;code&gt;incrementEach()&lt;/code&gt;/&lt;code&gt;decrementEach()&lt;/code&gt; methods to Eloquent Builder that call &lt;code&gt;toBase()&lt;/code&gt; to apply scopes. All 195 builder tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/fca18820e5f5feb15af924fcd4a17430&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/framework/issues/58377&quot;&gt;laravel/framework #58377&lt;/a&gt; -- SessionManager creates duplicate Redis connections.&lt;/strong&gt; Every request with Redis sessions opens 2 connections instead of 1 due to an unnecessary &lt;code&gt;clone&lt;/code&gt;. Causes &lt;code&gt;RedisException: Connection limit reached&lt;/code&gt; in strict environments. 12 comments. One-line fix -- remove the clone. All 93 session tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/2993447c7c6d9c0e3ed714eb073787f6&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/reverb/issues/273&quot;&gt;laravel/reverb #273&lt;/a&gt; -- Presence channels return wrong user list when scaling with Redis.&lt;/strong&gt; Each Reverb instance maintains its own local member list. Presence events never propagated via Redis pub/sub to other instances. Completely breaks presence channels at scale. Fix routes member events through the Redis event dispatcher and adds a new &lt;code&gt;PRESENCE_DATA&lt;/code&gt; metric type for cross-instance member gathering. 237 tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/32ae75bb2b2d7afeaa8feaad8dce9134&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/octane/issues/1004&quot;&gt;laravel/octane #1004&lt;/a&gt; -- No zero-downtime deployment.&lt;/strong&gt; &lt;code&gt;octane:reload&lt;/code&gt; doesn&apos;t work with symlink-based deployments (Deployer, Envoyer). 23 comments, 5-30s downtime per deploy. New &lt;code&gt;ResolvesSymlinks&lt;/code&gt; trait detects symlinked directories and ensures all three server types (Swoole, RoadRunner, FrankenPHP) use the symlink path. &lt;code&gt;clearstatcache(true)&lt;/code&gt; on reload. 180 tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/0a1be675960307433dabf05e95f32e40&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/scout/issues/957&quot;&gt;laravel/scout #957&lt;/a&gt; -- Timeout jobs block queue forever.&lt;/strong&gt; Scout&apos;s &lt;code&gt;MakeSearchable&lt;/code&gt; jobs don&apos;t set &lt;code&gt;failOnTimeout&lt;/code&gt;, so timed-out jobs silently hang in Horizon. One-line fix. All 211 Scout tests pass. &lt;a href=&quot;https://gist.github.com/JoshSalway/84def4e6a1a3f32cc499402c66bbd70e&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;New Windows Fixes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/laravel/wayfinder/issues/178&quot;&gt;laravel/wayfinder #178, #161, #159&lt;/a&gt; -- Three TypeScript generation bugs.&lt;/strong&gt; #178: Generated &lt;code&gt;.d.ts&lt;/code&gt; breaks Inertia type merging (missing import). #161: Dashed route names produce invalid TS identifiers. #159: Route action named &quot;options&quot; shadows the &lt;code&gt;routeOptions&lt;/code&gt; parameter. &lt;a href=&quot;https://gist.github.com/JoshSalway/653f5f0d7ac967d97b22b96a7a68f8d6&quot;&gt;Patch&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Updated Fixes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Cashier #1817 (swapAndInvoice)&lt;/strong&gt; was redesigned. The original &lt;code&gt;pendingIfPaymentFails()&lt;/code&gt; approach was incompatible with Stripe&apos;s API (rejects &lt;code&gt;tax_rates&lt;/code&gt; with &lt;code&gt;pending_if_incomplete&lt;/code&gt; -- verified with real Stripe test key, 8 API errors). The new fix uses &lt;strong&gt;webhook reconciliation&lt;/strong&gt;: a &lt;code&gt;handleInvoicePaymentFailed()&lt;/code&gt; handler detects failed subscription update payments and syncs the local DB back to Stripe&apos;s actual state. All 44 feature tests pass with real Stripe API key. &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/1&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cashier #1759 (duplicate subscriptions)&lt;/strong&gt; now also has a PR on my fork with the race condition fix verified against real Stripe API -- all 44 tests pass. &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe/pull/2&quot;&gt;PR&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Current Totals&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;25 fixes&lt;/strong&gt; across 11 repos (was 19)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;7 PRs&lt;/strong&gt; on forks: &lt;a href=&quot;https://github.com/JoshSalway/sail&quot;&gt;sail&lt;/a&gt; (3), &lt;a href=&quot;https://github.com/JoshSalway/octane&quot;&gt;octane&lt;/a&gt; (2), &lt;a href=&quot;https://github.com/JoshSalway/cashier-stripe&quot;&gt;cashier-stripe&lt;/a&gt; (2)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;18 gists&lt;/strong&gt; with patches for repos I can&apos;t fork&lt;/li&gt;
&lt;li&gt;All fixes in the &lt;a href=&quot;https://gist.github.com/JoshSalway/71925d8808935f0556ef47cba4af8d6b&quot;&gt;master gist&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><dc:creator>Josh Salway</dc:creator><atom:updated>2026-04-18T00:00:00.000Z</atom:updated></item><item><title>FragHub.gg</title><link>https://joshsalway.com/articles/fraghub-gg/</link><guid isPermaLink="true">https://joshsalway.com/articles/fraghub-gg/</guid><description>A PUBG stats and match replay tool. Search any player across Steam, Xbox, or PlayStation, then watch a full 2D replay with movement trails, zone shrink, and live kill feed.</description><pubDate>Sat, 07 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I launched &lt;a href=&quot;https://fraghub.gg&quot;&gt;fraghub.gg&lt;/a&gt; -- a PUBG stats and match replay tool.&lt;/p&gt;
&lt;p&gt;Search any PUBG player by name across Steam, Xbox, or PlayStation and get their full stat breakdown: K/D ratio, win rate, average damage, headshot percentage, ranked rating, and weapon mastery data. Browse their recent match history and open any match to watch a full 2D replay.&lt;/p&gt;
&lt;p&gt;The replay viewer renders actual match telemetry on the map -- player movement trails over the entire match, the blue zone shrinking in real time, red zone pulses, care package drop locations, and a live kill feed with timestamps. You can scrub to any point in the match.&lt;/p&gt;
&lt;p&gt;Built with Laravel, React, and Three.js. Match data comes from the official PUBG Developer API.&lt;/p&gt;
&lt;p&gt;Try it: &lt;strong&gt;&lt;a href=&quot;https://fraghub.gg&quot;&gt;fraghub.gg&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Barcoder - A Free Barcode Scanner &amp; Creator</title><link>https://joshsalway.com/articles/barcoder-a-free-barcode-scanner-and-creator/</link><guid isPermaLink="true">https://joshsalway.com/articles/barcoder-a-free-barcode-scanner-and-creator/</guid><description>A free PWA for scanning and generating barcodes in 27+ formats, including EAN-13, UPC-A, Code 128, and QR. Works offline, installs to your phone, validates check digits.</description><pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a free barcode scanner and creator. It&apos;s a PWA that lets you scan barcodes with your camera, upload a photo of a damaged barcode, or type in the digits manually to generate a scannable barcode on your phone screen.&lt;/p&gt;
&lt;p&gt;I originally built it for situations where product barcodes are damaged, missing, or hard to scan at the register. Instead of manually typing long barcode numbers, you can save frequently-needed barcodes and pull them up instantly.&lt;/p&gt;
&lt;p&gt;It supports 27+ barcode formats including EAN-13, UPC-A, EAN-8, Code 128, and QR codes. It works offline, is installable on your phone, and has built-in check digit validation.&lt;/p&gt;
&lt;p&gt;Try it out: &lt;strong&gt;&lt;a href=&quot;https://barcoder.joshsalway.com&quot;&gt;barcoder.joshsalway.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The source code is on GitHub: &lt;a href=&quot;https://github.com/JoshSalway/barcoder&quot;&gt;github.com/JoshSalway/barcoder&lt;/a&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Free Poker Timer</title><link>https://joshsalway.com/articles/i-built-a-free-poker-timer-app/</link><guid isPermaLink="true">https://joshsalway.com/articles/i-built-a-free-poker-timer-app/</guid><description>A no-signup poker blind timer for home games. No ads, no accounts, just open it and go. Source is on GitHub.</description><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a free poker timer for home games. No ads, no sign-ups, just open it and go.&lt;/p&gt;
&lt;p&gt;Check it out: &lt;strong&gt;&lt;a href=&quot;https://pokertimer.joshsalway.com&quot;&gt;pokertimer.joshsalway.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The source code is on GitHub: &lt;a href=&quot;https://github.com/JoshSalway/FreePokerTimer&quot;&gt;github.com/JoshSalway/FreePokerTimer&lt;/a&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Why I Chose to Build My Own Apps Instead of Taking a Traditional Job (for now)</title><link>https://joshsalway.com/articles/why-i-chose-to-build-my-own-apps-instead-of-taking-a-traditional-job-for-now/</link><guid isPermaLink="true">https://joshsalway.com/articles/why-i-chose-to-build-my-own-apps-instead-of-taking-a-traditional-job-for-now/</guid><description>A friend told me to get a traditional job and build apps on the side. I thought it through and picked the opposite path. Here&apos;s why building my own apps is the right move for me right now.</description><pubDate>Tue, 07 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Recently, a friend suggested that I should get a traditional full-time job at a big company, work on my side projects in my spare time, and play it safe. While their advice was well-meaning, after thinking it through, I realized that this path doesn&apos;t align with where I want to be right now.&lt;/p&gt;
&lt;p&gt;Instead, I&apos;ve decided to focus on building and launching my own apps. This choice is rooted in my years of experience as a Laravel developer and my desire to create something meaningful that&apos;s entirely mine.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;The Importance of Building My Own Apps&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;For years, I&apos;ve worked hard developing apps for other people. While that was valuable, I&apos;m now ready to channel that energy into my own ideas. Building apps for myself isn&apos;t just about creating income. It&apos;s about mastering the skills required to build a successful business, which is a much bigger win in the long run.&lt;/p&gt;
&lt;p&gt;Laravel plays a huge role in enabling this journey. Its creator, Taylor Otwell, famously designed Laravel with the philosophy of building a framework that could help solo developers succeed. Laravel&apos;s &quot;batteries included&quot; approach means it comes with powerful built-in features like authentication, routing, queues, and more, allowing me to focus on the big picture rather than getting bogged down in repetitive, low-level tasks.&lt;/p&gt;
&lt;p&gt;For a solo developer like me, this philosophy resonates deeply. It empowers me to work efficiently and build ambitious projects on my own without relying on a team. It&apos;s one of the reasons I love Laravel and continue to use it as the backbone for my apps.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;The Lessons From Failed Projects&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;This isn&apos;t my first time building personal projects. I&apos;ve started and failed at several side projects in the past, and each one has taught me valuable lessons. For example, I recently decided to take one of my projects offline because I realized I didn&apos;t care about it anymore. While building it, I learned a lot about development, hosting, and launching apps. But in the end, it wasn&apos;t something I was passionate about, and it wasn&apos;t generating income.&lt;/p&gt;
&lt;p&gt;As developers, it&apos;s completely normal to build many apps and example projects, including things that never get released. These projects are part of the learning process. They&apos;re opportunities to try new tools, test ideas, and gain experience, even if they don&apos;t lead to a finished product.&lt;/p&gt;
&lt;p&gt;What I&apos;ve learned is that starting and failing is part of the process. Much like exercising, it&apos;s not always about the end result. It&apos;s about the act of doing it and the growth that comes with it. Every failed project sharpens my skills and prepares me for the next one. In that sense, no project is truly a failure.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;Build What You Care About&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;One of the most important lessons I&apos;ve learned from working on my own projects is the importance of alignment. If you don&apos;t genuinely care about what you&apos;re building, it&apos;s hard to stay motivated, especially when you hit roadblocks. Building something you&apos;re passionate about makes it easier to push through the hard stuff, solve tricky problems, and see the project through to completion.&lt;/p&gt;
&lt;p&gt;That alignment between what you want and what you&apos;re working on is what keeps the process enjoyable. If you care about your app, every task, no matter how challenging, feels purposeful, and the reward of seeing it live is so much greater.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;The Importance of CI/CD Early On&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Another lesson I&apos;ve picked up recently is the value of setting up Continuous Integration and Continuous Deployment (CI/CD) early in the development lifecycle. I heard this advice in a YouTube video, and it really clicked with me.&lt;/p&gt;
&lt;p&gt;Having a CI/CD pipeline in place ensures that as soon as your app is ready, you can deploy it with confidence. It streamlines the process and allows you to focus on refining and improving your app without worrying about the technicalities of going live. Early setup of CI/CD is one of those things that might take a bit of time initially, but it pays off massively when you&apos;re ready to launch.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;Why Now Is the Right Time&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;I&apos;m fortunate to have some savings and a casual job to support myself, giving me the flexibility to pursue this. While job opportunities are always out there, sometimes it&apos;s best to take a chance on your own ideas and see where they lead.&lt;/p&gt;
&lt;p&gt;Of course, I&apos;d love for one of my apps to make money, that&apos;s always the dream. But what&apos;s even more valuable is the education I&apos;m gaining by doing this. Building apps teaches me not just technical skills, but also how to manage projects, market ideas, and solve real-world problems. Education and self-learning have always been important to me, and I believe focusing on personal growth is just as rewarding as financial success.&lt;/p&gt;
&lt;p&gt;This is about investing in myself and gaining the skills to create opportunities in the future, whether they come through my own ventures or other career paths.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;Open to Opportunities, Especially Contract Work&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;That&apos;s not to say I&apos;m completely closed off to other opportunities. I really enjoy contract work as a developer. It allows me to stay sharp, work on exciting projects, and connect with other businesses while still leaving room for my personal pursuits.&lt;/p&gt;
&lt;p&gt;If the right contract or even full-time role came along, something exciting and aligned with my goals, I&apos;d absolutely consider it. But for now, my main focus is on my own projects and working casually or part-time in roles that suit me.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;Check Out My Projects&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;If you&apos;re curious about what I&apos;m working on, check out my &lt;a href=&quot;/projects&quot;&gt;Projects Page&lt;/a&gt;. I regularly update it with new ideas, apps in progress, and tools I&apos;ve built. It&apos;s an evolving showcase of the work I&apos;m passionate about, and I&apos;m excited to share it with others.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;&lt;strong&gt;What&apos;s Your Dream?&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;I want to close with an important question inspired by Simon Squibb, an entrepreneur who challenges people to think deeply about their purpose and whose book, &lt;a href=&quot;https://www.amazon.com.au/Whats-Your-Dream-Passion-Richer/dp/1529935571&quot;&gt;What&apos;s Your Dream?: Find Your Passion. Love Your Work. Build a Richer Life.&lt;/a&gt; is set to release on February 16, 2025: What&apos;s your dream? What do you want to build or create in this world?&lt;/p&gt;
&lt;p&gt;For me, my dream is clear: I love to create. The thrill of taking an idea from my head and turning it into something real, whether it&apos;s an app, a tool, or a solution to a problem, is what keeps me motivated. Building something that didn&apos;t exist before is deeply rewarding, not just because of the end result, but because of the process itself.&lt;/p&gt;
&lt;p&gt;Simon Squibb emphasizes that when your work aligns with your passion, even the hard parts feel worthwhile. That&apos;s a lesson I&apos;ve learned firsthand. When you care about what you&apos;re building, it&apos;s easier to tackle tough challenges, stay consistent, and see the project through to completion.&lt;/p&gt;
&lt;p&gt;So, what&apos;s your dream? Whether it&apos;s launching a product, learning a new skill, or pursuing an entirely different path, the key is taking action. Start small, build consistently, and enjoy the process, because building something you care about is always worth it.&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Why KPIs Work Best When People and Customers Come First</title><link>https://joshsalway.com/articles/why-kpis-work-best-when-people-and-customers-come-first/</link><guid isPermaLink="true">https://joshsalway.com/articles/why-kpis-work-best-when-people-and-customers-come-first/</guid><description>KPIs are useful, but they aren&apos;t the goal. Take care of your people and your customers and the numbers take care of themselves. A case for people-first metrics with a lesson from Trek Bicycle.</description><pubDate>Mon, 06 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Key Performance Indicators (KPIs) are essential in modern businesses. They provide measurable ways to track progress, guide decision-making, and improve efficiency. But there&apos;s often a critical element missing: &lt;strong&gt;people and customers are the most important metrics.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It might sound counterintuitive, but the truth is simple: if you take care of your employees and customers, the numbers will take care of themselves. Businesses like Trek Bicycle exemplify this philosophy, proving that prioritizing people leads to long-term success. Here&apos;s why a people-first approach to KPIs isn&apos;t just ideal, but essential.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. People-First: The Secret to KPIs That Matter&lt;/h2&gt;
&lt;p&gt;Businesses are built on relationships, whether it&apos;s between a brand and its customers or a company and its employees. Yet, KPIs often focus narrowly on speed, cost, or output without considering the human element behind those numbers.&lt;/p&gt;
&lt;p&gt;When I purchased my Trek bike, I was struck by something the staff told me: &lt;strong&gt;&quot;We&apos;re a customer-service business that just happens to sell bikes.&quot;&lt;/strong&gt; That philosophy wasn&apos;t just words. It was reflected in every interaction I had. Whether I needed help with an issue or simply wanted to praise their products, I was always treated with care and professionalism.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What Trek Gets Right:&lt;/strong&gt; Their customer-first philosophy creates loyalty and satisfaction. Customers feel valued, encouraging repeat business and positive word-of-mouth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Key Lesson:&lt;/strong&gt; Focus on making people happy, whether they&apos;re employees or customers, and the metrics will naturally improve.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2. KPIs Are Tools, Not Goals&lt;/h2&gt;
&lt;p&gt;KPIs are there to guide progress, not dictate it. The danger of overly rigid KPIs is that they can lead to short-term thinking and counterproductive behaviors, like rushing to meet speed targets at the expense of quality.&lt;/p&gt;
&lt;p&gt;For instance, in a retail setting, a KPI might measure the number of orders packed per hour. While this emphasizes speed, it often overlooks other crucial factors like accuracy or customer satisfaction. Workers might rush to meet the target, leading to mispacked or damaged goods, a short-term win that creates long-term problems.&lt;/p&gt;
&lt;p&gt;Trek approaches this differently. Their &quot;Make the Call&quot; handbook encourages employees to understand and resolve customer concerns thoroughly, even if it takes longer.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Why This Matters:&lt;/strong&gt; A people-first approach ensures that KPIs like customer satisfaction and loyalty metrics stay high without pressuring employees to cut corners.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Key Lesson:&lt;/strong&gt; KPIs should reflect outcomes that matter to people, not just numbers on a spreadsheet.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;3. The KPI Triangle: Speed, Quality, and Cost!&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;/assets/assets/images/triangle.png&quot; alt=&quot;Triangle&quot;&gt;&lt;/p&gt;
&lt;p&gt;One of the biggest challenges in KPI design is balancing &lt;strong&gt;speed, quality, and cost.&lt;/strong&gt; Businesses often try to maximize all three but quickly find it&apos;s rarely possible. You can optimize for two, but the third will inevitably suffer.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Speed and Quality:&lt;/strong&gt; Achieving both usually requires higher costs, like investing in faster technology or more staff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quality and Cost:&lt;/strong&gt; Maintaining high standards while minimizing expenses often slows operations due to limited resources.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed and Cost:&lt;/strong&gt; Prioritizing efficiency at a low cost often compromises quality, leading to errors and dissatisfied customers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; A delivery service might prioritize speed, offering same-day delivery, but neglect proper packaging, leading to damaged goods. While delivery times look great, customer complaints and returns skyrocket, a prime example of focusing on one metric at the expense of others.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Key Lesson:&lt;/strong&gt; Businesses must choose their priorities. KPIs should reflect those priorities while acknowledging trade-offs.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4. Technology: The Backbone of Success&lt;/h2&gt;
&lt;p&gt;In today&apos;s workplaces, technology drives nearly every aspect of operations. Whether it&apos;s tools for managing workflows, customer interactions, or inventory systems, the efficiency of these tools can make or break KPI performance.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What I&apos;ve Learned:&lt;/strong&gt; When technology is slow, buggy, or prone to crashing, inefficiencies ripple through the operation. Workers waste time on glitches, customers are left waiting, and KPIs suffer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Bigger Issue:&lt;/strong&gt; Software teams are often the first to face budget cuts, but underinvesting in technology leads to long-term inefficiencies and frustrations for employees and customers alike.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;A Better Way&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Treat technology as a core investment, not an afterthought. Regular updates ensure systems meet the needs of both workers and customers.&lt;/li&gt;
&lt;li&gt;Gather feedback from frontline employees to identify inefficiencies and prioritize improvements.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Happy Employees Drive Happy Customers&lt;/h2&gt;
&lt;p&gt;Employees who feel valued and supported naturally deliver better customer experiences. Yet, many KPIs focus solely on output, ignoring the well-being of the people doing the work.&lt;/p&gt;
&lt;p&gt;Trek understands that employee satisfaction is just as important as customer satisfaction. Their supportive culture motivates employees to go above and beyond for customers.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What Companies Can Learn:&lt;/strong&gt; Incorporate employee satisfaction into KPIs. Metrics like retention rates, engagement surveys, and team feedback provide insight into how well a company supports its staff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Key Lesson:&lt;/strong&gt; Happy employees are more likely to create loyal customers.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;6. Flexibility Is Key to Effective KPIs&lt;/h2&gt;
&lt;p&gt;Rigid KPIs fail in the face of real-world challenges. For instance, expecting the same productivity targets during a system outage or supply chain disruption ignores the reality of the situation.&lt;/p&gt;
&lt;p&gt;Trek avoids this pitfall by focusing on outcomes rather than rigid metrics. Their priority is quality service and relationships, even if it means bending traditional measures of success.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What Businesses Can Do:&lt;/strong&gt; Build flexibility into KPIs to account for challenges and shifting priorities. This fosters trust and empowers employees to make the best decisions for customers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Key Lesson:&lt;/strong&gt; Adaptable KPIs keep teams focused on what matters, people and quality, without sacrificing long-term goals.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Final Thoughts: People and Customers Are the Real Metrics&lt;/h2&gt;
&lt;p&gt;KPIs are essential, but they should never overshadow the people they&apos;re meant to serve. Businesses like Trek Bicycle show that prioritizing employees and customers leads to metrics that improve organically.&lt;/p&gt;
&lt;p&gt;By focusing on customer satisfaction, valuing employees, and investing in technology, companies can create environments where everyone thrives. The result? Happier customers, more engaged employees, and KPIs that reflect true success.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;What do you think? Have you experienced workplaces where KPIs were designed with people and customers in mind? I&apos;d love to hear your insights!&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This article reflects my personal opinions and experiences and is not intended to critique any specific company or organization.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>How to Add a Dark Mode Switcher to Your Alpine.js and Tailwind CSS App</title><link>https://joshsalway.com/articles/how-to-add-a-dark-mode-selector-to-your-alpinejs-and-tailwind-css-app/</link><guid isPermaLink="true">https://joshsalway.com/articles/how-to-add-a-dark-mode-selector-to-your-alpinejs-and-tailwind-css-app/</guid><description>A step-by-step guide to wiring a three-state dark mode selector (light, dark, system) with Alpine.js and Tailwind, including persistence and system preference detection.</description><pubDate>Mon, 23 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Enhance your application&apos;s user experience by seamlessly integrating a dark mode feature using Alpine.js and Tailwind CSS.&lt;/p&gt;
&lt;h2&gt;Demo GIF&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;/assets/assets/images/darkmode-dropdown-selector.gif&quot; alt=&quot;Example GIF&quot;&gt;&lt;/p&gt;
&lt;h2&gt;Why Implement Dark Mode&lt;/h2&gt;
&lt;p&gt;Incorporating a dark mode into your application offers numerous benefits that enhance user experience and accessibility:&lt;/p&gt;
&lt;h3&gt;User Preference&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Aesthetic Appeal:&lt;/strong&gt; Many users favor dark mode for its sleek and modern look.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reduced Eye Strain:&lt;/strong&gt; Especially in low-light settings, dark mode minimizes eye fatigue, making prolonged usage more comfortable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Battery Efficiency&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Energy Savings:&lt;/strong&gt; On OLED and AMOLED screens, dark mode can significantly extend battery life by decreasing the number of bright pixels displayed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Accessibility&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Enhanced Readability:&lt;/strong&gt; Offering both light and dark themes caters to users with visual impairments, ensuring better contrast and readability.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inclusive Design:&lt;/strong&gt; Providing multiple theme options makes your application more accessible to a diverse user base.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Requirements&lt;/h2&gt;
&lt;p&gt;Before diving into the implementation, ensure you have the following set up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS:&lt;/strong&gt; Initialize Tailwind CSS in your project by following the &lt;a href=&quot;https://tailwindcss.com/docs/installation&quot;&gt;official installation guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alpine.js:&lt;/strong&gt; Integrate Alpine.js into your project by following the &lt;a href=&quot;https://alpinejs.dev/essentials/installation&quot;&gt;official installation instructions&lt;/a&gt;. You can include it via CDN or install it using npm.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step-by-Step Implementation&lt;/h2&gt;
&lt;h3&gt;1. Tailwind CSS Configuration&lt;/h3&gt;
&lt;p&gt;To enable dark mode using the &apos;class&apos; strategy, you&apos;ll need to update your tailwind.config.js file. This approach allows you to manually toggle dark mode by adding or removing the dark class on a parent element, typically the  tag.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update &lt;code&gt;tailwind.config.js&lt;/code&gt;&lt;/strong&gt;
Open &lt;code&gt;tailwind.config.js&lt;/code&gt;: Locate and open your project&apos;s tailwind.config.js file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configure Dark Mode:&lt;/strong&gt; Set the darkMode property to &apos;class&apos;. This configuration enables manual control over dark mode by toggling the dark class.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// tailwind.config.js
export default {
  darkMode: &apos;class&apos;, // Enables manual dark mode toggling
  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explanation:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;darkMode: &apos;class&apos;&lt;/code&gt;:&lt;/strong&gt; This setting tells Tailwind CSS to apply dark mode styles when the dark class is present on a parent element.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. Add the Dark Class to Your HTML Template&lt;/h3&gt;
&lt;p&gt;To activate dark mode by default or based on user preference, add the &lt;code&gt;dark&lt;/code&gt; class to the  element in your HTML template.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;!doctype html&gt;
&amp;#x3C;html lang=&quot;en&quot; class=&quot;dark&quot;&gt;
  &amp;#x3C;head&gt;
    &amp;#x3C;meta charset=&quot;utf-8&quot;&gt;
    &amp;#x3C;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=edge&quot;&gt;
    &amp;#x3C;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
    &amp;#x3C;title&gt;Your Site Title&amp;#x3C;/title&gt;
    &amp;#x3C;!-- Your other head elements --&gt;
  &amp;#x3C;/head&gt;
  &amp;#x3C;body class=&quot;bg-white dark:bg-gray-900 dark:text-gray-300 font-sans leading-normal text-gray-800 px-4 sm:px-10&quot;&gt;
    &amp;#x3C;!-- Your site content --&gt;
  &amp;#x3C;/body&gt;
&amp;#x3C;/html&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explanation:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;class=&quot;dark&quot;&lt;/code&gt; on &lt;code&gt;&amp;#x3C;html&gt;&lt;/code&gt;: Adding the &lt;code&gt;dark&lt;/code&gt; class to the &lt;code&gt;&amp;#x3C;html&gt;&lt;/code&gt; tag activates dark mode styles throughout your application.&lt;/li&gt;
&lt;li&gt;Tailwind Utility Classes:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bg-white dark:bg-gray-900&lt;/code&gt;: Sets a white background in light mode and a dark gray background in dark mode.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dark:text-gray-300 text-gray-800&lt;/code&gt;: Sets dark gray text in dark mode and darker gray text in light mode.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Note:&lt;/em&gt; Initially adding the &lt;code&gt;dark&lt;/code&gt; class sets the default theme to dark mode. To allow users to toggle between light and dark modes, you&apos;ll manage this class dynamically using JavaScript or Alpine.js in subsequent steps.&lt;/p&gt;
&lt;h3&gt;3. HTML Template Modifications&lt;/h3&gt;
&lt;p&gt;To ensure that your application respects the user&apos;s theme preference from the moment the page loads, you&apos;ll need to integrate an inline script within your HTML. This script is crucial for applying the user&apos;s selected theme immediately, thereby preventing any unwanted flashes of incorrect styling (FOUC).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;!doctype html&gt;
&amp;#x3C;html lang=&quot;en&quot; class=&quot;dark&quot;&gt;
  &amp;#x3C;head&gt;

    &amp;#x3C;!-- Inline Script to Apply Theme --&gt;
    &amp;#x3C;script&gt;
      (function() {
        const theme = localStorage.getItem(&apos;theme&apos;) || &apos;system&apos;;
        const isSystemDark = window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;).matches;

        // Apply the dark class to prevent FOUC
        if (theme === &apos;dark&apos; || (theme === &apos;system&apos; &amp;#x26;&amp;#x26; isSystemDark)) {
          document.documentElement.classList.add(&apos;dark&apos;);
        } else {
          document.documentElement.classList.remove(&apos;dark&apos;);
        }

        // Store the initial system preference
        localStorage.setItem(&apos;isSystemDark&apos;, isSystemDark);

        // Listen for system theme changes
        window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;).addEventListener(&apos;change&apos;, (event) =&gt; {
          localStorage.setItem(&apos;isSystemDark&apos;, event.matches);
          if (theme === &apos;system&apos;) {
            document.documentElement.classList.toggle(&apos;dark&apos;, event.matches);
          }
        });
      })();
    &amp;#x3C;/script&gt;

    &amp;#x3C;!-- Your compiled CSS &amp;#x26; JS file goes here --&gt;
    
    &amp;#x3C;!-- Alpine JS CDN goes here --&gt;
    &amp;#x3C;script src=&quot;//unpkg.com/alpinejs&quot; defer&gt;&amp;#x3C;/script&gt;
  &amp;#x3C;/head&gt;
  &amp;#x3C;body&gt;
	&amp;#x3C;!-- Content goes here --&gt;
  &amp;#x3C;/body&gt;
&amp;#x3C;/html&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What This Code Does&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Retrieve User Preference:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; The script first checks &lt;code&gt;localStorage&lt;/code&gt; for a saved theme preference (&lt;code&gt;&apos;light&apos;&lt;/code&gt;, &lt;code&gt;&apos;dark&apos;&lt;/code&gt;, or &lt;code&gt;&apos;system&apos;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fallback:&lt;/strong&gt; If no preference is found, it defaults to &apos;system&apos;, meaning the application will follow the system&apos;s current theme setting.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Determine System Theme:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Utilizes the &lt;code&gt;window.matchMedia&lt;/code&gt; API to detect if the user&apos;s operating system prefers a dark color scheme.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result:&lt;/strong&gt; Stores this preference in the &lt;code&gt;isSystemDark&lt;/code&gt; variable as a boolean (true if the system is in dark mode, false otherwise).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Apply Dark Mode Class:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Based on the retrieved theme and system preference, the script adds or removes the dark class on the  element.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Ensures that the correct theme styles are applied before the CSS fully loads, effectively preventing a Flash of Unstyled Content (FOUC).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Store System Theme Status:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Saves the system&apos;s dark mode status (&lt;code&gt;isSystemDark&lt;/code&gt;) in &lt;code&gt;localStorage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Allows the application to remember the system preference across browser sessions and page reloads, ensuring consistency in theme application.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Listen for System Changes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Sets up an event listener using window.matchMedia to detect any changes in the system&apos;s color scheme preference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Action on Change:&lt;/strong&gt; If the user has selected &apos;system&apos; mode, the script automatically updates the application&apos;s theme by toggling the dark class on the  element based on the new system preference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Keeps the application&apos;s theme in sync with the system&apos;s theme settings in real-time, enhancing user experience without requiring manual intervention.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. Add the Darkmode Drop Down Selector to Your Page&lt;/h3&gt;
&lt;p&gt;The following code snippet adds a dropdown selector that allows users to toggle between Light Mode, Dark Mode, and System Mode. This switcher uses Alpine.js for state management and interactivity.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;div class=&quot;relative&quot;
     x-data=&quot;{
        dropdownOpen: false,
        theme: localStorage.getItem(&apos;theme&apos;) || &apos;system&apos;,
        isSystemDark: localStorage.getItem(&apos;isSystemDark&apos;) === &apos;true&apos;,
        init() {
            // Watch for system dark mode changes
            const mediaQuery = window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;);
            this.isSystemDark = mediaQuery.matches;
            mediaQuery.addEventListener(&apos;change&apos;, (event) =&gt; {
                this.isSystemDark = event.matches;
                if (this.theme === &apos;system&apos;) {
                    this.updateTheme();
                }
            });

            // Set the initial theme
            this.updateTheme();
        },
        updateTheme() {
            const isDark = this.theme === &apos;dark&apos; || (this.theme === &apos;system&apos; &amp;#x26;&amp;#x26; this.isSystemDark);
            document.documentElement.classList.toggle(&apos;dark&apos;, isDark);
        },
        setTheme(newTheme) {
            this.theme = newTheme;
            localStorage.setItem(&apos;theme&apos;, newTheme);
            this.updateTheme();
            this.dropdownOpen = false; // Close dropdown after selection
        },
        currentIcon() {
            if (this.theme === &apos;light&apos;) return &apos;light&apos;;
            if (this.theme === &apos;dark&apos;) return &apos;dark&apos;;
            return &apos;system&apos;; // For &apos;system&apos;, decide based on system preference
        },
        systemIconColor() {
            return this.isSystemDark ? &apos;dark&apos; : &apos;light&apos;;
        }
    }&quot;
     @click.away=&quot;dropdownOpen = false&quot;
&gt;
    &amp;#x3C;!-- Trigger Button --&gt;
    &amp;#x3C;button
            @click=&quot;dropdownOpen = !dropdownOpen&quot;
            class=&quot;p-1 border dark:border-gray-700 rounded-full hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300&quot;&gt;
        &amp;#x3C;!-- Dynamic Icons --&gt;
        &amp;#x3C;template x-if=&quot;currentIcon() === &apos;light&apos;&quot;&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;w-6 h-6 text-yellow-500&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot;&gt;
                &amp;#x3C;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z&quot; /&gt;
            &amp;#x3C;/svg&gt;
        &amp;#x3C;/template&gt;
        &amp;#x3C;template x-if=&quot;currentIcon() === &apos;dark&apos;&quot;&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;w-6 h-6 text-blue-500&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot;&gt;
                &amp;#x3C;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z&quot; /&gt;
            &amp;#x3C;/svg&gt;
        &amp;#x3C;/template&gt;
        &amp;#x3C;template x-if=&quot;currentIcon() === &apos;system&apos;&quot;&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; :class=&quot;systemIconColor() === &apos;dark&apos; ? &apos;text-gray-300&apos; : &apos;text-gray-400&apos;&quot; class=&quot;w-6 h-6&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot;&gt;
                &amp;#x3C;path :d=&quot;systemIconColor() === &apos;dark&apos; ? &apos;M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z&apos; : &apos;M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z&apos;&quot; /&gt;
            &amp;#x3C;/svg&gt;
        &amp;#x3C;/template&gt;
    &amp;#x3C;/button&gt;

    &amp;#x3C;!-- Dropdown Content --&gt;
    &amp;#x3C;div
            x-show=&quot;dropdownOpen&quot;
            x-cloak
            class=&quot;absolute origin-top-right right-0 mt-2 py-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg z-10&quot;&gt;
        &amp;#x3C;!-- Light Mode Button --&gt;
        &amp;#x3C;button
                class=&quot;w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 flex items-center gap-2&quot;
                @click=&quot;setTheme(&apos;light&apos;)&quot;&gt;
            &amp;#x3C;!-- Light Mode Icon --&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;w-5 h-5&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot;&gt;
                &amp;#x3C;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z&quot; /&gt;
            &amp;#x3C;/svg&gt;
            Light Mode
        &amp;#x3C;/button&gt;

        &amp;#x3C;!-- Dark Mode Button --&gt;
        &amp;#x3C;button
                class=&quot;w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 flex items-center gap-2&quot;
                @click=&quot;setTheme(&apos;dark&apos;)&quot;&gt;
            &amp;#x3C;!-- Dark Mode Icon --&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; class=&quot;w-5 h-5&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot;&gt;
                &amp;#x3C;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z&quot; /&gt;
            &amp;#x3C;/svg&gt;
            Dark Mode
        &amp;#x3C;/button&gt;

        &amp;#x3C;!-- System Mode Button --&gt;
        &amp;#x3C;button
                class=&quot;w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 flex items-center gap-2&quot;
                @click=&quot;setTheme(&apos;system&apos;)&quot;&gt;
            &amp;#x3C;!-- System Mode Icon --&gt;
            &amp;#x3C;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot; class=&quot;w-5 h-5&quot;&gt;
                &amp;#x3C;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25&quot;&gt;&amp;#x3C;/path&gt;
            &amp;#x3C;/svg&gt;
            System Mode
        &amp;#x3C;/button&gt;
    &amp;#x3C;/div&gt;
&amp;#x3C;/div&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Overview of the Dark Mode Dropdown Selector&lt;/h2&gt;
&lt;p&gt;The following code implements a dark mode switcher using Alpine.js and Tailwind CSS. This switcher allows users to toggle between Light Mode, Dark Mode, and System Mode, providing a seamless and user-friendly experience. Here&apos;s a breakdown of what the code does and why each part is essential.&lt;/p&gt;
&lt;h3&gt;What It Implements&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;State Management&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Alpine.js Component:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Purpose: Manages the state of the theme (&lt;code&gt;light&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;) and controls whether the dropdown menu is open (&lt;code&gt;dropdownOpen&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Functionality: Utilizes reactive data properties to ensure that changes in the theme state automatically reflect in the UI.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Reactive Data:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;theme&lt;/code&gt;: Tracks the current theme preference, retrieved from localStorage or defaults to &apos;system&apos;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isSystemDark&lt;/code&gt;: Determines if the system&apos;s theme preference is dark, based on localStorage and media queries.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dropdownOpen&lt;/code&gt;: Controls the visibility of the dropdown menu for theme selection.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Persistent Preferences:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;localStorage:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Stores the user&apos;s theme preference (&lt;code&gt;theme&lt;/code&gt;) and the system&apos;s dark mode status (&lt;code&gt;isSystemDark&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benefit:&lt;/strong&gt; Ensures that the user&apos;s selection persists across browser sessions and page reloads, providing a consistent experience.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Dynamic Icons&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Visual Feedback:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Icons:&lt;/strong&gt; The switcher displays different icons (sun for light mode, moon for dark mode, and a neutral system icon) based on the current theme selection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Color Highlights:&lt;/strong&gt; Light and dark modes feature color highlights (yellow for light, blue for dark) to indicate active selection. System mode displays a neutral icon without highlights, signaling that the theme is controlled by system settings.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Dropdown Menu&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;User Control:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Options:&lt;/strong&gt; Provides clear options for users to select their preferred theme mode: Light, Dark, or System.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility:&lt;/strong&gt; Designed to be intuitive and accessible, ensuring that all users can easily navigate and select their desired theme.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Default to System Mode&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;System Integration:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Default Setting:&lt;/strong&gt; The switcher defaults to the system&apos;s theme preference (&apos;system&apos;), aligning the application&apos;s appearance with the user&apos;s device settings.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Dynamic Adaptation:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Real-Time Updates:&lt;/strong&gt; If the system&apos;s theme changes and the user is in system mode, the application automatically updates the theme without requiring any additional user action.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Explanation of Alpine.js Directives and Methods&lt;/h2&gt;
&lt;p&gt;To fully understand how Alpine.js manages the dark mode functionality, let&apos;s break down the key directives and methods used in the dropdown switcher component.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x-data&lt;/code&gt;: Initializes the component&apos;s reactive state, including &lt;code&gt;dropdownOpen&lt;/code&gt;, &lt;code&gt;theme&lt;/code&gt;, and &lt;code&gt;isSystemDark&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-init=&quot;init&quot;&lt;/code&gt;: Calls the &lt;code&gt;init&lt;/code&gt; method when the component is initialized, setting up initial theme preferences and event listeners.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@click.away=&quot;dropdownOpen = false&quot;&lt;/code&gt;: Closes the dropdown when a click occurs outside the component.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Methods:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;init()&lt;/code&gt;: Sets up media query listeners to detect system theme changes and applies the initial theme.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updateTheme()&lt;/code&gt;: Toggles the dark class on the  element based on the current theme and system preference.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setTheme(newTheme)&lt;/code&gt;: Updates the theme, stores the preference in localStorage, and closes the dropdown.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;currentIcon()&lt;/code&gt;: Determines which icon to display based on the current theme.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;systemIconColor()&lt;/code&gt;: Sets the color of the system icon based on the system&apos;s theme preference.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Visual Flow&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Initial Load&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Theme Initialization:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check &lt;code&gt;localStorage&lt;/code&gt;: On page load, the component checks &lt;code&gt;localStorage&lt;/code&gt; for any saved theme preference.&lt;/li&gt;
&lt;li&gt;Default to System: If no preference is found, it defaults to &apos;system&apos;, adopting the system&apos;s current theme.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Apply Theme:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Toggle &lt;code&gt;dark&lt;/code&gt; Class: Based on the determined theme, the dark class is added or removed from the  element to apply the appropriate styles, preventing any Flash of Unstyled Content (FOUC).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;User Interaction&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Toggle Dropdown:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Button Click:&lt;/strong&gt; When the user clicks the trigger button, the dropdown menu toggles open or closed, allowing access to theme selection options.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Select Theme:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose Mode:&lt;/strong&gt; Selecting a mode (&lt;code&gt;&apos;light&apos;&lt;/code&gt;, &lt;code&gt;&apos;dark&apos;&lt;/code&gt;, or &lt;code&gt;&apos;system&apos;&lt;/code&gt;) updates the theme state, saves the preference to localStorage, and closes the dropdown menu.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Icon Update:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dynamic Icons:&lt;/strong&gt; The switcher icon updates to reflect the selected theme, providing immediate visual feedback to the user.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;System Theme Changes&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Listen to Changes:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Media Query Listener:&lt;/strong&gt; The component listens for changes in the system&apos;s color scheme using the window.matchMedia API.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic Update:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;System Mode Active:&lt;/strong&gt; If the user is in &apos;system&apos; mode and the system&apos;s theme changes, the application automatically updates the theme to match the new system preference.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. Test Theme Toggling&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Use the dark mode switcher to toggle between &lt;code&gt;light&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt;, and &lt;code&gt;system&lt;/code&gt; modes.&lt;/li&gt;
&lt;li&gt;Refresh the page to verify that the selected theme persists.&lt;/li&gt;
&lt;li&gt;Switch your system&apos;s theme settings and ensure the application responds accordingly when in system mode.&lt;/li&gt;
&lt;li&gt;Resize your browser or test on different devices to ensure the dark mode feature works consistently across various screen sizes.&lt;/li&gt;
&lt;li&gt;Reload the page multiple times to ensure that the inline script effectively prevents any flash of the wrong theme (FOUC).&lt;/li&gt;
&lt;li&gt;Check Styling Consistency: Browse through various sections to ensure consistent styling and design for both light mode and dark mode.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Implementing a dark mode selector in your Alpine.js and Tailwind CSS application not only modernizes your user interface but also caters to user preferences for accessibility and aesthetics. By following this guide, you&apos;ve integrated a responsive and persistent dark mode feature that enhances the overall user experience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next Steps:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Advanced Customizations:&lt;/strong&gt; Explore adding smooth transitions between themes for a more polished effect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom Selection Styles:&lt;/strong&gt; Enhance user experience by customizing text selection styles using CSS pseudo-elements like &lt;code&gt;::selection&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User Preferences Panel:&lt;/strong&gt; Consider creating a dedicated settings page where users can manage their theme preferences alongside other personalized settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility Audits:&lt;/strong&gt; Regularly perform accessibility audits to ensure that both light and dark modes meet accessibility standards.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Additional Resources&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://tailwindcss.com/docs/dark-mode&quot;&gt;Tailwind CSS Dark Mode Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme&quot;&gt;CSS &lt;code&gt;prefers-color-scheme&lt;/code&gt; Media Feature&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Feel free to share your thoughts or ask questions. Happy coding!&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator><atom:updated>2026-04-19T00:00:00.000Z</atom:updated></item><item><title>Using Laravel Enums in React with Inertia.js: A Modern Update</title><link>https://joshsalway.com/articles/using-laravel-enums-in-react-with-inertiajs-a-modern-update/</link><guid isPermaLink="true">https://joshsalway.com/articles/using-laravel-enums-in-react-with-inertiajs-a-modern-update/</guid><description>An updated take on sharing data between Laravel and React: PHP 8.1 enums give you type safety, cleaner syntax, and encapsulated behaviour that class constants cannot.</description><pubDate>Fri, 20 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When building modern web applications, ensuring consistent data structures between your backend and frontend is essential. In a &lt;a href=&quot;/using-laravel-constants-in-react-with-inertiajs&quot;&gt;previous post&lt;/a&gt;, I demonstrated how Laravel constants could be used to share data seamlessly with React through Inertia.js. However, with the introduction of PHP 8.1 Enums (further enhanced in PHP 8.3), I&apos;ve refined my approach to fully leverage this powerful feature.&lt;/p&gt;
&lt;p&gt;Enums offer type safety, cleaner syntax, and encapsulated behavior that surpasses the simplicity of constants. In this updated post, I&apos;ll walk you through the benefits of using enums, how to implement them in Laravel, and why they&apos;re a better alternative to constants for most use cases.&lt;/p&gt;
&lt;h2&gt;Why Share Data Between Laravel and React?&lt;/h2&gt;
&lt;p&gt;Sharing data like dropdown options, validation rules, and configuration values between the backend and frontend ensures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Consistency&lt;/strong&gt;: Both layers use the same source of truth, reducing discrepancies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maintainability&lt;/strong&gt;: Updates to shared data propagate seamlessly across the application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer Productivity&lt;/strong&gt;: Eliminates redundant code and reduces errors.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Traditionally, constants were the go-to solution for this, but enums now provide an improved alternative.&lt;/p&gt;
&lt;h2&gt;What Are Enums and How Are They Different from Constants?&lt;/h2&gt;
&lt;h3&gt;Constants&lt;/h3&gt;
&lt;p&gt;Constants are static values defined in a class or file. They&apos;re simple, lightweight, and great for defining unchanging data. For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class Platform
{
    public const PLATFORMS = [
        &apos;steam&apos; =&gt; &apos;Steam&apos;,
        &apos;psn&apos; =&gt; &apos;PlayStation Network&apos;,
        &apos;xbox&apos; =&gt; &apos;Xbox&apos;,
        &apos;kakao&apos; =&gt; &apos;Kakao&apos;,
        &apos;stadia&apos; =&gt; &apos;Stadia&apos;,
    ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Limitations:&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;No type safety:&lt;/strong&gt; You need custom validation to ensure only valid keys are used.
&lt;strong&gt;Static behavior:&lt;/strong&gt; You can&apos;t add methods or encapsulate related logic.&lt;/p&gt;
&lt;h3&gt;Enums:&lt;/h3&gt;
&lt;p&gt;Enums are a newer feature in PHP that represent a finite set of values with built-in type safety and encapsulated logic. Example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;enum PlatformEnum: string
{
    case Steam = &apos;steam&apos;;
    case PlayStationNetwork = &apos;psn&apos;;
    case Xbox = &apos;xbox&apos;;
    case Kakao = &apos;kakao&apos;;
    case Stadia = &apos;stadia&apos;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Advantages:&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Type Safety:&lt;/strong&gt; Only defined enum cases are allowed.
&lt;strong&gt;Encapsulated Behavior:&lt;/strong&gt; Enums can include methods for transformations or additional data.
&lt;strong&gt;Cleaner Code:&lt;/strong&gt; Eliminates boilerplate and reduces the risk of errors.&lt;/p&gt;
&lt;h2&gt;Why I Switched to Enums&lt;/h2&gt;
&lt;p&gt;Here are the key reasons I moved from constants to enums:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type Safety:&lt;/strong&gt; With enums, you can ensure only valid values are used throughout your application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$request-&gt;validate([
    &apos;platform&apos; =&gt; &apos;required|in:&apos; . implode(&apos;,&apos;, PlatformEnum::values()),
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reduces the risk of errors caused by typos or outdated constants.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Centralized Behavior:&lt;/strong&gt; Enums allow you to group related logic in one place. For example, you can define dropdown options directly in the enum:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;public static function options(): array
{
    return array_map(
        fn($case) =&gt; [&apos;value&apos; =&gt; $case-&gt;value, &apos;label&apos; =&gt; self::labels()[$case-&gt;value]],
        self::cases()
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Better Validation:&lt;/strong&gt; With enums, validation is more robust and concise, as enum values are inherently valid.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Future-Proofing:&lt;/strong&gt; Adding new platforms or modifying behavior is easier with enums. Constants often require updating multiple locations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cleaner Syntax:&lt;/strong&gt; Enums reduce boilerplate code by combining data and behavior in a single structure.&lt;/p&gt;
&lt;h2&gt;Implementing Laravel Enums&lt;/h2&gt;
&lt;p&gt;Here&apos;s how I updated my application to use enums instead of constants:&lt;/p&gt;
&lt;h3&gt;Step 1: Define the Enum&lt;/h3&gt;
&lt;p&gt;Create the PlatformEnum under app/Enums:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

namespace App\Enums;

enum PlatformEnum: string
{
    case Steam = &apos;steam&apos;;
    case PlayStationNetwork = &apos;psn&apos;;
    case Xbox = &apos;xbox&apos;;
    case Kakao = &apos;kakao&apos;;
    case Stadia = &apos;stadia&apos;;

    public static function labels(): array
    {
        return [
            self::Steam-&gt;value =&gt; &apos;Steam&apos;,
            self::PlayStationNetwork-&gt;value =&gt; &apos;PlayStation Network&apos;,
            self::Xbox-&gt;value =&gt; &apos;Xbox&apos;,
            self::Kakao-&gt;value =&gt; &apos;Kakao&apos;,
            self::Stadia-&gt;value =&gt; &apos;Stadia&apos;,
        ];
    }

    public static function options(): array
    {
        return array_map(
            fn($case) =&gt; [&apos;value&apos; =&gt; $case-&gt;value, &apos;label&apos; =&gt; self::labels()[$case-&gt;value]],
            self::cases()
        );
    }

    public static function values(): array
    {
        return array_map(fn($case) =&gt; $case-&gt;value, self::cases());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: Share Data with React Using Inertia&lt;/h3&gt;
&lt;p&gt;In your web.php file, pass the enum data to your React component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use App\Enums\PlatformEnum;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get(&apos;/&apos;, function () {
    return Inertia::render(&apos;Home&apos;, [
        &apos;platforms&apos; =&gt; PlatformEnum::options(),
    ]);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: Use Enums in React&lt;/h3&gt;
&lt;p&gt;Access the platforms prop in your React component and generate the dropdown options:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;const { platforms } = usePage().props;

&amp;#x3C;select&gt;
    {platforms.map(({ value, label }) =&gt; (
        &amp;#x3C;option key={value} value={value}&gt;
            {label}
        &amp;#x3C;/option&gt;
    ))}
&amp;#x3C;/select&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 4: Validation Made Easy&lt;/h3&gt;
&lt;p&gt;Enums simplify validation by providing a definitive list of valid values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$request-&gt;validate([
    &apos;platform&apos; =&gt; &apos;required|in:&apos; . implode(&apos;,&apos;, PlatformEnum::values()),
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;When to Use Constants Instead&lt;/h2&gt;
&lt;p&gt;While enums are ideal for structured, finite sets of values, constants still have their place:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;For Static Data:&lt;/strong&gt; If the values won&apos;t change and don&apos;t require additional behavior.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For Simplicity:&lt;/strong&gt; In cases where you don&apos;t need the added functionality of enums.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For Backward Compatibility:&lt;/strong&gt; If your project needs to support PHP versions before 8.1.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By switching from constants to enums, I&apos;ve made my application:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;More Type-Safe:&lt;/strong&gt; Reducing errors caused by invalid data.
Easier to Maintain: Centralizing logic and behavior within enums.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cleaner:&lt;/strong&gt; Streamlining validation and frontend integration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&apos;re using PHP 8.1 or later, enums are a fantastic way to modernize your codebase. If you&apos;re stuck on older versions, constants remain a reliable option. This transition reflects my journey as a developer and my commitment to improving both my code and my learning process.&lt;/p&gt;
&lt;p&gt;Let me know your thoughts or share your experience with enums!&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item><item><title>Using Laravel Constants in React with Inertia.js</title><link>https://joshsalway.com/articles/using-laravel-constants-in-react-with-inertiajs/</link><guid isPermaLink="true">https://joshsalway.com/articles/using-laravel-constants-in-react-with-inertiajs/</guid><description>Share PHP class constants with your React frontend through Inertia shared data to create a single source of truth for dropdowns, form options, and validation rules.</description><pubDate>Thu, 19 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;hr&gt;
&lt;h2&gt;Consider Reading the Newer Post&lt;/h2&gt;
&lt;p&gt;This post covers using Laravel constants with React through Inertia.js effectively. However, we&apos;ve now updated our approach to leverage PHP Enums for greater type safety, maintainability, and cleaner code. If you&apos;re looking for a modern alternative, check out the &lt;a href=&quot;/using-laravel-enums-in-react-with-inertiajs-a-modern-update&quot;&gt;newer blog post&lt;/a&gt; that explores these enhancements in depth.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;There are many ways to share data between your Laravel backend and your JavaScript frontend. Today, we&apos;ll focus on one particularly powerful method: sharing constants between Laravel and JavaScript using Inertia.js. This approach is especially useful for managing dropdowns, form options, or validation rules, ensuring consistency and reducing repetition across your application.&lt;/p&gt;
&lt;p&gt;By centralizing constants in Laravel, you create a single source of truth that can be used throughout your backend and frontend. In this post, we&apos;ll walk through a practical example of defining platform constants in Laravel and using them seamlessly in a React component.&lt;/p&gt;
&lt;h2&gt;Why Use Constants?&lt;/h2&gt;
&lt;p&gt;Constants are essential for maintaining consistency, reducing errors, and improving maintainability. Defining them in Laravel allows you to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Share Consistent Data Structures:&lt;/strong&gt; Share the same data structure between your backend and frontend seamlessly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Avoid Duplication:&lt;/strong&gt; Reduce redundancy and ensure updates propagate across the application effortlessly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simplify Validation and Forms:&lt;/strong&gt; Make validation rules and dropdown creation in your forms straightforward and error-free.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Step 1: Define the Constants in Laravel&lt;/h2&gt;
&lt;p&gt;Create a &lt;code&gt;Platform&lt;/code&gt; class under &lt;code&gt;app/Constants&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&amp;#x3C;?php

namespace App\Constants;

class Platform
{
    public const PLATFORMS = [
        &apos;steam&apos; =&gt; &apos;Steam&apos;,
        &apos;psn&apos; =&gt; &apos;PlayStation Network&apos;,
        &apos;xbox&apos; =&gt; &apos;Xbox&apos;,
        &apos;kakao&apos; =&gt; &apos;Kakao&apos;,
        &apos;stadia&apos; =&gt; &apos;Stadia&apos;,
    ];

    public static function getPlatforms(): array
    {
        // Transform into a key-value array suitable for frontend use
        return collect(self::PLATFORMS)
            -&gt;map(function ($label, $key) {
                return [
                    &apos;value&apos; =&gt; $key,
                    &apos;label&apos; =&gt; $label,
                ];
            })
            -&gt;values()
            -&gt;toArray();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How It Works&lt;/h3&gt;
&lt;p&gt;In this example, the &lt;code&gt;Platform&lt;/code&gt; class defines a centralized list of platforms and formats them.&lt;/p&gt;
&lt;h4&gt;Using Laravel Collections to Format the Data&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;getPlatforms()&lt;/code&gt; method in the &lt;code&gt;Platform&lt;/code&gt; class leverages &lt;a href=&quot;https://laravel.com/docs/11.x/collections&quot;&gt;Laravel Collections&lt;/a&gt; to format the &lt;code&gt;PLATFORMS&lt;/code&gt; data into a reusable structure. Let&apos;s break down how this works:&lt;/p&gt;
&lt;h4&gt;Wrap the Data in a Collection&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;collect()&lt;/code&gt; function takes the &lt;code&gt;PLATFORMS&lt;/code&gt; constant and wraps it in a Laravel collection. This allows for easy manipulation of the data.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;collect(self::PLATFORMS)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Transform the Data&lt;/h4&gt;
&lt;p&gt;The map() method iterates over each platform key-value pair and transforms it into an array with the following structure:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;value:&lt;/strong&gt; The platform&apos;s key (e.g., steam, psn).
&lt;strong&gt;label:&lt;/strong&gt; The platform&apos;s human-readable name (e.g., Steam, PlayStation Network).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;-&gt;map(function ($label, $key) {
    return [
        &apos;value&apos; =&gt; $key,
        &apos;label&apos; =&gt; $label,
    ];
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Flatten the Structure&lt;/h4&gt;
&lt;p&gt;The values() method resets the array keys to ensure a clean, sequentially indexed array.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;-&gt;values()
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Convert to Plain Array&lt;/h4&gt;
&lt;p&gt;Finally, the toArray() method converts the collection into a plain PHP array, ready to be passed to the frontend.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;-&gt;toArray();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;What getPlatforms() Produces...&lt;/h4&gt;
&lt;p&gt;The final output of getPlatforms() is a structured array like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;[
    [&apos;value&apos; =&gt; &apos;steam&apos;, &apos;label&apos; =&gt; &apos;Steam&apos;],
    [&apos;value&apos; =&gt; &apos;psn&apos;, &apos;label&apos; =&gt; &apos;PlayStation Network&apos;],
    [&apos;value&apos; =&gt; &apos;xbox&apos;, &apos;label&apos; =&gt; &apos;Xbox&apos;],
    [&apos;value&apos; =&gt; &apos;kakao&apos;, &apos;label&apos; =&gt; &apos;Kakao&apos;],
    [&apos;value&apos; =&gt; &apos;stadia&apos;, &apos;label&apos; =&gt; &apos;Stadia&apos;],
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2: Pass the Constants to the Frontend Using Inertia&lt;/h2&gt;
&lt;p&gt;In your web.php file, pass the platforms to the React component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use App\Constants\Platform;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get(&apos;/&apos;, function () {
    return Inertia::render(&apos;Home&apos;, [
        &apos;platforms&apos; =&gt; Platform::getPlatforms(),
    ]);
})-&gt;name(&apos;home&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 3: Use the Constants in Your React Component&lt;/h2&gt;
&lt;p&gt;In this step, we make use of the &lt;code&gt;usePage&lt;/code&gt; hook provided by Inertia.js to access the constants passed from Laravel. The &lt;code&gt;usePage&lt;/code&gt; hook is a powerful utility that allows you to directly access the page&apos;s props in your React component. These props are passed down from your Laravel backend via Inertia when rendering the page.&lt;/p&gt;
&lt;p&gt;For more details, check out the official documentation on &lt;a href=&quot;https://inertiajs.com/shared-data&quot;&gt;shared data with Inertia&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here&apos;s how &lt;code&gt;usePage&lt;/code&gt; is useful in this scenario:&lt;/p&gt;
&lt;p&gt;•	&lt;strong&gt;Seamless Data Sharing:&lt;/strong&gt;
The &lt;code&gt;usePage&lt;/code&gt; hook enables seamless sharing of data between the backend and frontend without the need for additional API calls. In our case, the platforms constant is passed as a prop from Laravel, and we access it directly in the React component using &lt;code&gt;usePage&lt;/code&gt;.
•	&lt;strong&gt;Centralized State Management:&lt;/strong&gt;
Since &lt;code&gt;usePage&lt;/code&gt; provides access to all props sent with the current Inertia response, it serves as a centralized way to manage and access state or configuration data required for a specific page.
•	&lt;strong&gt;Dynamic and Flexible:&lt;/strong&gt;
With &lt;code&gt;usePage&lt;/code&gt;, any data passed from Laravel, such as the platforms list, is dynamically available in the React component. This flexibility ensures that updates to the backend logic (e.g., enabling/disabling platforms) are automatically reflected in the frontend.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;import React from &quot;react&quot;;
import { Head, useForm, usePage } from &quot;@inertiajs/react&quot;;

export default function Home() {
    // Access platforms from the page props
    const { platforms } = usePage().props;

    // Form state using Inertia&apos;s useForm hook
    const { data, setData, post, processing, errors } = useForm({
        username: &quot;&quot;, // Input for the username
        platform: platforms?.[0]?.value || &quot;steam&quot;, // Default to the first platform
    });

    // Handle form submission
    const handleSubmit = (e) =&gt; {
        e.preventDefault();
        post(route(&quot;search&quot;));
    };

    return (
        &amp;#x3C;&gt;
            &amp;#x3C;Head title=&quot;Platform Selector&quot; /&gt;
            &amp;#x3C;div className=&quot;min-h-screen bg-gray-100 flex items-center justify-center&quot;&gt;
                &amp;#x3C;div className=&quot;w-full max-w-md bg-white shadow rounded p-6&quot;&gt;
                    &amp;#x3C;h1 className=&quot;text-2xl font-bold mb-4&quot;&gt;Select Your Platform&amp;#x3C;/h1&gt;
                    &amp;#x3C;form onSubmit={handleSubmit}&gt;
                        {/* Platform Dropdown */}
                        &amp;#x3C;select
                            className=&quot;w-full p-2 border border-gray-300 rounded mb-4&quot;
                            value={data.platform}
                            onChange={(e) =&gt; setData(&quot;platform&quot;, e.target.value)}
                        &gt;
                            {platforms.map((platform) =&gt; (
                                &amp;#x3C;option key={platform.value} value={platform.value}&gt;
                                    {platform.label}
                                &amp;#x3C;/option&gt;
                            ))}
                        &amp;#x3C;/select&gt;
                        {errors.platform &amp;#x26;&amp;#x26; (
                            &amp;#x3C;div className=&quot;text-red-500 text-sm&quot;&gt;{errors.platform}&amp;#x3C;/div&gt;
                        )}

                        {/* Username Input */}
                        &amp;#x3C;input
                            type=&quot;text&quot;
                            placeholder=&quot;Enter Username&quot;
                            className=&quot;w-full p-2 border border-gray-300 rounded mb-4&quot;
                            value={data.username}
                            onChange={(e) =&gt; setData(&quot;username&quot;, e.target.value)}
                        /&gt;
                        {errors.username &amp;#x26;&amp;#x26; (
                            &amp;#x3C;div className=&quot;text-red-500 text-sm&quot;&gt;{errors.username}&amp;#x3C;/div&gt;
                        )}

                        {/* Submit Button */}
                        &amp;#x3C;button
                            type=&quot;submit&quot;
                            className=&quot;w-full bg-blue-500 text-white p-2 rounded&quot;
                            disabled={processing}
                        &gt;
                            {processing ? &quot;Submitting...&quot; : &quot;Submit&quot;}
                        &amp;#x3C;/button&gt;
                    &amp;#x3C;/form&gt;
                &amp;#x3C;/div&gt;
            &amp;#x3C;/div&gt;
        &amp;#x3C;/&gt;
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 4: Reuse Constants Anywhere&lt;/h2&gt;
&lt;p&gt;Want to use these platforms elsewhere, like in validation? The Platform constant is ready to go:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use App\Constants\Platform;
use Illuminate\Http\Request;

Route::post(&apos;/search&apos;, function (Request $request) {
    $request-&gt;validate([
        &apos;username&apos; =&gt; &apos;required|string|max:255&apos;,
        &apos;platform&apos; =&gt; &apos;required|in:&apos; . implode(&apos;,&apos;, array_keys(Platform::PLATFORMS)),
    ]);

    // Handle the search logic here
    return response()-&gt;json([&apos;message&apos; =&gt; &apos;Search successful&apos;]);
})-&gt;name(&apos;search&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By centralizing your constants in Laravel and using Inertia.js, you can easily share data between your backend and frontend:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Write Once:&lt;/strong&gt; Define your constants in Laravel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reuse Everywhere:&lt;/strong&gt; Use them in React, validation, or other backend logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep it Simple:&lt;/strong&gt; Laravel and Inertia make the data flow seamless.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This approach ensures consistency and makes your codebase easier to maintain. Happy coding!&lt;/p&gt;</content:encoded><dc:creator>Josh Salway</dc:creator></item></channel></rss>