<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Sander Muller</title>
    <description>The latest articles on Forem by Sander Muller (@sandermuller).</description>
    <link>https://forem.com/sandermuller</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866202%2F42f5f0d2-0ae7-4542-b8ac-4d662f2a135f.png</url>
      <title>Forem: Sander Muller</title>
      <link>https://forem.com/sandermuller</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/sandermuller"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>Sander Muller</dc:creator>
      <pubDate>Tue, 14 Apr 2026 08:55:38 +0000</pubDate>
      <link>https://forem.com/sandermuller/-3c76</link>
      <guid>https://forem.com/sandermuller/-3c76</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg" class="crayons-story__hidden-navigation-link"&gt;Building and battle-testing a Laravel package with AI peers&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/sandermuller" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866202%2F42f5f0d2-0ae7-4542-b8ac-4d662f2a135f.png" alt="sandermuller profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sandermuller" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sander Muller
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sander Muller
                
              
              &lt;div id="story-author-preview-content-3498535" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sandermuller" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866202%2F42f5f0d2-0ae7-4542-b8ac-4d662f2a135f.png" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sander Muller&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 14&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg" id="article-link-3498535"&gt;
          Building and battle-testing a Laravel package with AI peers
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/laravel"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;laravel&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/opensource"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;opensource&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/php"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;php&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt; reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Building and battle-testing a Laravel package with AI peers</title>
      <dc:creator>Sander Muller</dc:creator>
      <pubDate>Tue, 14 Apr 2026 08:55:09 +0000</pubDate>
      <link>https://forem.com/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg</link>
      <guid>https://forem.com/sandermuller/building-and-battle-testing-a-laravel-package-with-ai-peers-4dlg</guid>
      <description>&lt;p&gt;I built &lt;a href="https://github.com/SanderMuller/laravel-fluent-validation" rel="noopener noreferrer"&gt;laravel-fluent-validation&lt;/a&gt;, a fluent rule builder for Laravel. Magic strings like &lt;code&gt;'required|string|max:255'&lt;/code&gt; have always bothered me. I tried PRing expansions to Laravel's fluent API, but even small additions got closed with the usual answer: release it as a package instead. So I did.&lt;/p&gt;

&lt;p&gt;Along the way I also &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk"&gt;fixed a performance problem&lt;/a&gt; with wildcard validation and built a &lt;a href="https://github.com/SanderMuller/laravel-fluent-validation-rector" rel="noopener noreferrer"&gt;Rector companion&lt;/a&gt; for automated migration.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the package itself. It was the workflow that built and hardened it.&lt;/p&gt;

&lt;p&gt;I used four Claude Code sessions. One owned the package, three owned real Laravel codebases that were adopting it. They reviewed each other's work through &lt;a href="https://github.com/louislva/claude-peers-mcp" rel="noopener noreferrer"&gt;claude-peers&lt;/a&gt;, a peer messaging MCP server. The codebase peers would test, hit edge cases, report back. The package peer would fix, tag a release, and the codebase peers would re-verify. This compressed release-and-feedback loops from days to minutes.&lt;/p&gt;

&lt;p&gt;The Rector companion went through eight functional releases in about 24 hours this way. 108 files converted on one codebase, net -1,426 lines of code, 566 tests green after migration with no behavioral regressions observed. But the Rector cycle is just the most compressed example. The same method shaped the performance benchmarks, the Livewire integration, the error messages, the documentation.&lt;/p&gt;

&lt;p&gt;The examples below are Laravel-specific, but the method isn't. Isolated AI agents become far more useful when they review changes against multiple real environments with automated verification.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/louislva/claude-peers-mcp" rel="noopener noreferrer"&gt;claude-peers&lt;/a&gt; is an MCP server for Claude Code. Each instance running on your machine can discover other instances, see what they're working on, and send messages. They don't share context. Each has its own conversation with full codebase access.&lt;/p&gt;

&lt;p&gt;In practice it works like this: the package peer tags a new release. It sends a message to the three codebase peers saying "0.4.5 tagged, fixes the parallel-worker race, please re-verify." Each codebase peer receives the message, pulls the new version, runs the migration, runs their tests, and sends back results. If something breaks, the response includes the exact error, the file, and usually a theory about why. The package peer reads that, asks follow-up questions if needed, fixes the issue, and the loop continues.&lt;/p&gt;

&lt;p&gt;One thing I didn't expect was how quickly the peers developed their own review dynamic. They would challenge each other's assumptions, ask for evidence, and sometimes reach consensus before coming back with a recommendation.&lt;/p&gt;

&lt;p&gt;I had four terminals open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;package repo&lt;/strong&gt;, building features, writing tests, shipping releases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three production codebases&lt;/strong&gt;, each a real Laravel app with its own validation patterns, framework integrations, and test suites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs locally. Claude Code works on local clones of each codebase, with the same filesystem access you'd have in your terminal. No production servers, no remote environments, no secrets exposed to AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why real codebases beat synthetic fixtures
&lt;/h2&gt;

&lt;p&gt;Running against multiple codebases isn't about redundancy. Each one stresses a different part of the code.&lt;/p&gt;

&lt;p&gt;The first app has 108 FormRequests and uses &lt;code&gt;rules()&lt;/code&gt; as a naming convention on Actions and Collections, not just validation. The Rector's skip log grew to 2,988 entries and 777KB. The package author expected a near-empty log. At 108 files, it was unusable. On a smaller codebase, you'd never notice. The same app also runs Filament alongside Livewire, and five of its components use Filament's &lt;code&gt;InteractsWithForms&lt;/code&gt; trait, which defines its own &lt;code&gt;validate()&lt;/code&gt; method. Inserting the package's trait would have created a fatal method collision on first form render. The right fix was to bail and flag those classes for manual review, since the Rector can't know whether the developer intends fluent validation or Filament's form validation.&lt;/p&gt;

&lt;p&gt;The second app runs 15 parallel Rector workers. The skip log's "truncate on first write" flag was per-process, so every worker thought it was first and wiped the others' entries. Synthetic test fixtures run single-process. This bug doesn't exist there.&lt;/p&gt;

&lt;p&gt;The third app was already on fluent validation with only 7 files left to convert. They tracked Pint code-style fixer counts across releases as an acceptance metric, and found that 5 of their 7 Livewire files had &lt;code&gt;#[Validate]&lt;/code&gt; attributes coexisting with explicit &lt;code&gt;validate([...])&lt;/code&gt; calls. Dead-code attributes the package author hadn't anticipated. That drove a whole new hybrid-detection path.&lt;/p&gt;

&lt;p&gt;None of these were likely to surface in a fixture-based test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What automated tests still missed
&lt;/h2&gt;

&lt;p&gt;The first app tracked firing counts across every release, how many times each Rector rule fired on their 108-file corpus. On one release, trait-insertion rectors fired zero times. Rector still reported "108 files changed" because the converter rules worked fine. A tester checking that output would have shipped it. The peer tracking counts caught that "108 to 0 on trait rectors" was a regression. The fix landed the same day, and expected counts became a permanent test.&lt;/p&gt;

&lt;p&gt;One peer asked a question during a retrospective: "You've tested that the Rector output parses. Have you tested that the runtime semantics match?" Nobody had asked this in nine releases. It led to 16 parameterized test cases asserting that FluentRule and string-form rules produce identical error messages. All 16 passed. But those tests only exist because a peer who didn't write the code asked "prove it."&lt;/p&gt;

&lt;h2&gt;
  
  
  What peers changed at the design level
&lt;/h2&gt;

&lt;p&gt;Before one release, the package peer was weighing whether to expand detection to handle &lt;code&gt;new Password()&lt;/code&gt; constructor calls inside rule arrays. It sounded reasonable, more complete conversion, 30-60 minutes of work. A codebase peer killed it with one observation: the converter is context-free. It runs inside &lt;code&gt;rules()&lt;/code&gt; methods and inside attribute arguments. Any expansion would fire in both contexts, silently rewriting code where the developer chose the constructor form intentionally. No test was failing. The feature would have worked in the narrow case it was designed for. The peer prevented it by naming a failure mode the author hadn't considered.&lt;/p&gt;

&lt;p&gt;All three codebases reported near-zero ternary rules (&lt;code&gt;$condition ? 'required' : 'nullable'&lt;/code&gt;), which was enough to shelve the feature on demand alone. But one peer added a reframe: developers who reach for ternaries in rule arrays are optimizing for terseness, and the closure-form fluent version loses on that axis by construction. Even with demand, the feature might make its target audience's code worse. That moved it from "deferred" to "won't fix."&lt;/p&gt;

&lt;p&gt;In both cases, the peer contributed framing, not just evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What made this work
&lt;/h2&gt;

&lt;p&gt;Each Claude instance has full codebase access and its own conversation history. The package peer knows the internals. The codebase peers know their app's patterns, test suites, and integrations. Nobody has to context-switch.&lt;/p&gt;

&lt;p&gt;The codebases were real, not demo fixtures. Every bug described above required production-level complexity that doesn't exist in test scenarios.&lt;/p&gt;

&lt;p&gt;Automated verification made the loop objective. The package runs PHPStan on level max, Rector, and Pint on every change, with 616 tests and 1,235 assertions. Each codebase peer runs the same stack. When a peer reports "PHPStan clean, 566 tests green, Pint fixer count down from 3 to 2," you can trust the result.&lt;/p&gt;

&lt;p&gt;The back-and-forth was fast because it stayed in the same session. Tag a release, three codebases verify, issues come back with exact errors and hypotheses, fix ships, re-verify. The whole cycle in 15-30 minutes. GitHub issues lose context between messages. These peers kept corpus knowledge across every release.&lt;/p&gt;

&lt;p&gt;And the peers could challenge scope, not just report failures. The &lt;code&gt;new Password()&lt;/code&gt; conversation and the ternary-rule reframe both came from peers who could say "I don't think you should build this" with technical reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this workflow costs
&lt;/h2&gt;

&lt;p&gt;Running four Claude Code sessions in parallel means watching your weekly usage limits and session caps burn in front of your eyes. It's worth it for a focused release cycle, but you feel the cost. For a solo contributor, the same process works across sequential sessions. You'd lose the synchronous loop but keep the corpus context.&lt;/p&gt;

&lt;p&gt;The workflow also has a blind spot: if all test codebases share the same architectural assumptions, peers can miss the same category of bug together. The three-codebase model worked here because each app had genuinely different patterns: scale, parallel execution, hybrid Livewire attributes. If all three had been small Livewire apps, the skip-log volume and parallel-worker bugs would have shipped uncaught.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would and wouldn't use this
&lt;/h2&gt;

&lt;p&gt;I'd use this workflow for packages or tools that modify other people's code: Rector rules, code generators, migration tools, linters. The cost of a silent-rewrite bug is high, and running against codebases you didn't write is the most reliable way to catch them before release.&lt;/p&gt;

&lt;p&gt;I'd also use it for packages with integration surface across frameworks. Livewire, Filament, and Inertia all have their own quirks. A peer running on a codebase that actually uses Filament + Livewire together will find trait conflicts and method collisions that your test suite won't.&lt;/p&gt;

&lt;p&gt;For a simpler utility package with a narrow API surface, I'd scale it down. One project peer instead of three. You still get the "does this actually work in someone else's codebase" signal without the overhead of a full multi-peer setup.&lt;/p&gt;

&lt;p&gt;The surprising part was that multiple isolated peers, each grounded in a different real codebase, acted more like an internal design-and-QA loop than an autocomplete tool. That changed what got built, what got cut, and what got tested.&lt;/p&gt;




&lt;p&gt;The package: &lt;a href="https://github.com/SanderMuller/laravel-fluent-validation" rel="noopener noreferrer"&gt;laravel-fluent-validation&lt;/a&gt; -- fluent validation rule builders with up to 160x wildcard performance gains, full Laravel parity, Livewire and Filament support.&lt;/p&gt;

&lt;p&gt;The Rector companion: &lt;a href="https://github.com/SanderMuller/laravel-fluent-validation-rector" rel="noopener noreferrer"&gt;laravel-fluent-validation-rector&lt;/a&gt; -- automated migration from string rules. 108 files converted on one production codebase, -1,426 LOC, 566 tests green.&lt;/p&gt;

&lt;p&gt;The peer messaging: &lt;a href="https://github.com/louislva/claude-peers-mcp" rel="noopener noreferrer"&gt;claude-peers&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI skills for Laravel packages: &lt;a href="https://github.com/SanderMuller/package-boost" rel="noopener noreferrer"&gt;package-boost&lt;/a&gt; -- ships migration guides, optimization hints, and framework-specific gotchas alongside your package so each peer has context without manual setup.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>laravel</category>
      <category>opensource</category>
      <category>php</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Sander Muller</dc:creator>
      <pubDate>Tue, 07 Apr 2026 15:57:17 +0000</pubDate>
      <link>https://forem.com/sandermuller/-55am</link>
      <guid>https://forem.com/sandermuller/-55am</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk" class="crayons-story__hidden-navigation-link"&gt;Laravel's wildcard validation is O(n ) — here's how to fix it&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/sandermuller" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866202%2F42f5f0d2-0ae7-4542-b8ac-4d662f2a135f.png" alt="sandermuller profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sandermuller" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sander Muller
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sander Muller
                
              
              &lt;div id="story-author-preview-content-3467051" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sandermuller" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866202%2F42f5f0d2-0ae7-4542-b8ac-4d662f2a135f.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sander Muller&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk" id="article-link-3467051"&gt;
          Laravel's wildcard validation is O(n ) — here's how to fix it
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;3&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Laravel's wildcard validation is O(n ) — here's how to fix it</title>
      <dc:creator>Sander Muller</dc:creator>
      <pubDate>Tue, 07 Apr 2026 15:55:22 +0000</pubDate>
      <link>https://forem.com/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk</link>
      <guid>https://forem.com/sandermuller/laravels-wildcard-validation-is-on-heres-how-to-fix-it-1nlk</guid>
      <description>&lt;p&gt;I maintain several large Laravel applications. Last year I was profiling an import endpoint that accepts JSON payloads with up to a hundred items. Each item has around 47 fields, many with conditional rules like &lt;code&gt;exclude_unless&lt;/code&gt; and &lt;code&gt;required_if&lt;/code&gt;. The endpoint was taking 3.4 seconds. I assumed it was database queries.&lt;/p&gt;

&lt;p&gt;It wasn't. Validation alone was taking 3.2 of those 3.4 seconds. The database work was 200ms.&lt;/p&gt;

&lt;p&gt;I spent a good part of a day on finding out why. Once I figured out what was causing it, I submitted 10 performance PRs to Laravel. Most were closed. As Taylor always says: "Consider releasing it as a package". So I did, and it brings that 3.2s down to 83ms of time spent on validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens when you validate arrays
&lt;/h2&gt;

&lt;p&gt;When you write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'items.*.name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|string|max:255'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="s1"&gt;'items.*.qty'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|numeric|min:1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel's &lt;code&gt;explodeWildcardRules()&lt;/code&gt; expands the wildcards into concrete rules for every item in the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;items.0.name =&amp;gt; required|string|max:255
items.0.qty  =&amp;gt; required|numeric|min:1
items.1.name =&amp;gt; required|string|max:255
items.1.qty  =&amp;gt; required|numeric|min:1
items.2.name =&amp;gt; required|string|max:255
...
items.499.name =&amp;gt; required|string|max:255
items.499.qty  =&amp;gt; required|numeric|min:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a simpler example: 500 items with 7 fields gives you 3,500 concrete rules. The expansion works by matching each wildcard pattern against every key in the flattened data array. That's where the O(n²) comes from — for every wildcard rule, it scans every data key.&lt;/p&gt;

&lt;p&gt;That's not the only cost. During validation, each attribute runs through &lt;code&gt;validateAttribute()&lt;/code&gt; which evaluates every rule — including &lt;code&gt;exclude_unless&lt;/code&gt; and &lt;code&gt;exclude_if&lt;/code&gt;. These exclusion rules call dependent-rule parameter resolution and accumulate excluded attributes. After the main loop, &lt;code&gt;shouldBeExcluded()&lt;/code&gt; checks each attribute against that list and removes excluded ones. For conditional-heavy payloads, the cost of evaluating all those exclusion rules dominates. With 100 items and 47 conditional patterns, you end up with 4,700 concrete rules, each evaluating its exclusion conditions.&lt;/p&gt;

&lt;p&gt;Here's a simplified view of what the validator is doing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for each of 4,700 expanded attributes:
    for each rule on this attribute:
        parse the rule string                       ← repeated for identical rules
        resolve the value from nested data          ← Arr::dot() on every access
        evaluate exclude_unless/exclude_if          ← dependent-rule parameter resolution
        run the validation logic
    check if attribute was excluded                  ← scans excludeAttributes list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply that inner loop by 4,700 and you have your 3.2 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;I started with Laravel Telescope, which showed the validation taking most of the request time but didn't show why. So I switched to Xdebug's profiler and opened the trace in KCachegrind.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;explodeWildcardRules()&lt;/code&gt; was eating 35% of validation time — converting &lt;code&gt;Arr::dot()&lt;/code&gt; output to regex patterns, then matching every wildcard against every key. Exclusion rule evaluation (&lt;code&gt;validateExcludeUnless&lt;/code&gt;, &lt;code&gt;validateExcludeIf&lt;/code&gt;, and the dependent-rule parameter resolution they trigger) took another 28%. &lt;code&gt;ValidationRuleParser::parse()&lt;/code&gt; accounted for 15%, parsing the same rule strings hundreds of times because there's no caching between items.&lt;/p&gt;

&lt;p&gt;The remaining 22% was scattered: &lt;code&gt;BigNumber&lt;/code&gt; for simple integer comparisons that could've been native PHP, repeated &lt;code&gt;hasRule()&lt;/code&gt; calls, message placeholder replacement on strings that had no placeholders.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried upstream
&lt;/h2&gt;

&lt;p&gt;On March 15, 2026, I submitted 10 performance PRs to &lt;code&gt;laravel/framework&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Traverse data directly for wildcard expansion instead of &lt;code&gt;Arr::dot()&lt;/code&gt; + regex&lt;/li&gt;
&lt;li&gt;Cache parsed string rules to avoid re-parsing identical rules across items&lt;/li&gt;
&lt;li&gt;Pre-compute bail/nullable/sometimes flags instead of calling &lt;code&gt;hasRule()&lt;/code&gt; repeatedly&lt;/li&gt;
&lt;li&gt;Use native PHP comparison for size validation instead of BigNumber&lt;/li&gt;
&lt;li&gt;Short-circuit &lt;code&gt;shouldStopValidating()&lt;/code&gt; when no errors exist yet&lt;/li&gt;
&lt;li&gt;Cache &lt;code&gt;Arr::undot()&lt;/code&gt; results in &lt;code&gt;Rule::compile()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Skip placeholder replacements when the message template has none&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;array_push&lt;/code&gt; with spread in &lt;code&gt;MessageBag::all()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Cache route instances in &lt;code&gt;CompiledRouteCollection::getByName()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Avoid redundant &lt;code&gt;Util::getParameterClassName()&lt;/code&gt; in the container&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last four were merged. The six validation-specific ones were all closed. I resubmitted the wildcard traversal PR (&lt;a href="https://github.com/laravel/framework/pull/59287" rel="noopener noreferrer"&gt;#59287&lt;/a&gt;) against the 13.x branch with benchmarks from a real application. Closed again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried that didn't work
&lt;/h2&gt;

&lt;p&gt;Before building a package, I tried a few things.&lt;/p&gt;

&lt;p&gt;Splitting the payload into chunks and validating each chunk separately. Helps with memory, but the O(n²) expansion still runs per chunk, and error messages lose their original indices.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Rule::forEach()&lt;/code&gt; instead of wildcard rules. It uses &lt;code&gt;NestedRules::compile()&lt;/code&gt; to generate per-item rules, avoiding &lt;code&gt;explodeWildcardRules()&lt;/code&gt;. But the compilation overhead per item — instantiating rule objects, re-parsing strings, resolving callbacks — meant it was actually slower for simple cases.&lt;/p&gt;

&lt;p&gt;Pre-expanding rules manually before passing them to the validator. This skips the expansion cost but you still pay for exclusion rule evaluation during validation. Half the problem, not the whole thing.&lt;/p&gt;

&lt;p&gt;I also tried caching the validator instance across requests, but rules change with every payload — different item counts mean different expanded rules.&lt;/p&gt;

&lt;p&gt;The fundamental issue is that Laravel's validator works well up to maybe 50-100 rules. Beyond that, the linear scans become quadratic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Since the fixes couldn't go upstream, I built them into &lt;a href="https://github.com/SanderMuller/laravel-fluent-validation" rel="noopener noreferrer"&gt;laravel-fluent-validation&lt;/a&gt;. The &lt;code&gt;HasFluentRules&lt;/code&gt; trait plugs into your existing FormRequest and applies three optimizations.&lt;/p&gt;

&lt;p&gt;The first optimization replaces the O(n²) wildcard expansion with O(n) tree traversal. Instead of flattening the data with &lt;code&gt;Arr::dot()&lt;/code&gt; and matching regex patterns against every key, &lt;code&gt;WildcardExpander&lt;/code&gt; walks the data tree once and emits concrete paths as it descends. For 500 items with 7 fields, all those regex matches become a single tree walk. It also skips the intermediate &lt;code&gt;Arr::dot()&lt;/code&gt; array, which saves memory on large payloads. About 20% faster by itself.&lt;/p&gt;

&lt;p&gt;The second optimization makes the biggest difference on simple rules. The package compiles PHP closures for 25 common rules — string, numeric, integer, boolean, email, url, ip, uuid, in, regex, and others. Each closure is a few &lt;code&gt;is_string()&lt;/code&gt; / &lt;code&gt;strlen()&lt;/code&gt; / &lt;code&gt;in_array()&lt;/code&gt; calls. During validation, each item hits the closure first. Pass? Laravel's validator never sees it. Fail? That item goes through Laravel for the correct error message.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is_string($v) &amp;amp;&amp;amp; strlen($v) &amp;lt;= 255&lt;/code&gt; takes nanoseconds. Laravel's validator doing the same check goes through rule parsing, method dispatch, &lt;code&gt;BigNumber&lt;/code&gt; size comparison, and message formatting — even when the value is valid. Custom &lt;code&gt;Rule&lt;/code&gt; classes, closures, and anything the package doesn't recognize pass through to Laravel untouched.&lt;/p&gt;

&lt;p&gt;The third optimization targets conditional rules. For &lt;code&gt;exclude_unless&lt;/code&gt; and &lt;code&gt;exclude_if&lt;/code&gt;, the package evaluates the conditions before the validator starts and removes excluded attributes from the rule set entirely. Instead of 4,700 rules where each attribute evaluates its exclusion conditions at validation time, the validator only sees the ~200 rules that actually apply.&lt;/p&gt;

&lt;p&gt;You don't need to change your rules. Just add the trait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;SanderMuller\FluentValidation\HasFluentRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportRequest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;FormRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasFluentRules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// your existing rules() method, unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;These benchmarks run in CI on every PR. The data is randomly generated each run; the numbers are median of 3 runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Native Laravel&lt;/th&gt;
&lt;th&gt;With HasFluentRules&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500 items × 7 simple fields (string, numeric, in)&lt;/td&gt;
&lt;td&gt;~200ms&lt;/td&gt;
&lt;td&gt;~2ms&lt;/td&gt;
&lt;td&gt;97x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 items × 7 mixed fields (string + date comparison)&lt;/td&gt;
&lt;td&gt;~200ms&lt;/td&gt;
&lt;td&gt;~20ms&lt;/td&gt;
&lt;td&gt;10x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 items × 47 conditional fields (exclude_unless)&lt;/td&gt;
&lt;td&gt;~3,200ms&lt;/td&gt;
&lt;td&gt;~83ms&lt;/td&gt;
&lt;td&gt;39x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;97x on simple rules because every field is fast-checkable and the validator only runs for items that fail. 10x on mixed rules because string and numeric fields use closures while date comparisons still go through Laravel. 39x on conditional rules comes almost entirely from pre-evaluation removing excluded attributes before the validator starts.&lt;/p&gt;

&lt;p&gt;If your form has 5 fields, validation takes ~1ms either way. Nobody cares. But if you're validating a CSV import with 500 rows or an API endpoint accepting a batch of orders, 200ms vs 2ms is the difference between a page that feels instant and one that feels broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this safe?
&lt;/h2&gt;

&lt;p&gt;The closures implement the same logic as Laravel's validator — &lt;code&gt;is_string()&lt;/code&gt;, &lt;code&gt;is_numeric()&lt;/code&gt;, &lt;code&gt;filter_var()&lt;/code&gt;, the same checks. If a fast-check passes, the value would have passed Laravel's validator too. If it fails, that value goes through Laravel's full validation pipeline for the correct error message and rule identifier.&lt;/p&gt;

&lt;p&gt;The package has 516 tests and 1,020 assertions, and benchmarks run in CI on every PR. It's Octane-safe (factory resolver restored via try/finally, no static state between requests).&lt;/p&gt;

&lt;h2&gt;
  
  
  When you don't need this
&lt;/h2&gt;

&lt;p&gt;If your form requests have fewer than ~50 expanded rules, the standard validator is fine. The overhead is negligible at that scale. This package solves a specific problem: array validation with hundreds or thousands of items where wildcard expansion and per-attribute scanning become the bottleneck.&lt;/p&gt;

&lt;p&gt;If you're not sure whether validation is your bottleneck, profile first. Laravel Telescope shows total request time breakdowns. If validation isn't in the top 3, you have bigger fish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;use HasFluentRules&lt;/code&gt; to a FormRequest with wildcard rules and run your benchmarks. The performance optimization works with your existing string rules — just add the trait. There's also a fluent rule builder API with IDE autocompletion (&lt;code&gt;FluentRule::string()-&amp;gt;required()-&amp;gt;max(255)&lt;/code&gt;) but the performance fix is the real reason you want this.&lt;/p&gt;

&lt;p&gt;I've deployed this across all the large applications I maintain — around 80 files converted, thousands of tests passing, zero behavioral regressions. Each codebase surfaced different edge cases (Livewire's rule key pre-reading breaking &lt;code&gt;each()&lt;/code&gt;, Filament's trait collision with the validation override) and the package got better each time.&lt;/p&gt;

&lt;p&gt;If you've ever wondered why your import endpoint is slow, check validation before reaching for query optimization. It might be where 90% of the time goes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/SanderMuller/laravel-fluent-validation" rel="noopener noreferrer"&gt;https://github.com/SanderMuller/laravel-fluent-validation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These optimizations belong in the framework. I'll keep advocating for that upstream while maintaining the package. The performance issue is tracked in &lt;a href="https://github.com/laravel/framework/issues/49375" rel="noopener noreferrer"&gt;laravel/framework#49375&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
