DEV Community

Ahmad Khokhar for Syftnex

Posted on

What We Learned Building a Multi-Tenant Payroll Engine in Laravel

Payroll software looks simple from the outside. You multiply hours by rate, subtract taxes, and send a number to a bank. That's it.

Then you actually build one.

We've been building an HR and payroll platform for a while now — 14 integrated modules, 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.


The Multi-Tenancy Decision

The first real decision was how to handle multi-tenancy. There are three common approaches in Laravel:

  1. Separate databases per tenant — cleanest isolation, more overhead to manage
  2. Separate schemas (PostgreSQL) — good middle ground
  3. Shared database with tenant ID column — most common, most dangerous if you get it wrong

We started with option 3. Global query scopes, tenant_id on every table, seemed manageable. Then we hit exactly the problem everyone warns you about.

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.

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:

class AuthenticatedSessionController extends Controller
{
    public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();

        $user   = $request->user();
        $tenant = $user->tenant;

        // Store in session at login — resolved once, not per request
        session(['tenant_db' => $tenant->database_name]);

        config(['database.connections.tenant.database' => $tenant->database_name]);
        DB::purge('tenant');

        $request->session()->regenerate();

        return redirect()->intended(RouteServiceProvider::HOME);
    }
}
Enter fullscreen mode Exit fullscreen mode

No per-request resolution overhead. The connection is set when the session is established and stays for its lifetime.

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 Artisan::call() across all tenant databases.

The overhead was worth it. When you're handling payroll data, isolation has to be guaranteed, not assumed.


Modular Architecture — The Part We Got Right

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.

We made each module a Laravel service provider that registers its own routes, policies, and bindings:

class PayrollServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        if (!Module::isEnabled('payroll')) {
            return; // Routes, policies, and resources never load
        }

        $this->app->bind(PayrollEngine::class, StandardPayrollEngine::class);
    }

    public function boot(): void
    {
        if (!Module::isEnabled('payroll')) {
            return;
        }

        $this->loadRoutesFrom(__DIR__.'/../routes/payroll.php');
        $this->loadPoliciesFrom(__DIR__.'/../policies');
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

This also made testing significantly cleaner. Each module's test suite runs in isolation with only its dependencies loaded.


The Payroll Computation Problem

This is where payroll software actually gets hard.

A payroll run for 500 employees isn't one calculation — it's 500 independent calculations, each involving:

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

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.

We moved the entire payroll computation to Laravel queues with Horizon managing the workers:

class ProcessPayrollRun implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 3600; // 1 hour max for large tenants

    public function handle(PayrollEngine $engine): void
    {
        $this->run->employees->each(function (Employee $employee) use ($engine) {
            ProcessEmployeePayslip::dispatch($employee, $this->run);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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


Statutory Compliance Is Not a Report

The mistake we almost made: treating statutory compliance as a reporting feature you add at the end.

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.

We modeled statutory rates as versioned configuration:

// config/statutory/national_insurance.php
return [
    '2025-04-01' => [
        'employee_rate'     => 0.12,
        'employer_rate'     => 0.138,
        'upper_earnings_limit' => 967, // weekly
        'lower_earnings_limit' => 123,
    ],
    // next rate change will be added here
];
Enter fullscreen mode Exit fullscreen mode

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.


The Audit Trail Problem

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.

Standard Laravel model events write to a log table, but that table can be edited by anyone with database access.

We solved this with append-only writes and a hash chain:

class AuditEntry extends Model
{
    public $timestamps = false;

    // No update() or delete() methods
    protected static function booted(): void
    {
        static::updating(fn() => throw new \Exception('Audit entries are immutable'));
        static::deleting(fn() => throw new \Exception('Audit entries are immutable'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Each entry contains a hash of the previous entry. Tampering with any record breaks the chain — detectable without needing a separate integrity service.


What We'd Do Differently

Start with the permission model. 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.

Don't build a notification system from scratch. 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.

Version your API from day one. We added versioning to the REST API after two external integrations were already in production. The migration was manageable but unnecessary.


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


We build Laravel-based products at Syftnex. The HR & Payroll platform is available for demos if you want to see the architecture in action.

Top comments (0)