Skip to content
Irvine "Irvs" Clark B. EbajanIrvs
Back to WritingBackend

Designing a Statutory Payroll Deduction Engine for Philippine Compliance

A deep-dive into implementing SSS, PhilHealth, Pag-IBIG, and TRAIN law tax calculations as a config-driven service layer.

Why This Matters

Payroll is not a CRUD app. The hard part isn't storing employees or generating PDFs — it's getting the numbers right. In the Philippines, every payroll run must compute four separate statutory deductions, each with its own rule set, bracket table, rate changes over time, and edge cases.

If the math is wrong, employees don't get paid correctly and the company faces compliance penalties. This post walks through how I structured the deduction engine to be testable, auditable, and adaptable to rate changes without code deploys.

Architecture Overview

class PayrollService
{
  public function compute(Employee $e, $period): PayrollResult
  {
    $gross = $this->calculateGrossPay($e, $period);
    $sss   = $this->deductSSS($gross);
    $ph    = $this->deductPhilHealth($gross);
    $pi    = $this->deductPagIbig($gross);
    $tax   = $this->deductWithholdingTax($gross, $sss + $ph + $pi, $period);
    return new PayrollResult($gross, $sss, $ph, $pi, $tax);
  }
  // Each deduct* method is independently testable
}

Each deduction type is a separate method on PayrollService. The public compute() method orchestrates them. This structure means:

  • Each deduction can be unit-tested in isolation with known inputs and expected outputs
  • Adding a new deduction type (e.g., a company-specific loan deduction) doesn't touch existing logic
  • The HTTP layer (controller) stays thin — it calls compute() and returns the result

Deduction-by-Deduction Breakdown

SSSSocial Security System

The employee and employer each contribute a percentage of the monthly salary credit (MSC), which falls into one of ~60 brackets. The bracket determines the exact peso amount both parties pay.

Implementation

The contribution table is stored in a database table with columns: min_compensation, max_compensation, employee_share, employer_share, and effectivity_date. A lookup query finds the row where salary falls between min and max, ordered by effectivity_date DESC.

Edge Case

The MSC bracket lookup isn't a simple formula — it's a government-issued table that changes every few years. Storing it as data (not code) means updating rates is a database insert, not a deployment. The tradeoff is that every payroll run needs a query, but with caching that's negligible.

PhilHealthPhilippine Health Insurance Corporation

A fixed-rate premium based on salary brackets. As of the latest rate structure, the premium is a percentage of the monthly basic salary, capped at a maximum contribution.

Implementation

Same table-driven approach as SSS but with different bracket granularity. The service class applies the percentage rate to the basic pay, checks against the cap, and splits the premium between employee (50%) and employer (50%).

Edge Case

PhilHealth rates changed significantly in 2023–2024 (from 4% to 5%). The versioned table approach handled the transition seamlessly — existing payroll periods use the old rate, new ones use the current rate.

Pag-IBIGHome Development Mutual Fund

A simpler fixed-rate contribution. The employee pays a percentage of their basic salary, capped at a maximum monthly contribution. Employer matches the same amount.

Implementation

A single-row config instead of a bracket table: percentage rate, employee cap, employer cap, and effectivity_date. The engine checks if salary * rate exceeds the cap and applies whichever is lower.

Edge Case

Pag-IBIG has a quirky rule: if the employee's salary is below a threshold, the employer share is the same as the employee share (not double). The service class handles this as a conditional branch rather than separate config.

TRAIN LawTax Reform for Acceleration and Inclusion

The Philippine graduated income tax system. Taxable income falls into brackets with progressively higher rates. Unlike the fixed-amount deductions above, this is a multi-step calculation: determine taxable income (basic pay - mandatory deductions), apply bracket rate, subtract the bracket's base tax.

Implementation

A tax_brackets table with columns: min_income, max_income, base_tax, rate_over_min. The service computes: taxable_income = gross_pay - sss - philhealth - pagibig, then finds the bracket, then tax = base_tax + (taxable_income - min_income) * rate_over_min.

Edge Case

The "13th month pay" exclusion (first ₱90,000 is tax-exempt) means the engine needs to track cumulative non-taxable compensation across payroll periods. I implemented this as a year-to-date tracker that resets each calendar year.

Why Config-Driven Beats Hard-Coded

The alternative to DB-backed bracket tables is hard-coding lookup arrays in PHP. I chose the database approach for three reasons:

  • Rate changes don't require deploys. When SSS updates its contribution table (which happens every few years), an admin inserts new rows with a future effectivity_date. The engine automatically uses the correct table for the payroll period.
  • Audit trail by default. Every rate change is a timestamped row in the database. You can reconstruct what the deduction should have been on any given date — useful for retroactive adjustments.
  • Test coverage is simpler. Tests insert known bracket tables before running, so the test data is explicit in the test file, not hidden in a PHP array somewhere.

The tradeoff: each payroll run requires 4+ database lookups (one per deduction type). For 24 employees, this adds milliseconds. For thousands of employees, you'd want to cache the current rate tables in Redis — the engine is designed for that swap.

The 13th Month Pay Gotcha

Philippine law requires employers to pay "13th month pay" — an additional half-month's salary paid by December. Critically, the first ₱90,000 of 13th month pay and other bonuses is tax-exempt. This means the withholding tax engine must track year-to-date non-taxable compensation across pay periods.

My implementation adds a cumulative_non_taxablecolumn on the payroll_periods table. Each payroll run sums the prior periods' non-taxable amounts. Once the running total hits ₱90,000, any excess is included in taxable income. The tracker resets on January 1st of each year.

// Pseudocode for the 13th month cap
const TAX_EXEMPT_CAP = 90_000;
ytdNonTaxable = PayrollPeriod::ytdSum(employee, period);
if (ytdNonTaxable < TAX_EXEMPT_CAP) {
  exemptAmount = min(bonus, TAX_EXEMPT_CAP - ytdNonTaxable);
  taxableBonus = bonus - exemptAmount;
}

Testing Strategy

The deduction engine is the most testable part of the system because it's a pure function: salary in, deductions out. The test suite covers:

Each SSS bracket edge (lowest, highest, mid)

PhilHealth cap boundary (below and above max)

Pag-IBIG threshold rule for employer share

TRAIN bracket transitions (every bracket boundary)

13th month cap accumulation across periods

Zero salary / null deduction cases

Year-end rate version switching

Tests use SQLite in-memory with seeded bracket tables. The 80+ test suite runs in under 3 seconds in CI. Each deduction method has a corresponding test class that reads like a spec document — the test names describe the business rule being verified.

Key Takeaway

A payroll engine is a good example of a principle I try to follow: make the variable things variable. Government rate tables change. Tax brackets change. What shouldn't change is the calculation pipeline itself. By pushing the variable data into the database with versioning, the code stays stable and the compliance team (or future me) can update rates without touching a deployment pipeline.