<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nasrul Hazim Bin Mohamad</title>
    <description>The latest articles on DEV Community by Nasrul Hazim Bin Mohamad (@nasrulhazim).</description>
    <link>https://dev.to/nasrulhazim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F47230%2Fc062b1f5-2c98-4750-8877-6991f248b4bf.jpg</url>
      <title>DEV Community: Nasrul Hazim Bin Mohamad</title>
      <link>https://dev.to/nasrulhazim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nasrulhazim"/>
    <language>en</language>
    <item>
      <title>Making a datatable keyboard-usable: focus traps and arrow-key row reorder</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:48:36 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/making-a-datatable-keyboard-usable-focus-traps-and-arrow-key-row-reorder-252f</link>
      <guid>https://dev.to/nasrulhazim/making-a-datatable-keyboard-usable-focus-traps-and-arrow-key-row-reorder-252f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shipped two a11y wins in &lt;a href="https://github.com/cleaniquecoders/laravel-livewire-tables" rel="noopener noreferrer"&gt;laravel-livewire-tables&lt;/a&gt; v4.1.0: &lt;strong&gt;focus traps&lt;/strong&gt; on popovers and a &lt;strong&gt;keyboard alternative to drag-reorder&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Focus trap = Alpine's &lt;code&gt;x-trap&lt;/code&gt;. One directive gives you focus-in, Tab-wrap, and ESC-to-close-and-return.&lt;/li&gt;
&lt;li&gt;Drag handles are now focusable buttons that move rows with ArrowUp/ArrowDown — mouse is no longer mandatory.&lt;/li&gt;
&lt;li&gt;Bonus: an opt-in client-side column toggle that flips visibility via &lt;code&gt;x-show&lt;/code&gt; with zero Livewire round-trips.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drag-and-drop and mouse-only popovers are the two spots where datatables quietly lock out keyboard and screen-reader users. Both got fixed today. Here's the how and the why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Focus traps without writing a focus manager
&lt;/h2&gt;

&lt;p&gt;When a filter or column dropdown opens, focus should move &lt;em&gt;into&lt;/em&gt; it, Tab should cycle &lt;em&gt;within&lt;/em&gt; it, and ESC should close it and hand focus back to the button that opened it. Writing that by hand means tracking the first/last focusable element, listening for Tab at the boundaries, and stashing the trigger to restore later. It's fiddly and easy to get subtly wrong.&lt;/p&gt;

&lt;p&gt;Alpine's &lt;code&gt;x-trap&lt;/code&gt; does the whole dance in one directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div x-trap="filterPopoverOpen" ...&amp;gt;
    {{-- popover contents --}}
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind it to the same boolean that controls the popover's visibility. When the expression flips true, focus moves in; ESC and Tab-wrapping come for free; when it flips false, focus returns to the trigger. Same pattern on the column-select and bulk-actions popovers — three popovers, one mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR of this section:&lt;/strong&gt; don't hand-roll focus management. &lt;code&gt;x-trap&lt;/code&gt; is a battle-tested primitive; wiring it to your existing open/close state is a one-line change per popover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyboard row reorder
&lt;/h2&gt;

&lt;p&gt;Drag-to-reorder is the classic "works great with a mouse, impossible without one" feature. The fix isn't to replace drag — it's to give the drag handle a keyboard mode too. While reordering is active, the handle becomes a real button and listens for arrow keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-livewire-tables::table.td.plain
    x-show="currentlyReorderingStatus"
    role="button"
    aria-label="{{ __('Reorder') }}"
    x-bind:tabindex="currentlyReorderingStatus ? 0 : -1"
    x-on:keydown.arrow-up.prevent.stop="moveRow(event, -1)"
    x-on:keydown.arrow-down.prevent.stop="moveRow(event, 1)"&amp;gt;
    {{-- handle icon --}}
&amp;lt;/x-livewire-tables::table.td.plain&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things make it correct, not just present:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;role="button"&lt;/code&gt; + &lt;code&gt;aria-label&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The icon-only handle now announces itself as an actionable control with a name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tabindex&lt;/code&gt; bound to reorder state&lt;/td&gt;
&lt;td&gt;The handle is only in the tab order &lt;em&gt;while&lt;/em&gt; reordering — no dead stops otherwise.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.prevent.stop&lt;/code&gt; on arrow keys&lt;/td&gt;
&lt;td&gt;Stops the page from scrolling and the event from bubbling when a row moves.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same &lt;code&gt;moveRow()&lt;/code&gt; logic backs both the drag and the keyboard path, so behaviour stays consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it with a test
&lt;/h2&gt;

&lt;p&gt;Interactive focus behaviour ultimately needs a real browser (and a screen-reader pass), but the &lt;em&gt;markup contract&lt;/em&gt; is cheap to lock down in Pest so a refactor can't silently drop the hooks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'renders keyboard-operable reorder handles while reordering'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;livewire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PetsTable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'enableReordering'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role="button"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'aria-label="Reorder"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'moveRow(event, -1)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'moveRow(event, 1)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'traps focus inside the filter popover (x-trap)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;livewire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PetsTable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'x-trap="filterPopoverOpen"'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These aren't a substitute for testing actual focus movement — they're a guard rail. If someone rewrites the reorder cell and forgets the ARIA hooks, the suite goes red before review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: client-side column toggle
&lt;/h2&gt;

&lt;p&gt;Same release, different itch. Toggling a column normally costs a Livewire round-trip. There's now an opt-in mode — &lt;code&gt;setUseClientSideColumnVisibilityEnabled()&lt;/code&gt; — where every selectable column renders up front and the dropdown flips it via Alpine &lt;code&gt;x-show&lt;/code&gt;, entangled with &lt;code&gt;selectedColumns&lt;/code&gt;. Toggling feels instant; the selection still syncs and persists on the next Livewire request. It's opt-in on purpose (rendering every column has a cost) and it steps aside on the Flux theme, whose native cells carry no Alpine hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Accessibility on a complex widget isn't one big lift — it's a handful of small, correct primitives: &lt;code&gt;x-trap&lt;/code&gt; for focus, &lt;code&gt;role&lt;/code&gt;/&lt;code&gt;aria-label&lt;/code&gt;/&lt;code&gt;tabindex&lt;/code&gt; for keyboard controls, and a markup-contract test so none of it rots. Reach for the framework's primitive before you write your own focus manager.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>a11y</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-02 — disable means drain, and case never matters</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:48:23 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-02-disable-means-drain-and-case-never-matters-cp6</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-02-disable-means-drain-and-case-never-matters-cp6</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Disable" should drain, not delete.&lt;/strong&gt; Turning off a gateway route or an upstream target now blocks/redirects traffic gracefully instead of yanking config out from under live requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case should never decide identity.&lt;/strong&gt; Directory lookups and auth flows now normalise case so &lt;code&gt;UPPER()&lt;/code&gt; mismatches stop causing phantom "user not found" and duplicate-account bugs.&lt;/li&gt;
&lt;li&gt;A couple of sharp framework gotchas: a Blade compile quirk and a pgBouncer + PDO prepared-statement trap.&lt;/li&gt;
&lt;li&gt;Standout of the day is a public one — accessible datatables — &lt;a href="https://github.com/cleaniquecoders/laravel-livewire-tables" rel="noopener noreferrer"&gt;written up on its own&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disable is a state, not a delete
&lt;/h2&gt;

&lt;p&gt;Across an API-gateway management app I work on, the recurring theme today was the difference between &lt;em&gt;removing&lt;/em&gt; something and &lt;em&gt;disabling&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;Disabling a route shouldn't quietly deregister it and let in-flight requests fall through to nothing. It should install an explicit guard that terminates (or redirects) traffic while the route sits disabled — an off switch, not a shredder. Same idea for upstream targets: an Enable/Disable toggle now &lt;strong&gt;drains&lt;/strong&gt; a target instead of deleting it from the pool, so connections finish instead of getting cut.&lt;/p&gt;

&lt;p&gt;The lesson generalises: any "on/off" on live infrastructure wants three states in your head — active, draining, off — not a binary that maps "off" to "gone".&lt;/p&gt;

&lt;h2&gt;
  
  
  Case should never decide identity
&lt;/h2&gt;

&lt;p&gt;The other thread ran through an enterprise identity portal. Directory backends are merciless about case: an Oracle-backed username, an LDAP attribute, and an email typed at login can all differ only in capitalisation and still be the &lt;em&gt;same person&lt;/em&gt;. That mismatch shows up as "user not found", duplicate accounts, or a password reset that silently targets the wrong row.&lt;/p&gt;

&lt;p&gt;The fix is boring and correct: normalise case at the boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Match on a case-folded column, not the raw input.&lt;/span&gt;
&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UPPER(email) = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do it consistently — lookups, login sync, password reset — and a whole category of "works for me / not for them" bugs disappears. If you can, back it with a functional index on &lt;code&gt;UPPER(column)&lt;/code&gt; so the normalised lookup stays fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas worth pocketing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Number field renders invisible in a component&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@if&lt;/code&gt; placed &lt;em&gt;inside&lt;/em&gt; a component tag's attributes skips compilation&lt;/td&gt;
&lt;td&gt;Move the conditional outside the tag, or use &lt;code&gt;:attribute&lt;/code&gt; binding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query fails behind a connection pooler&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pdo_pgsql&lt;/code&gt; server-side prepared statements clash with pgBouncer (transaction pooling)&lt;/td&gt;
&lt;td&gt;Disable server-side prepares on the connection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both cost more debugging time than they should because the failure looks like something else — a styling bug, a flaky query — not a config mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Two ideas carried the day: &lt;strong&gt;disable is a lifecycle state, so drain don't delete&lt;/strong&gt;, and &lt;strong&gt;case is not identity, so normalise at the edge&lt;/strong&gt;. Neither is glamorous; both quietly remove an entire class of production surprises.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dev Log: 2026-07-01</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:09:55 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-07-01-141e</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-07-01-141e</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Public Livewire tables package: extracted a &lt;strong&gt;theme-class seam&lt;/strong&gt; so Blades stop branching per theme (full write-up in a separate post).&lt;/li&gt;
&lt;li&gt;Analytics dashboard: fixed &lt;strong&gt;timezone-bucketed charts&lt;/strong&gt; and &lt;strong&gt;exact request counts past Elasticsearch's 10k cap&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Identity sync: nailed an Active Directory &lt;strong&gt;password-set gotcha&lt;/strong&gt; that needed a model re-fetch.&lt;/li&gt;
&lt;li&gt;Membership platform: shipped &lt;strong&gt;white-label branding&lt;/strong&gt; driven by settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four threads today, one public, three anonymized.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Public: a theme-class seam for Livewire tables
&lt;/h2&gt;

&lt;p&gt;The big one. Blade partials that branched &lt;code&gt;@if($isTailwind)/@elseif($isBootstrap)&lt;/code&gt; for CSS classes don't scale when you add a third theme. I pulled all class strings into one static, cache-safe map and gave Blades a single &lt;code&gt;themeClasses('key')&lt;/code&gt; accessor — new themes override keys instead of re-implementing templates, guarded by characterization tests. Repo: &lt;a href="https://github.com/cleaniquecoders/laravel-livewire-tables" rel="noopener noreferrer"&gt;cleaniquecoders/laravel-livewire-tables&lt;/a&gt;. Separate focused post covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Analytics dashboard: timezone and count correctness
&lt;/h2&gt;

&lt;p&gt;Two subtle reporting bugs, both generic enough to be worth naming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peak-hours bucketing.&lt;/strong&gt; Hourly charts bucketed on raw UTC, so "peak hour" was off by the app's offset. Fix: bucket by the application timezone at the metrics-provider layer, not in the app on top of a UTC result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 10k ceiling.&lt;/strong&gt; Elasticsearch caps &lt;code&gt;hits.total&lt;/code&gt; at 10,000 by default, so "Total Requests" plateaued and error-rate math went wrong on busy days. The fix is one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// exact totals instead of the lower-bound 10k estimate&lt;/span&gt;
&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'track_total_hits'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bug&lt;/th&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong peak hour&lt;/td&gt;
&lt;td&gt;Bucketed on UTC&lt;/td&gt;
&lt;td&gt;Bucket on app timezone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests capped at 10k&lt;/td&gt;
&lt;td&gt;ES default &lt;code&gt;track_total_hits&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Set it to &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chart drill-down 500&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;array_sum&lt;/code&gt; over mixed shape&lt;/td&gt;
&lt;td&gt;Normalize series first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also settled the timezone at the package level and dropped the per-app override — the fix belongs where the data is shaped, not patched downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Identity sync: the AD password gotcha
&lt;/h2&gt;

&lt;p&gt;Syncing users into Active Directory, setting the password threw &lt;code&gt;unicodePwd: No such attribute&lt;/code&gt;. Cause: the directory model was stale between create and the password write. Fix: &lt;strong&gt;re-fetch the model right before setting the password&lt;/strong&gt; so the write targets a live object. Wrote it up as a support note because it's the kind of thing you rediscover painfully six months later.&lt;/p&gt;

&lt;p&gt;Also moved pending-sync reconciliation to be source-of-truth-first: reconcile against the authoritative HR system before touching the directory, so orphaned attempts heal instead of pile up.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Membership platform: white-label branding
&lt;/h2&gt;

&lt;p&gt;Footer credit, contact email, and brand colours are now settings-driven instead of hardcoded, so one codebase can present as different brands. Standard move: a typed settings object, a config fallback, and a Blade partial reading from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thread of the day
&lt;/h2&gt;

&lt;p&gt;Correctness in the boring layer. Three of four items were "the code ran fine and returned the wrong thing" — a timezone offset, a silent 10k cap, a stale directory model. None throw. All caught by asking whether the number is actually &lt;em&gt;true&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Killing @if($isTailwind) in Blade: a theme-class seam for Livewire tables</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:09:23 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/killing-ifistailwind-in-blade-a-theme-class-seam-for-livewire-tables-478d</link>
      <guid>https://dev.to/nasrulhazim/killing-ifistailwind-in-blade-a-theme-class-seam-for-livewire-tables-478d</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Blade templates that branch &lt;code&gt;@if($isTailwind) ... @elseif($isBootstrap)&lt;/code&gt; for CSS classes don't scale. Adding a Flux theme meant a third branch in every file.&lt;/li&gt;
&lt;li&gt;I moved all class strings into one static map (&lt;code&gt;ThemeStyles&lt;/code&gt;) and gave Blades a single seam: &lt;code&gt;themeClasses('key')&lt;/code&gt;. Themes now &lt;em&gt;override&lt;/em&gt; keys, not re-implement templates.&lt;/li&gt;
&lt;li&gt;Kept it closure-free so it survives &lt;code&gt;config:cache&lt;/code&gt;, and guarded every migration with characterization tests that assert the rendered HTML is byte-for-byte unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is from the public &lt;a href="https://github.com/cleaniquecoders/laravel-livewire-tables" rel="noopener noreferrer"&gt;cleaniquecoders/laravel-livewire-tables&lt;/a&gt; v4 fork.&lt;/p&gt;

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

&lt;p&gt;The package ships Tailwind and Bootstrap themes. Every Blade that needed styling did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@class([
  'px-3 py-2 ...' =&amp;gt; $isTailwind,
  'p-2 border ...' =&amp;gt; $isBootstrap,
])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine for two themes. But I was adding a &lt;strong&gt;Flux&lt;/strong&gt; theme, and the honest question was: do I really want a third &lt;code&gt;@elseif&lt;/code&gt; in every one of ~40 Blade partials? That's the smell. When adding a variant means editing every template, your variation axis is in the wrong place.&lt;/p&gt;

&lt;p&gt;Analogy: this is a light switch wired directly to the bulb. Want a dimmer? You rewire every room. Better to run everything through one panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam
&lt;/h2&gt;

&lt;p&gt;One static class holds per-theme class strings, keyed by a dotted name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ThemeStyles&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'tailwind'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'table.wrapper'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'shadow overflow-y-auto border-b ... sm:rounded-lg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'flux'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* only the keys Flux overrides */&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blades stop branching and just ask for a key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{-- component trait: themeClasses() delegates to ThemeStyles::for() --}}
&amp;lt;td @class([$this-&amp;gt;themeClasses('td.collapsed.base')])&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trait behind it is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;themeClasses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ThemeStyles&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTheme&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices matter here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flux falls back to Tailwind.&lt;/strong&gt; Flux is Tailwind-based — it only differs on a handful of keys (dropdown panels, pills, empty state). So &lt;code&gt;for()&lt;/code&gt; resolves the theme's key, and if it's missing, falls back to the &lt;code&gt;tailwind&lt;/code&gt; map. A new theme defines &lt;em&gt;only its diffs&lt;/em&gt;, not the whole surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No closures in the map.&lt;/strong&gt; Tempting to store &lt;code&gt;fn () =&amp;gt; ...&lt;/code&gt; for dynamic classes. Don't — closures can't be serialized, so &lt;code&gt;php artisan config:cache&lt;/code&gt; (and Octane) chokes. Plain strings keep it cache-safe. Anything genuinely dynamic stays in the Blade around the seam, not inside the map.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add a theme&lt;/td&gt;
&lt;td&gt;Edit ~40 Blades&lt;/td&gt;
&lt;td&gt;Add one map entry, override diffs only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class source&lt;/td&gt;
&lt;td&gt;Scattered inline&lt;/td&gt;
&lt;td&gt;One file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache-safe&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Yes (no closures)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blade job&lt;/td&gt;
&lt;td&gt;Branch per theme&lt;/td&gt;
&lt;td&gt;Ask for a key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Don't trust yourself — guard with characterization tests
&lt;/h2&gt;

&lt;p&gt;Migrating 40 templates by hand is exactly where you silently drop a class and shift a border 1px. So before touching a Blade, I pinned its current output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'renders the collapsed cell identically after the seam migration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BooleanColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Active'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHtml&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p-3 table-cell text-center'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A characterization test doesn't care whether the output is &lt;em&gt;good&lt;/em&gt; — only that it &lt;strong&gt;didn't change&lt;/strong&gt;. Migrate one Blade group, run the per-theme visual suite, confirm green, commit, next group. One slice at a time.&lt;/p&gt;

&lt;p&gt;One real find along the way: an empty-string class key rendered subtly differently through &lt;code&gt;@class()&lt;/code&gt; than an inlined blank — the kind of thing you only catch because the test compares exact output, not intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When adding a variant forces you to edit every file, extract the variation into a seam the files &lt;em&gt;consult&lt;/em&gt;. For a Blade UI package that's a keyed class map plus a one-line &lt;code&gt;themeClasses()&lt;/code&gt; accessor. Keep the map serializable so it survives config caching, and let characterization tests carry the risk of a large mechanical migration. The payoff: the Flux theme landed as a set of key overrides, not a fourth copy of every template.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>architecture</category>
      <category>php</category>
    </item>
    <item>
      <title>Dev Log: 2026-06-30</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 16:16:06 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-06-30-1l1l</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-06-30-1l1l</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Three threads today: a public Livewire tables package jumped to &lt;strong&gt;Laravel 13 + Livewire 4&lt;/strong&gt;, an identity portal got a &lt;strong&gt;cleaner audit-log datatable&lt;/strong&gt;, and an IAM system got &lt;strong&gt;defensive plumbing&lt;/strong&gt; — whitespace-safe status, read-only DB guards, and ops tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Public package: Laravel 13 + Livewire 4
&lt;/h2&gt;

&lt;p&gt;I forked &lt;code&gt;laravel-livewire-tables&lt;/code&gt; to a v4 line after upstream declined Livewire 4. Today it landed Laravel 13 support, dropped Livewire 3, moved tests to Pest 4, and got a Testbench workbench demo. Full write-up in a separate post — short version: when upstream says no, fork it, but bring the test harness.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Identity portal: collapsing a wide datatable
&lt;/h2&gt;

&lt;p&gt;An audit-log datatable had drifted into per-service columns — one column per service, which doesn't scale and reads badly on mobile. The fix was to &lt;strong&gt;merge them into a single Services cell&lt;/strong&gt; with inline pills, and pair the user into one cell and status+services into another.&lt;/p&gt;

&lt;p&gt;TL;DR: fewer, denser columns beat many thin ones. Lesson: when a Livewire table grows a column per entity, that's the signal to collapse into a composed cell (a Blade partial rendering pills) instead of widening the table forever.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One column per service&lt;/td&gt;
&lt;td&gt;One Services cell, inline pills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate user/email columns&lt;/td&gt;
&lt;td&gt;One composed user cell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faded status colours&lt;/td&gt;
&lt;td&gt;Solid status colours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  3. IAM: defensive plumbing
&lt;/h2&gt;

&lt;p&gt;The bigger thread was hardening an identity &amp;amp; access management system. Three lessons worth keeping:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whitespace-insensitive status.&lt;/strong&gt; A stored status string with a stray space (think &lt;code&gt;"ACTIVE -X"&lt;/code&gt; instead of &lt;code&gt;"ACTIVE-X"&lt;/code&gt;) silently failed a downstream eligibility gate. The root-cause fix was to normalize on the way in and compare insensitively, then backfill the bad rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// normalize before persisting, compare without trusting whitespace&lt;/span&gt;
&lt;span class="nv"&gt;$canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;squish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A status string is a contract. If a space can break a gate, the comparison is too trusting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only DB guard.&lt;/strong&gt; A failover left the DB read-only, and the scheduler kept firing jobs that all failed — a cascade of noise. The fix: detect read-only mode and stop the cron cascade early, with a notification instead of a flood of exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ops tooling over the app.&lt;/strong&gt; Added audit + remediation tools (status audit, resync, normalize) so on-call can inspect and fix state without raw DB access, plus permission-gated access to queue/dashboard internals. Exposing safe, gated operations beats handing out database credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Different repos, one theme: &lt;strong&gt;make the source of truth tolerant of messy input, then guard the edges&lt;/strong&gt; — whitespace, failover, and access all count as edges.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Forking laravel-livewire-tables for Laravel 13 + Livewire 4</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 16:15:38 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/forking-laravel-livewire-tables-for-laravel-13-livewire-4-4800</link>
      <guid>https://dev.to/nasrulhazim/forking-laravel-livewire-tables-for-laravel-13-livewire-4-4800</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upstream &lt;code&gt;rappasoft/laravel-livewire-tables&lt;/code&gt; marked Livewire 4 support &lt;code&gt;wontfix&lt;/code&gt;, so I forked it.&lt;/li&gt;
&lt;li&gt;The fork now targets &lt;strong&gt;Laravel 13 (keeps 12)&lt;/strong&gt;, runs on &lt;strong&gt;Livewire 4&lt;/strong&gt;, and &lt;strong&gt;drops Livewire 3&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Tests moved to &lt;strong&gt;Pest 4&lt;/strong&gt; (old PHPUnit classes still run via interop), with a &lt;strong&gt;Testbench workbench&lt;/strong&gt; demo app.&lt;/li&gt;
&lt;li&gt;Takeaway: when an upstream says no, a fork is fine — but only if you bring the test harness with you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why fork at all
&lt;/h2&gt;

&lt;p&gt;I lean on this package across projects: sortable, searchable, filterable tables for Livewire. The problem: upstream &lt;a href="https://github.com/rappasoft/laravel-livewire-tables/issues/2315" rel="noopener noreferrer"&gt;declined Livewire 4 support&lt;/a&gt; (&lt;code&gt;wontfix&lt;/code&gt;) and hadn't shipped Laravel 13. I didn't want to be stuck on Livewire 3 forever, so I started a v4 line in my fork at &lt;a href="https://github.com/cleaniquecoders/laravel-livewire-tables" rel="noopener noreferrer"&gt;cleaniquecoders/laravel-livewire-tables&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A fork is like renovating a rented house you've decided to buy — worth it only if you're committed to maintaining it. I am, so the roadmap lives in GitHub milestones M1–M8.&lt;/p&gt;

&lt;h2&gt;
  
  
  What landed today
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Milestone&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;M1&lt;/td&gt;
&lt;td&gt;Laravel 13 + Livewire 4 support; drop Livewire 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M2&lt;/td&gt;
&lt;td&gt;Migrate test suite to Pest 4 + Testbench workbench demo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M3&lt;/td&gt;
&lt;td&gt;Upstream bug-fix cluster + regression tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;Replace L10/L11 workflows with a v4 matrix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Pint formatting pass under the upgraded toolchain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The constraint matrix
&lt;/h2&gt;

&lt;p&gt;The dependency floor moved up. Here's the before/after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;8.1+&lt;/td&gt;
&lt;td&gt;8.2+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;td&gt;10 / 11&lt;/td&gt;
&lt;td&gt;12 / 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Livewire&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4 (3 dropped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;PHPUnit&lt;/td&gt;
&lt;td&gt;Pest 4 (PHPUnit interop)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CI now runs the cross-product so a regression on any combo shows up fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;php&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.3'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.4'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.5'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;laravel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;12.*'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;13.*'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vendor/bin/pest --no-coverage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pest without a big-bang rewrite
&lt;/h2&gt;

&lt;p&gt;The trick to migrating tests is &lt;em&gt;don't rewrite them all at once&lt;/em&gt;. Pest runs class-based PHPUnit tests unchanged via its interop layer, so the ~hundreds of existing &lt;code&gt;test_*&lt;/code&gt; methods keep passing while new tests are written Pest-native.&lt;/p&gt;

&lt;p&gt;The bind is one line in &lt;code&gt;tests/Pest.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestCase&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Visuals'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Feature'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And new tests read the way Pest tests should — fluent, no class boilerplate:&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;Pest\Livewire\livewire&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'builds a configurable column fluently'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSortable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSearchable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the migration is incremental: old suite stays green, new work goes Pest-first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A workbench you can actually click
&lt;/h2&gt;

&lt;p&gt;Testing a UI package against assertions only gets you so far. M2 adds an Orchestra Testbench &lt;strong&gt;workbench&lt;/strong&gt; — a tiny real Laravel app inside the package you can boot and click through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer build   &lt;span class="c"&gt;# testbench workbench:build&lt;/span&gt;
composer serve   &lt;span class="c"&gt;# testbench serve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships demo models (Owner, Species, Breed, Pet), a seeder, and a &lt;code&gt;DemoPetsTable&lt;/code&gt; Livewire component, so you can see the table render against real data instead of imagining it from a test name. For a component library, that's the difference between "tests pass" and "it actually looks right."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The heavy refactor is still ahead (M6): the component composes ~26 load-order-dependent &lt;code&gt;With*&lt;/code&gt; traits and inlines theme branches across ~59 Blade files. That sprawl is the real target — but the rule is behaviour-preserving and test-guarded. Today's job was getting onto Laravel 13 + Livewire 4 with a green Pest suite first, so the refactor has a safety net.&lt;/p&gt;

&lt;p&gt;If you're maintaining a package and upstream says no to the version you need: fork it, but bring the tests. The test harness is what makes a fork maintainable instead of a liability.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>php</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Resolve the tenant from the user, not the request</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:04:56 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/resolve-the-tenant-from-the-user-not-the-request-4n02</link>
      <guid>https://dev.to/nasrulhazim/resolve-the-tenant-from-the-user-not-the-request-4n02</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A multi-tenant app was resolving the active tenant from the &lt;strong&gt;request&lt;/strong&gt; (subdomain/header) instead of the authenticated &lt;strong&gt;user&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;That makes the client the source of truth for "which tenant am I" — the wrong place for it.&lt;/li&gt;
&lt;li&gt;Fix: derive the tenant from the user's organization membership, enforce it in middleware, and fail closed. One test locks the behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug, in one sentence
&lt;/h2&gt;

&lt;p&gt;The request was telling the app which tenant to load, and the app believed it.&lt;/p&gt;

&lt;p&gt;In a multi-tenant SaaS, every query is implicitly scoped: "give me &lt;em&gt;this tenant's&lt;/em&gt; dashboards." If the tenant ID comes from something the client controls — a subdomain, a header, a route param — then the scoping is only as trustworthy as the client. That's a leak waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the trust should live
&lt;/h2&gt;

&lt;p&gt;Think of it like a building pass. The request is someone saying "I'm here for floor 9." The membership record is the pass that says which floors you're actually allowed on. You check the pass, not the claim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;request (subdomain / header)&lt;/td&gt;
&lt;td&gt;user's organization membership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who decides the tenant&lt;/td&gt;
&lt;td&gt;the client&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;user can land in a tenant they don't belong to&lt;/td&gt;
&lt;td&gt;resolution fails closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testable?&lt;/td&gt;
&lt;td&gt;hard — depends on request shape&lt;/td&gt;
&lt;td&gt;yes — depends on the user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;Resolve the tenant from the authenticated user's organization, in one middleware, before anything tenant-scoped runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SetTenantContext&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$org&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;currentOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// No org, no tenant context. Fail closed, never guess.&lt;/span&gt;
        &lt;span class="nf"&gt;abort_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$org&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'No organization context.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Tenancy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setCurrent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// server-derived, not request-derived&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key line isn't the &lt;code&gt;setCurrent()&lt;/code&gt; — it's that the value comes from &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt;, not from &lt;code&gt;$request&lt;/code&gt;. The user is authenticated; the subdomain is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  request ──&amp;gt; [auth] ──&amp;gt; [SetTenantContext] ──&amp;gt; tenant-scoped routes
                              │
                              └── tenant = user's org  (NOT the URL/header)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lock it with a test
&lt;/h2&gt;

&lt;p&gt;A leak like this is exactly the kind of thing that silently regresses. So the fix isn't done until a test would scream if someone reintroduces it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'never resolves a tenant the user does not belong to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$userA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;inOrganization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orgA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$orgB&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Even if the request "asks" for org B, user A must stay in org A.&lt;/span&gt;
    &lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orgB&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getKey&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboards'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Tenancy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orgA&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If a value scopes data, it has to come from something the client can't forge. For tenant resolution that means the authenticated user's membership — verified server-side, failing closed when it's missing. Resolve identity from who you &lt;em&gt;are&lt;/em&gt;, not from what you &lt;em&gt;ask for&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>architecture</category>
      <category>php</category>
    </item>
    <item>
      <title>When a KPI reads 163 billion instead of 819</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:04:21 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/when-a-kpi-reads-163-billion-instead-of-819-4ph7</link>
      <guid>https://dev.to/nasrulhazim/when-a-kpi-reads-163-billion-instead-of-819-4ph7</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A metrics engine had two query paths — a SQL push-down for big datasets, an in-memory aggregator for small ones. They drifted.&lt;/li&gt;
&lt;li&gt;The push-down path &lt;em&gt;bound&lt;/em&gt; a &lt;code&gt;metric&lt;/code&gt; parameter but never added it to the &lt;code&gt;WHERE&lt;/code&gt;. With several metric series in one dataset, every query summed across all of them.&lt;/li&gt;
&lt;li&gt;A KPI that should read &lt;strong&gt;819&lt;/strong&gt; read &lt;strong&gt;163,667,603,769&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fix: put the &lt;code&gt;metric_key&lt;/code&gt; predicate in the shared base &lt;code&gt;WHERE&lt;/code&gt; so every compile path inherits it, and regression-test both paths assert it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup: two paths, one contract
&lt;/h2&gt;

&lt;p&gt;A lot of analytics layers compute the same number two ways. For a big dataset you push the aggregation down to the database. For a small one — a preview, a draft dashboard — you pull the rows and aggregate in memory. Faster path, correct path. Both are supposed to return the same value. That's the contract.&lt;/p&gt;

&lt;p&gt;The dataset stores rows keyed by a &lt;code&gt;metric_key&lt;/code&gt;, because one dataset can hold several series at once — say a plain row count &lt;em&gt;and&lt;/em&gt; a count-distinct. Each series lives in the same table, told apart only by its key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a bound param is not a filter
&lt;/h2&gt;

&lt;p&gt;The in-memory aggregator filtered by &lt;code&gt;metric_key&lt;/code&gt; correctly. The SQL compiler bound a &lt;code&gt;metric&lt;/code&gt; parameter into the query... and never referenced it in the &lt;code&gt;WHERE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With a single series in the dataset, it worked by accident — there was nothing else to sum. Add a second series and the math quietly breaks: the query sums across &lt;em&gt;every&lt;/em&gt; series. In this case the second series stored hashed values around 1.9 billion each, so the KPI ballooned from 819 to 163 billion.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;metric&lt;/code&gt; value&lt;/td&gt;
&lt;td&gt;bound, unused&lt;/td&gt;
&lt;td&gt;bound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WHERE&lt;/code&gt; predicate&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none on metric)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;metric_key = {metric:String}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 series in dataset&lt;/td&gt;
&lt;td&gt;correct (by luck)&lt;/td&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N series in dataset&lt;/td&gt;
&lt;td&gt;sums across all&lt;/td&gt;
&lt;td&gt;isolated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lesson is small and easy to miss: &lt;strong&gt;binding a parameter only makes the value available — it does nothing until a predicate references it.&lt;/strong&gt; When one path already returns sane-looking numbers, nobody goes looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix is parity, not a patch
&lt;/h2&gt;

&lt;p&gt;You could bolt the predicate onto the one broken query and move on. Better: put shared predicates in one place so every compile path inherits them by construction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌─────────────────────┐
 query  ──▶ │  basePredicates()   │  ◀── metric_key = {metric}
            │  (shared WHERE)     │      tenant_id  = {tenant}
            └──────────┬──────────┘
                       │
        ┌──────────────┴──────────────┐
        ▼                             ▼
  push-down SQL                 in-memory aggregator
  (big datasets)                (preview / small)
        └──────────────┬──────────────┘
                       ▼
                same number
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When two code paths must agree, pin the agreement in a test — don't just test each path alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'isolates every metric query by metric_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;CompilePath&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;PushDown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CompilePath&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;InMemory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MetricCompiler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;metricKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'record_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'acme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sql&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wherePredicates&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'metric_key = {metric:String}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Any time you have a fast path and a correct path for the same value, write the test that asserts they produce the same filter — and the same answer. The gap between "works on my one-series sample" and "sums a billion rows in production" is exactly one missing predicate. Drift between two paths is where the absurd-number bugs live.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
      <category>php</category>
    </item>
    <item>
      <title>Dev Log: 2026-06-29</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:03:59 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-06-29-1hed</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-06-29-1hed</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Two threads today: an &lt;strong&gt;organization layer&lt;/strong&gt; on top of an existing multi-tenant app, and &lt;strong&gt;driver-based password-reset backends&lt;/strong&gt; in an identity portal. Both came down to the same idea — put the source of truth in the right place, then test it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenant app: an organization layer above tenancy
&lt;/h2&gt;

&lt;p&gt;The product already had tenancy. What it lacked was a human-friendly layer on top: organizations users actually belong to, can switch between, and manage.&lt;/p&gt;

&lt;p&gt;What landed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Org switcher&lt;/td&gt;
&lt;td&gt;A sidebar switcher to move between organizations you belong to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management&lt;/td&gt;
&lt;td&gt;Create/update org, invitations, ownership transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenancy&lt;/td&gt;
&lt;td&gt;Resolve the active tenant from the user's org — closed a leak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Dark-mode pass + responsive fixes across the org views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboards&lt;/td&gt;
&lt;td&gt;Richer per-widget configuration from the UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The standout is the tenancy fix: the active tenant was being resolved from the request instead of the authenticated user. I pulled that into its own focused post — &lt;strong&gt;"Resolve the tenant from the user, not the request."&lt;/strong&gt; Short version: if a value scopes data, it can't come from something the client controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity portal: make the reset backends swappable
&lt;/h2&gt;

&lt;p&gt;The password-reset flow needed to support more than one backend, and let an admin decide the order they run in. Classic case for a &lt;strong&gt;driver-based abstraction&lt;/strong&gt; — a contract plus interchangeable drivers, picked at runtime from config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PasswordResetBackend&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two optional backends came back as drivers behind that contract, and the run order is now admin-reorderable instead of hard-coded. Adding a third backend later is a new class + a config line — no touching the flow itself.&lt;/p&gt;

&lt;p&gt;The other half of the day was unglamorous but necessary: the test suite had drifted — stale tests for removed features, and &lt;strong&gt;env leakage&lt;/strong&gt; between tests (one test's state bleeding into the next). Fixed the leakage, deleted the dead tests, and the suite is honest again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Two different apps, one lesson repeated: decide &lt;em&gt;where the truth lives&lt;/em&gt; (the user's org; the config-driven driver), make the boundary explicit, and pin it with a test. Everything else is UI.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Dev Log: 2026-06-28</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:03:45 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-06-28-275o</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-06-28-275o</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Centred a sidebar brand mark in the collapsed rail (open-source starter kit) — pure CSS, no JS.&lt;/li&gt;
&lt;li&gt;A CRM app got a "daily cockpit" dashboard (hot leads + overdue follow-ups) plus a full favicon/PWA icon set.&lt;/li&gt;
&lt;li&gt;An analytics product's ingest pipeline learned to handle messy uploads — files with no date column and no numeric measure — and a nasty metrics bug got squashed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A spread day across three repos. Quick tour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Centring a collapsed sidebar logo (CSS only)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/cleaniquecoders/kickoff" rel="noopener noreferrer"&gt;Kickoff&lt;/a&gt;, my open-source Laravel starter kit, had a small visual snag: when the sidebar collapses to a narrow rail, the header switches to a column — but the brand mark sat off-centre. The content area is ~72px, yet the logo kept its width and a leftover &lt;code&gt;space-x&lt;/code&gt; margin, nudging it left of the nav icons.&lt;/p&gt;

&lt;p&gt;No JavaScript needed. Make the logo and toggle full-width, centre their content, and zero the leftover child margins when collapsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-flux-sidebar&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;data-collapsed&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.sidebar-header&lt;/span&gt; &lt;span class="nc"&gt;.app-logo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;padding-inline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;/* kill the leftover space-x margin pushing it off-centre */&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-flux-sidebar&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;data-collapsed&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.sidebar-header&lt;/span&gt; &lt;span class="nc"&gt;.app-logo&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: when a flex container changes direction, old horizontal margins don't disappear — they just push things in the new axis. Tag the element, scope the override to the collapsed state, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  A CRM "daily cockpit"
&lt;/h2&gt;

&lt;p&gt;A CRM app I work on got a dashboard rebuild: instead of a generic landing screen, the first thing you see is what needs action today — hot leads and overdue follow-ups. The cockpit framing matters more than the widgets: surface the work, don't make people hunt for it. Also shipped a full favicon/PWA icon set and a branded responsive landing page, with feature tests so the brand pass didn't quietly break routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingest that survives real-world files
&lt;/h2&gt;

&lt;p&gt;The bigger chunk of the day went into an analytics/dashboard product's ingest pipeline. Real uploads are messy, so the pipeline now copes with the awkward shapes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Awkward file&lt;/th&gt;
&lt;th&gt;Handling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No date/period column&lt;/td&gt;
&lt;td&gt;Derive the snapshot period from the filename (&lt;code&gt;report-2026-06.xlsx&lt;/code&gt; → &lt;code&gt;2026-06&lt;/code&gt;), else ask or use upload time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No numeric measure&lt;/td&gt;
&lt;td&gt;Let the user choose the aggregation (e.g. count rows) instead of failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dirty columns&lt;/td&gt;
&lt;td&gt;A data-quality panel flags blanks, near-duplicate "variant clusters" (&lt;code&gt;A &amp;amp; B&lt;/code&gt; vs &lt;code&gt;A And B&lt;/code&gt;), and encoding mojibake before anything reaches a dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The upload flow also got simpler: drop a file → instant clean dashboard, with column mapping tucked behind an &lt;em&gt;Advanced&lt;/em&gt; step for the cases that need it.&lt;/p&gt;

&lt;p&gt;And the bug of the day — a metrics query that summed across &lt;em&gt;every&lt;/em&gt; series in a dataset and turned an 819 KPI into 163 billion. That one earned its own write-up: &lt;strong&gt;"When a KPI reads 163 billion instead of 819."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;More ingest edge cases (filename date formats, transforms), and rolling the centred-rail fix into the other apps that share the sidebar.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Dev Log: 2026-06-27</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Sat, 27 Jun 2026 15:44:00 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/dev-log-2026-06-27-3ajj</link>
      <guid>https://dev.to/nasrulhazim/dev-log-2026-06-27-3ajj</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shipped a &lt;code&gt;composer share&lt;/code&gt; command + reverse-proxy trust so a local Laravel app can be tested over a public HTTPS tunnel without mixed-content errors.&lt;/li&gt;
&lt;li&gt;Built a self-serve onboarding flow: upload a spreadsheet, a wizard maps the columns and auto-builds a metric and a dashboard.&lt;/li&gt;
&lt;li&gt;Added a CRM intake pass: bulk contact import (paste/CSV/Excel) with duplicate detection + merge, web lead capture via UTM, and an activity timeline feeding a lead-scoring engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Busy day across four repos. Grouping by theme, not commit order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public tunnels for local apps
&lt;/h2&gt;

&lt;p&gt;The standout (full write-up: &lt;a href="https://github.com/cleaniquecoders/kickoff" rel="noopener noreferrer"&gt;cleaniquecoders/kickoff&lt;/a&gt;): sharing a local app over a Cloudflare/ngrok tunnel kept breaking on mixed content because &lt;code&gt;php artisan serve&lt;/code&gt; is HTTP behind an HTTPS proxy. Fixes — &lt;code&gt;trustProxies(at: '*')&lt;/code&gt; so &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; is honoured, plus pointing &lt;code&gt;APP_URL&lt;/code&gt;/&lt;code&gt;ASSET_URL&lt;/code&gt; at the tunnel URL. Bundled into one &lt;code&gt;composer share&lt;/code&gt; command that restores &lt;code&gt;.env&lt;/code&gt; on exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-serve data onboarding
&lt;/h2&gt;

&lt;p&gt;Across an analytics app, the theme was killing setup anxiety. The win: an upload-to-dashboard wizard where you drop a spreadsheet and it does the rest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload&lt;/td&gt;
&lt;td&gt;Accept an &lt;code&gt;.xlsx&lt;/code&gt;, show a raw-data preview&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map columns&lt;/td&gt;
&lt;td&gt;Smart defaults + guidance; &lt;strong&gt;each value column becomes its own metric&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate&lt;/td&gt;
&lt;td&gt;Friendly errors for duplicate metric key / board slug instead of a DB exception&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Auto-create the metric + a starter dashboard from the mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details I liked: auto-deriving a Title-Cased label from the column name, and shipping ~30 industry sample datasets so a new user can click "try an example" instead of hunting for a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  CRM intake + lead scoring
&lt;/h2&gt;

&lt;p&gt;On the CRM side: getting leads &lt;em&gt;in&lt;/em&gt; and ranking them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk import&lt;/strong&gt; — paste, CSV, or Excel. Missing name? Derive a Title-Cased one from the email; keep existing app users out of the funnel; map Status/Type and Source columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web lead capture&lt;/strong&gt; — a UTM-aware intake endpoint with duplicate detection and a merge tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activity timeline + scoring&lt;/strong&gt; — each logged activity raises a contact's score via a listener; a scheduled command decays stale scores so the ranking reflects &lt;em&gt;recent&lt;/em&gt; engagement, not all-time noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The package-worthy bit is the scoring flow: event (&lt;code&gt;ActivityLogged&lt;/code&gt;) → listener (&lt;code&gt;AwardScoreForActivity&lt;/code&gt;) → a service applying rule-based points, with a scheduled decay command. Side effects stay out of the controller.&lt;/p&gt;

&lt;p&gt;Also landed a small "MCP Tokens" settings page across apps — mint/revoke personal access tokens for MCP access.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A Pest pass on the wizard's column-mapping edge cases (duplicate keys, empty columns), and a driver-based abstraction for the scoring rules so point values aren't hard-coded. Tomorrow's problem.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>livewire</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Share Your Local Laravel App on a Public URL Without Mixed-Content Hell</title>
      <dc:creator>Nasrul Hazim Bin Mohamad</dc:creator>
      <pubDate>Sat, 27 Jun 2026 15:43:17 +0000</pubDate>
      <link>https://dev.to/nasrulhazim/share-your-local-laravel-app-on-a-public-url-without-mixed-content-hell-2996</link>
      <guid>https://dev.to/nasrulhazim/share-your-local-laravel-app-on-a-public-url-without-mixed-content-hell-2996</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Exposing a local Laravel app over a public HTTPS tunnel breaks two ways: assets load from your &lt;code&gt;localhost&lt;/code&gt; (teammate sees no CSS), and &lt;code&gt;php artisan serve&lt;/code&gt; speaks plain HTTP behind the HTTPS proxy, so Livewire/Flux emit &lt;code&gt;http://&lt;/code&gt; URLs that the browser blocks as mixed content.&lt;/li&gt;
&lt;li&gt;Fix the protocol with &lt;code&gt;trustProxies(at: '*')&lt;/code&gt;, and fix the asset origin by pointing &lt;code&gt;APP_URL&lt;/code&gt; + &lt;code&gt;ASSET_URL&lt;/code&gt; at the tunnel URL.&lt;/li&gt;
&lt;li&gt;I wrapped the whole thing in a &lt;code&gt;composer share&lt;/code&gt; command that builds assets, opens the tunnel, rewrites &lt;code&gt;.env&lt;/code&gt;, and restores it on exit. It's in &lt;a href="https://github.com/cleaniquecoders/kickoff" rel="noopener noreferrer"&gt;cleaniquecoders/kickoff&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You want a teammate to click through your work-in-progress without deploying. The classic move: &lt;code&gt;cloudflared tunnel&lt;/code&gt; (or ngrok) gives you a public &lt;code&gt;https://...&lt;/code&gt; URL pointing at your local &lt;code&gt;php artisan serve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then two things go wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Teammate sees raw HTML, no styling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@vite&lt;/code&gt; points at the Vite dev server on &lt;em&gt;your&lt;/em&gt; localhost (&lt;code&gt;public/hot&lt;/code&gt; exists)&lt;/td&gt;
&lt;td&gt;Build assets, delete &lt;code&gt;public/hot&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console: "mixed content blocked", random 419s&lt;/td&gt;
&lt;td&gt;Proxy is HTTPS but &lt;code&gt;artisan serve&lt;/code&gt; is HTTP → &lt;code&gt;request()-&amp;gt;isSecure()&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; → &lt;code&gt;http://&lt;/code&gt; asset/script URLs&lt;/td&gt;
&lt;td&gt;Trust the proxy + set &lt;code&gt;ASSET_URL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Fix 1: trust the proxy
&lt;/h2&gt;

&lt;p&gt;The tunnel terminates TLS and forwards plain HTTP to your app with an &lt;code&gt;X-Forwarded-Proto: https&lt;/code&gt; header. Laravel ignores that header unless you tell it the proxy is trustworthy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bootstrap/app.php&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Honour X-Forwarded-Proto from the tunnel so isSecure() is true&lt;/span&gt;
    &lt;span class="c1"&gt;// and Livewire/Flux emit https:// URLs (no mixed-content blocks).&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;trustProxies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;at: '*'&lt;/code&gt; trusts any proxy. That's fine for a throwaway tunnel on your own machine. &lt;strong&gt;In production, trust specific load-balancer IPs instead&lt;/strong&gt; — a wildcard there lets a client spoof the forwarded headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: point assets at the tunnel
&lt;/h2&gt;

&lt;p&gt;Even with HTTPS detected, &lt;code&gt;@vite&lt;/code&gt; and asset helpers resolve against &lt;code&gt;APP_URL&lt;/code&gt;. If that's still &lt;code&gt;http://localhost&lt;/code&gt;, the public visitor's browser tries to fetch your &lt;code&gt;localhost&lt;/code&gt;. So rewrite both to the tunnel URL once it's up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# wait for the tunnel to print its public https URL, then:&lt;/span&gt;
set_env APP_URL   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUBLIC_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
set_env ASSET_URL &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUBLIC_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
php artisan config:clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one habit that saves you: &lt;strong&gt;back up &lt;code&gt;.env&lt;/code&gt; first and restore it on exit&lt;/strong&gt;, so a throwaway URL never gets left behind in your config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ENV_BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENV_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; public/hot
    &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENV_BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; .env   &lt;span class="c"&gt;# restore APP_URL / ASSET_URL verbatim&lt;/span&gt;
    php artisan config:clear &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'exit 130'&lt;/span&gt; INT TERM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One command
&lt;/h2&gt;

&lt;p&gt;Wrapped together, &lt;code&gt;composer share&lt;/code&gt; does the boring sequence every time: &lt;code&gt;npm run build&lt;/code&gt; → &lt;code&gt;rm public/hot&lt;/code&gt; → &lt;code&gt;php artisan serve&lt;/code&gt; → open a Cloudflare (or ngrok) tunnel → scrape the public URL → set &lt;code&gt;APP_URL&lt;/code&gt;/&lt;code&gt;ASSET_URL&lt;/code&gt; → stream output until Ctrl+C → restore &lt;code&gt;.env&lt;/code&gt;. Cloudflare's quick tunnel needs no account, so it's the default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composer share
  │
  ├─ npm run build         (real assets, not the dev server)
  ├─ rm public/hot         (stop @vite pointing at localhost)
  ├─ php artisan serve     (:8000)
  ├─ cloudflared tunnel    -&amp;gt; https://xxxx.trycloudflare.com
  ├─ set APP_URL+ASSET_URL -&amp;gt; that url, config:clear
  └─ Ctrl+C -&amp;gt; restore .env, drop public/hot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The tunnel was never the hard part — the protocol mismatch was. Trust the forwarded proto, anchor your asset URL to the public host, and always restore &lt;code&gt;.env&lt;/code&gt;. Bundle it into one command so "send me a link" takes five seconds, not five minutes of debugging blank CSS.&lt;/p&gt;

&lt;p&gt;Code lives in &lt;a href="https://github.com/cleaniquecoders/kickoff" rel="noopener noreferrer"&gt;cleaniquecoders/kickoff&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
