<?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: Ahmad Khokhar</title>
    <description>The latest articles on DEV Community by Ahmad Khokhar (@ahmad_khokhar).</description>
    <link>https://dev.to/ahmad_khokhar</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%2F3958049%2F6f5ed82a-5109-4cc6-9b13-fa59c0d688c7.jpg</url>
      <title>DEV Community: Ahmad Khokhar</title>
      <link>https://dev.to/ahmad_khokhar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmad_khokhar"/>
    <language>en</language>
    <item>
      <title>AI Doesn't Write Buggy Code — Your Workflow Does</title>
      <dc:creator>Ahmad Khokhar</dc:creator>
      <pubDate>Mon, 08 Jun 2026 16:22:43 +0000</pubDate>
      <link>https://dev.to/ahmad_khokhar/ai-doesnt-write-buggy-code-your-workflow-does-397o</link>
      <guid>https://dev.to/ahmad_khokhar/ai-doesnt-write-buggy-code-your-workflow-does-397o</guid>
      <description>&lt;p&gt;There's a complaint I see constantly from developers:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"AI wrote the code but I spent hours debugging it."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been using AI heavily in my own projects. And honestly — I don't relate to this experience. Not because the AI I use is magical, but because I've noticed the bugs almost always trace back to one of two things. Neither of them is the AI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1 — The Implementation Plan
&lt;/h2&gt;

&lt;p&gt;When I give an AI agent a vague instruction, I get vague code back. That's not a bug. That's math.&lt;/p&gt;

&lt;p&gt;If I say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Build me an authentication system"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'm going to get generic code that makes assumptions about my database structure, my session handling, my token strategy, and my error responses. Some of those assumptions will be wrong. I'll spend an hour figuring out which ones.&lt;/p&gt;

&lt;p&gt;If I say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Build a token-based auth system using Laravel Sanctum. Users table already exists with these columns. Tokens stored in personal_access_tokens. Login returns a Bearer token. Failed login returns 401 with this JSON structure. No session-based auth needed."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The output is precise. The edge cases are handled. There's almost nothing to debug.&lt;/p&gt;

&lt;p&gt;The AI didn't get smarter. I gave it a better plan.&lt;/p&gt;

&lt;p&gt;This is the part most developers skip. They open a chat, type a rough idea, and then blame the tool when the output doesn't match the thing they had in their head but never wrote down.&lt;/p&gt;

&lt;p&gt;An AI agent is not a mind reader. It's a very fast executor of whatever context you give it. The quality of the output is almost entirely a function of the quality of the input.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2 — Session Length
&lt;/h2&gt;

&lt;p&gt;This one is less talked about but equally important.&lt;/p&gt;

&lt;p&gt;AI models have a context window. As a conversation gets longer, earlier parts of it get compressed or effectively lost. The model starts making decisions without full access to what was established at the start of the session.&lt;/p&gt;

&lt;p&gt;In practice this looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A function that contradicts a constraint you defined 40 messages ago&lt;/li&gt;
&lt;li&gt;A variable name that doesn't match the naming convention from earlier in the project&lt;/li&gt;
&lt;li&gt;Logic that reimplements something already handled elsewhere in the codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code isn't wrong because the AI is bad. It's wrong because you asked it to hold more context than it reliably can.&lt;/p&gt;

&lt;p&gt;The fix is simple: &lt;strong&gt;start a new session when the context gets heavy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before a new session, summarize what's been built, what conventions are in use, what's already handled, and what the next task is. Paste that as the first message. You're not losing work — you're giving the AI a clean working memory instead of a cluttered one.&lt;/p&gt;

&lt;p&gt;Developers who don't do this and then hit inconsistencies blame the AI. Developers who manage their sessions rarely hit those inconsistencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where AI Actually Deserves the Blame
&lt;/h2&gt;

&lt;p&gt;I want to be honest here — there are cases where the AI genuinely introduces bugs regardless of how good your plan is.&lt;/p&gt;

&lt;p&gt;Complex interdependent business logic is one. If you have five systems that affect each other in non-obvious ways, AI will occasionally make a confident assumption that's subtly wrong. It won't tell you it's guessing. It'll just be wrong.&lt;/p&gt;

&lt;p&gt;Large existing codebases are another. Asking an AI to extend code it hasn't seen in full leads to inconsistencies. It's working with a partial picture.&lt;/p&gt;

&lt;p&gt;These cases exist. They're real. But in my experience they're maybe 10-20% of the debugging complaints I see developers make. The other 80% is a planning problem or a session management problem — both of which are entirely within the developer's control.&lt;/p&gt;




&lt;h2&gt;
  
  
  "But Real Development IS Complex Tasks"
&lt;/h2&gt;

&lt;p&gt;Fair pushback — and I've heard it.&lt;/p&gt;

&lt;p&gt;The argument is: simple tasks are easy to plan, but real-world development involves complex interdependent systems. That's where AI falls apart, and no amount of planning fixes that.&lt;/p&gt;

&lt;p&gt;I partially agree. But the response is not "therefore AI can't handle complex work." The response is: &lt;strong&gt;complex tasks are just multiple small well-defined tasks stacked together.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The mistake is treating complexity as one big instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Build me a multi-tenant payroll engine with attendance, leave, deductions, and statutory compliance"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not a task. That's a project. Throwing that at an AI in one session and expecting clean output is not a workflow problem — it's an expectation problem.&lt;/p&gt;

&lt;p&gt;Break it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session 1 — Tenant database switching on auth
Session 2 — Employee model and relationships
Session 3 — Attendance calculation logic
Session 4 — Payroll computation engine
Session 5 — Statutory deductions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each session gets a fresh context. Each task is specific. The AI's output per session is clean because the scope is clear.&lt;/p&gt;

&lt;p&gt;The complexity doesn't disappear — you're managing it. That's the actual skill. Decomposing a complex system into well-scoped pieces and feeding them to an AI with proper context is harder than typing one big prompt. It's also the reason some developers get great results and others don't.&lt;/p&gt;

&lt;p&gt;Complex tasks don't break the argument. They prove it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Do
&lt;/h2&gt;

&lt;p&gt;Before any non-trivial task I write out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What already exists and what it does&lt;/li&gt;
&lt;li&gt;What I'm building and why&lt;/li&gt;
&lt;li&gt;The data structures involved&lt;/li&gt;
&lt;li&gt;Edge cases I already know about&lt;/li&gt;
&lt;li&gt;What the output should look like&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I start a fresh session with that as context.&lt;/p&gt;

&lt;p&gt;When a session gets long — past maybe 20-30 exchanges on a complex task — I start a new one with a summary.&lt;/p&gt;

&lt;p&gt;This workflow basically eliminated the "AI wrote broken code" problem for me. Not because I'm using it better than everyone else — just because I stopped treating it like a shortcut and started treating it like a junior developer who needs a proper brief.&lt;/p&gt;




&lt;p&gt;A junior developer given a vague task will produce vague work. The same junior developer given a clear spec, existing context, and defined constraints will produce something usable.&lt;/p&gt;

&lt;p&gt;The AI is the junior developer. The brief is your job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about practical decisions behind real projects. Follow if that's useful.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>What We Learned Building a Multi-Tenant Payroll Engine in Laravel</title>
      <dc:creator>Ahmad Khokhar</dc:creator>
      <pubDate>Sat, 06 Jun 2026 16:21:58 +0000</pubDate>
      <link>https://dev.to/syftnex/what-we-learned-building-a-multi-tenant-payroll-engine-in-laravel-54g3</link>
      <guid>https://dev.to/syftnex/what-we-learned-building-a-multi-tenant-payroll-engine-in-laravel-54g3</guid>
      <description>&lt;p&gt;Payroll software looks simple from the outside. You multiply hours by rate, subtract taxes, and send a number to a bank. That's it.&lt;/p&gt;

&lt;p&gt;Then you actually build one.&lt;/p&gt;

&lt;p&gt;We've been building an HR and payroll platform for a while now — &lt;a href="https://syftnex.com/product/hr-payroll/" rel="noopener noreferrer"&gt;14 integrated modules&lt;/a&gt;, multi-tenant, modular, built entirely on Laravel. These are the decisions that turned out to be harder than expected, and what we'd do differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Multi-Tenancy Decision
&lt;/h2&gt;

&lt;p&gt;The first real decision was how to handle multi-tenancy. There are three common approaches in Laravel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate databases per tenant&lt;/strong&gt; — cleanest isolation, more overhead to manage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate schemas&lt;/strong&gt; (PostgreSQL) — good middle ground&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared database with tenant ID column&lt;/strong&gt; — most common, most dangerous if you get it wrong&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We started with option 3. Global query scopes, &lt;code&gt;tenant_id&lt;/code&gt; on every table, seemed manageable. Then we hit exactly the problem everyone warns you about.&lt;/p&gt;

&lt;p&gt;A missing scope bypass in one admin operation returned records across tenants. In a todo app that's embarrassing. In payroll software — where the data is salary figures and bank account numbers — that's a hard stop.&lt;/p&gt;

&lt;p&gt;We moved to separate databases per tenant. Each tenant gets their own database. The connection is resolved at authentication — not on every request. Once a user logs in, their tenant's database name is stored in the session, and every subsequent request in that session uses 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthenticatedSessionController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;LoginRequest&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;RedirectResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$user&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Store in session at login — resolved once, not per request&lt;/span&gt;
        &lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'tenant_db'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;database_name&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'database.connections.tenant.database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;database_name&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;purge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&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;regenerate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;intended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RouteServiceProvider&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HOME&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;No per-request resolution overhead. The connection is set when the session is established and stays for its lifetime.&lt;/p&gt;

&lt;p&gt;Cross-tenant leakage is now architecturally impossible — not a discipline problem, a structural one. The tradeoff is real: migrations run per tenant, not once. We handled that with a queued migration runner using &lt;code&gt;Artisan::call()&lt;/code&gt; across all tenant databases.&lt;/p&gt;

&lt;p&gt;The overhead was worth it. When you're handling payroll data, isolation has to be guaranteed, not assumed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Modular Architecture — The Part We Got Right
&lt;/h2&gt;

&lt;p&gt;HR software has modules nobody uses. If a 30-person team enables the recruitment pipeline, performance review engine, and training catalog from day one, they're paying for complexity they don't need.&lt;/p&gt;

&lt;p&gt;We made each module a Laravel service provider that registers its own routes, policies, and bindings:&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;PayrollServiceProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceProvider&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;register&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Module&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payroll'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Routes, policies, and resources never load&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PayrollEngine&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;StandardPayrollEngine&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Module&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payroll'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loadRoutesFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../routes/payroll.php'&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;loadPoliciesFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/../policies'&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;When a module is disabled, its routes don't exist. Its policies never register. Its queries never run. There's no conditional logic scattered through controllers — the module simply isn't there.&lt;/p&gt;

&lt;p&gt;This also made testing significantly cleaner. Each module's test suite runs in isolation with only its dependencies loaded.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Payroll Computation Problem
&lt;/h2&gt;

&lt;p&gt;This is where payroll software actually gets hard.&lt;/p&gt;

&lt;p&gt;A payroll run for 500 employees isn't one calculation — it's 500 independent calculations, each involving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Base salary for the period (handling mid-month joiners and leavers)&lt;/li&gt;
&lt;li&gt;Attendance data and approved overtime&lt;/li&gt;
&lt;li&gt;Leave taken (paid, unpaid, partially paid)&lt;/li&gt;
&lt;li&gt;Active deductions (loan EMIs, advances)&lt;/li&gt;
&lt;li&gt;Statutory amounts (National Insurance, Health Insurance, income tax withholding)&lt;/li&gt;
&lt;li&gt;Custom earning and deduction components configured per salary structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running this synchronously in a web request is not an option. On a slow connection, a 500-employee run would timeout in under two minutes.&lt;/p&gt;

&lt;p&gt;We moved the entire payroll computation to Laravel queues with Horizon managing the workers:&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;ProcessPayrollRun&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 hour max for large tenants&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;PayrollEngine&lt;/span&gt; &lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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;Employee&lt;/span&gt; &lt;span class="nv"&gt;$employee&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;ProcessEmployeePayslip&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$employee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each employee's payslip is computed as a separate job. If one fails (bad data, edge case in overtime calculation), the rest of the run continues. Failed jobs retry automatically and surface in a review queue for the payroll officer.&lt;/p&gt;

&lt;p&gt;The UI shows a live progress bar. The payroll officer can review anomalies — overtime spikes, partial-month joiners, mid-cycle resignations — before approving disbursement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Statutory Compliance Is Not a Report
&lt;/h2&gt;

&lt;p&gt;The mistake we almost made: treating statutory compliance as a reporting feature you add at the end.&lt;/p&gt;

&lt;p&gt;National Insurance contributions, Health Insurance employee and employer shares, income tax withholding — these are not numbers you calculate at year-end and hope they match payroll. They have to be embedded in every computation cycle, against the rates and wage ceilings in effect for that period.&lt;/p&gt;

&lt;p&gt;We modeled statutory rates as versioned configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/statutory/national_insurance.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'2025-04-01'&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;'employee_rate'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'employer_rate'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.138&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'upper_earnings_limit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;967&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// weekly&lt;/span&gt;
        &lt;span class="s1"&gt;'lower_earnings_limit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// next rate change will be added here&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When rates change, we add a new entry. Historical payroll runs use the rates that were in effect at the time — the computation is reproducible years later without the database knowing what today's rates are.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Audit Trail Problem
&lt;/h2&gt;

&lt;p&gt;HR data mutations need to be permanent and unalterable. An employee's salary change, a leave approval, a payroll disbursement — if any of these are later questioned, you need a record that cannot be edited.&lt;/p&gt;

&lt;p&gt;Standard Laravel model events write to a log table, but that table can be edited by anyone with database access.&lt;/p&gt;

&lt;p&gt;We solved this with append-only writes and a hash chain:&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;AuditEntry&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$timestamps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// No update() or delete() methods&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;booted&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;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Audit entries are immutable'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Audit entries are immutable'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry contains a hash of the previous entry. Tampering with any record breaks the chain — detectable without needing a separate integrity service.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with the permission model.&lt;/strong&gt; We designed features first and added access control later. Retrofitting field-level permissions (hiding salary from managers while showing headcount) onto an existing codebase is painful. Define your access control matrix before your first migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't build a notification system from scratch.&lt;/strong&gt; We spent weeks building an in-app notification system that was essentially a simplified version of Laravel Notifications with a custom UI. Laravel Notifications with a database channel and a thin frontend is enough for most things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version your API from day one.&lt;/strong&gt; We added versioning to the REST API after two external integrations were already in production. The migration was manageable but unnecessary.&lt;/p&gt;




&lt;p&gt;The product is live. If you're working on something similar or have questions about any of these decisions, the comments are open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build Laravel-based products at &lt;a href="https://syftnex.com" rel="noopener noreferrer"&gt;Syftnex&lt;/a&gt;. The HR &amp;amp; Payroll platform is available for demos if you want to see the architecture in action.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Building Native Apps for Small Businesses — You're Wasting Their Money</title>
      <dc:creator>Ahmad Khokhar</dc:creator>
      <pubDate>Sat, 30 May 2026 15:46:57 +0000</pubDate>
      <link>https://dev.to/ahmad_khokhar/stop-building-native-apps-for-small-businesses-youre-wasting-their-money-211d</link>
      <guid>https://dev.to/ahmad_khokhar/stop-building-native-apps-for-small-businesses-youre-wasting-their-money-211d</guid>
      <description>&lt;p&gt;I'm going to say something that will probably annoy a few mobile developers.&lt;/p&gt;

&lt;p&gt;Most small and mid-sized businesses do not need a native app. And the people convincing them they do are either uninformed or have a billing incentive to keep them confused.&lt;/p&gt;

&lt;p&gt;I've seen this play out too many times. A business owner gets excited about "having an app." They spend $30–80k on iOS and Android development. Six months later, their app has 200 downloads, a 2.1 star rating because nobody wanted to install it in the first place, and a maintenance bill they didn't budget for.&lt;/p&gt;

&lt;p&gt;This isn't a failure of execution. It's a failure of the right question not being asked upfront.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Question Nobody Asks
&lt;/h2&gt;

&lt;p&gt;Before any mobile discussion, someone should ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Why does this need to be native?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "what features do you need" — that comes later. First: &lt;strong&gt;why native specifically?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The honest answers that justify native are actually pretty narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need Bluetooth, NFC, or deep hardware access&lt;/li&gt;
&lt;li&gt;You're building a game with complex rendering&lt;/li&gt;
&lt;li&gt;You need background processing that runs independently of the browser&lt;/li&gt;
&lt;li&gt;Your users are offline 80% of the time with zero connectivity (not just spotty)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most SME use cases — field service tools, customer portals, internal dashboards, loyalty programs, booking systems — none of these apply.&lt;/p&gt;




&lt;h2&gt;
  
  
  What SMEs Actually Need From "An App"
&lt;/h2&gt;

&lt;p&gt;When you dig into what a business owner means when they say &lt;em&gt;"I want an app"&lt;/em&gt;, it usually comes down to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works on mobile, looks good, feels fast&lt;/li&gt;
&lt;li&gt;Users can access it without opening a browser every time&lt;/li&gt;
&lt;li&gt;Sends notifications when something happens&lt;/li&gt;
&lt;li&gt;Works even when internet is slow or drops&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. That's the whole list.&lt;/p&gt;

&lt;p&gt;Every single one of those is solvable without the App Store.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Costs Nobody Mentions
&lt;/h2&gt;

&lt;p&gt;Native development has costs that don't show up in the initial quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two codebases.&lt;/strong&gt; iOS and Android are different languages, different paradigms, different release cycles. You're either paying for two teams or accepting that one platform will always lag behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App Store dependency.&lt;/strong&gt; Apple can reject your update. A policy change can affect your app overnight. Your release schedule is now partially controlled by a third party.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update friction.&lt;/strong&gt; Every time you fix a bug or ship a feature, users have to update. Some won't. Now you're supporting multiple versions in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ongoing maintenance.&lt;/strong&gt; OS updates break things. Each new iOS or Android version requires testing and often code changes — whether you shipped new features or not.&lt;/p&gt;

&lt;p&gt;A web-based solution has none of these constraints. You deploy once. Everyone gets the update instantly. No review process. No two codebases.&lt;/p&gt;




&lt;h2&gt;
  
  
  "But PWAs Aren't Real Apps"
&lt;/h2&gt;

&lt;p&gt;This one comes up every time.&lt;/p&gt;

&lt;p&gt;In 2019, that was a fair criticism. In 2026, it's just not accurate anymore.&lt;/p&gt;

&lt;p&gt;PWAs can be installed on the home screen on both iOS and Android. They send push notifications. They work offline with proper caching. They load fast on slow connections. They pass through app stores if you actually need that distribution channel.&lt;/p&gt;

&lt;p&gt;The gap between a well-built PWA and a native app — for the majority of business use cases — is negligible to the end user.&lt;/p&gt;

&lt;p&gt;What is not negligible is the cost difference and the time to ship.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Actual Cases Where Native Makes Sense
&lt;/h2&gt;

&lt;p&gt;I want to be clear — I'm not saying native is always wrong. There are legitimate use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consumer apps where offline hardware access is central to the product (fitness trackers, AR tools, music production)&lt;/li&gt;
&lt;li&gt;Apps targeting platforms where PWA support is still limited&lt;/li&gt;
&lt;li&gt;High-frequency trading or real-time systems where milliseconds matter&lt;/li&gt;
&lt;li&gt;Products where App Store discoverability is a core acquisition channel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your use case is genuinely in this list, build native. It's the right call.&lt;/p&gt;

&lt;p&gt;But if you're a logistics company that needs field workers to log deliveries, or a clinic that needs patients to book appointments, or a retailer that wants a loyalty card on their customer's phone — you do not need to spend six figures on a native app.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;Developers like building native apps. The work is interesting, the billing is higher, and clients don't know enough to question it.&lt;/p&gt;

&lt;p&gt;That's not malicious — it's just the natural result of an information gap. When someone doesn't know the alternative exists and performs comparably, they default to what they've heard of.&lt;/p&gt;

&lt;p&gt;The problem is that nobody in the room has an incentive to close that gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Tell Any SME Owner
&lt;/h2&gt;

&lt;p&gt;Before you sign anything for app development, ask the agency or developer one question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Could this be built as a Progressive Web App, and if not, why not?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If they can't answer that clearly, or if they dismiss it without explanation, get a second opinion.&lt;/p&gt;

&lt;p&gt;You might end up saving half your budget and shipping three months earlier.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, I write about practical web development and the decisions behind real projects. Follow along if that's your thing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
