The HR person called and said — "the PF report is fine for everyone, but one person's data isn't showing up."
No error. No crash. Just gone. Works for 23 people, silently breaks for one. Classic.
The System
I maintain a legacy ERP at work — a PHP CodeIgniter 3 app that's been running in production for years. It handles payroll, attendance, reports — the full thing. Most of the "new" work I do is in Spring Boot, but production is this. And honestly, working in a 2000-line legacy codebase has taught me more about software design than any clean tutorial project.
The PF/EPF salary report is one of the trickier ones. It pulls every employee for a month, calculates their Provident Fund contributions based on their salary template, and displays it in a printable table. Simple enough on paper.
The Bug
After going through the database first (records were all there, nothing missing), I finally just read the function carefully.
Deep inside the loop that builds the report, there was this:
foreach ($employees as $key => $emp) { // if PF is disabled on the salary template... if ($emp['pf_status'] == 0) { unset($employees[$key]); // ← just gone. no log. no flag. nothing. continue; } // ... PF calculation happens here $pf_amount = ($emp['basic'] * 12) / 100; $employees[$key]['pf_amount'] = $pf_amount; }
If an employee's salary template had PF turned off, they got silently removed from the output array. No log entry. No flag. No blank row. Just unset and continue.
But why did only one employee have PF disabled? It turned out the code for enabling PF had been commented out two years ago, with the system defaulting new templates to 1 (true). However, this specific employee joined five years ago. Because their legacy template predated that change, their status remained 0, causing them to be silently omitted while everyone else appeared normally.
The Fix
The immediate fix was small. Instead of removing the person, flag them and still include them in the output with a visual indicator:
if ($emp['pf_status'] == 0) { unset($employees[$key]); continue; }
if ($emp['pf_status'] == 0) { $employees[$key]['pf_excluded'] = true; $employees[$key]['pf_note'] = 'PF disabled'; continue; }
The report stays accurate, HR can see who's excluded and why. But the real problem was structural.
Strangler Fig
The PF calculation logic was sitting inside the controller — attendance math, percentage calculations, database reads, and even a save operation for manual overrides, all in the same function. And the whole thing was duplicated for two employee types.
This is where the Strangler Fig pattern clicked for me. The idea is simple: don't rewrite the old thing. Build the new thing next to it, make the old code delegate to it, and slowly let the new structure take over. The host tree doesn't fall all at once.
class PfReportService { /** * Calculate PF report for regular employees. * Returns every employee — excluded ones are flagged, not removed. */ public function calculateForRegular(array $employees, string $month): array { foreach ($employees as $key => $emp) { if ($emp['pf_status'] == 0) { $employees[$key]['pf_excluded'] = true; $employees[$key]['pf_note'] = 'PF disabled on template'; continue; } $employees[$key]['pf_amount'] = ($emp['basic'] * 12) / 100; } return $employees; } // Second employee type — method, not another copy-paste public function calculateForContract(array $employees, string $month): array { // same pattern, different rate logic } }
class SalaryController extends CI_Controller { public function pf_report() { $month = $this->input->get('month'); $employees = $this->employee_model->get_all($month); // old inline logic → now delegated to the service $data['employees'] = $this->PfReportService->calculateForRegular($employees, $month); // same URL. same view. nothing breaks in production. $this->load->view('report/pf_report', $data); } }
The old inline logic is still there for now. But it's in the process of being replaced, one piece at a time, without touching anything that's in production.
Why This Stuck With Me
I spend most of my personal project time in Spring Boot — that's what I'm building toward. But this bug taught me a few things that apply regardless of the stack.
I write about things I'm actually working on. Thanks for reading.