PHP is unfashionable. ERP is unfashionable. Put them together and developers on Twitter will tell you to rewrite the whole thing in Rust. Meanwhile, I have shipped HRMS, payroll, finance, and inventory modules in object-oriented PHP that are used by more than 9,000 employees every working day. Here is why the boring choice keeps winning.
ERP requirements are not what tutorials assume
An ERP is not a CRUD app. It is fifteen CRUD apps that have to agree with each other on rounding, dates, taxes, employee IDs, and approval chains. Three properties of ERP work that change the architecture decision:
- Requirements never stop arriving. Tax rules change in March. Approval limits change in April. New leave types appear in May. The codebase must absorb these without surgery on unrelated modules.
- Code outlives the team. The person who wrote the payroll module two years ago has left. Whoever inherits it should not have to rebuild a mental model from scratch.
- Errors cost money directly. A bug in a marketing site loses one lead. A bug in payroll triggers a CFO phone call at 7 PM.
Object-oriented PHP, done with discipline, handles all three. Not because PHP is special, but because the patterns OOP encourages — boundaries, services, value objects, repositories — are exactly what ERP needs.
Module boundaries are the most important line of code you will write
Every ERP I have inherited that became a maintenance disaster had the same problem: modules touched each other directly. The HR module called the payroll table. The finance module read employee records straight from the database. After two years, nothing could be changed without breaking three other things.
The fix is a hard rule: modules only talk to other modules through their service layer. Never through the database, never through shared helpers, never via globals.
// hr/EmployeeService.php
class EmployeeService
{
public function __construct(private EmployeeRepository $employees) {}
public function findActive(int $id): ?Employee
{
$row = $this->employees->byId($id);
return $row?->isActive ? $row : null;
}
}
// payroll/PayrollService.php
class PayrollService
{
public function __construct(
private PayrollRepository $payroll,
private EmployeeService $hr, // ← cross-module via SERVICE, not repo
) {}
public function generatePayslip(int $employeeId, string $month): Payslip
{
$employee = $this->hr->findActive($employeeId);
if (!$employee) throw new \DomainException('Inactive employee');
// ...
}
}
Six months later when HR decides "active" means something more complicated, only EmployeeService::findActive changes. Payroll keeps working.
Service classes for the rules that matter
Business rules belong in service classes, not in controllers and not in models. A service class:
- Takes plain inputs (IDs, dates, amounts).
- Returns a domain object or throws a domain exception.
- Is testable without spinning up Laravel / Symfony.
class LeaveBalanceService
{
public function __construct(private LeaveRepository $repo) {}
public function balance(int $employeeId, string $leaveType): int
{
$entitled = $this->repo->entitlement($employeeId, $leaveType);
$taken = $this->repo->takenSoFar($employeeId, $leaveType);
return max(0, $entitled - $taken);
}
}
Twelve months later when leave entitlement starts to depend on years of service and a public-holiday quirk, the entire change happens here. Controllers, dashboards, and approval flows do not move.
Value objects: cheap insurance against silly bugs
Money, dates, employee IDs, and tax rates should not be raw float or int. They should be tiny classes that know their own rules.
final class Money
{
public function __construct(
public readonly int $minorUnits, // store paise / cents
public readonly string $currency = 'INR',
) {}
public function add(Money $other): Money
{
if ($other->currency !== $this->currency) {
throw new \DomainException('Currency mismatch');
}
return new Money($this->minorUnits + $other->minorUnits, $this->currency);
}
public function format(): string
{
return number_format($this->minorUnits / 100, 2) . ' ' . $this->currency;
}
}
This eliminates an entire class of bugs (currency mismatch, float rounding) at compile time. PHP type hints actually do work for this.
Repositories, not Eloquent everywhere
Eloquent and similar ActiveRecord ORMs are great for small apps. For ERP, I keep ActiveRecord at the edges and write thin repository classes for anything important.
class PayrollRepository
{
public function __construct(private PDO $db) {}
public function findForMonth(int $employeeId, string $month): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM payroll_runs
WHERE employee_id = :id AND month = :m
LIMIT 1'
);
$stmt->execute(['id' => $employeeId, 'm' => $month]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
}
Why? Because in production you will need to optimise a query, change an index, or add a CTE. Doing that inside an ActiveRecord chain is a nightmare. Doing it inside a repository method is a one-line change.
Transactions, audit, and the part that keeps you employed
Every business action that changes money or status must be wrapped in a transaction and append an audit event in the same transaction. If you forget, you eventually get a payroll-recalculation discrepancy at month-end with no idea how it happened.
$db->beginTransaction();
try {
$payslipId = $payroll->generate($employee, $month);
$audit->record('payslip.generated', [
'payslip_id' => $payslipId,
'employee_id' => $employee->id,
'month' => $month,
'actor' => $currentUserId,
]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
What I would not do again
- Cross-module joins in raw SQL. They feel fast at first and ruin you in year two.
- Static service classes. Easier in the moment, untestable forever.
- Letting controllers contain business logic. Controllers should be 8 lines: parse, call service, return.
The honest bit
This is not new advice. It is just discipline. The reason OOP PHP keeps winning for ERP is that it forces enough structure to survive the second year, while staying simple enough that a small team can hold the whole thing in their heads. If you are designing or rescuing an ERP and want a second pair of eyes, see the contact section on the homepage.
