Indian payroll and statutory calculations in plain PHP. No framework, no
dependencies beyond ext-json, and every figure comes back with the working and
the statute behind it.
composer require crmleaf/payroll-coreThis is the engine the CRMLeaf payroll tools are built on. Each tool is a thin package over one calculator here; this is where the arithmetic and the rate data live.
Indian payroll arithmetic is not difficult, but it is fiddly, and it is wrong in a lot of software:
- The pension share is capped at the wage ceiling even when provident fund is not, so 8.33% of a ₹30,000 basic is ₹1,250, not ₹2,499.
- State insurance contributions continue to the end of a contribution period after wages cross the limit, so applicability cannot be read off the wage.
- Gratuity rounds a part year up only past six months. Seven years and six months is seven; seven and seven is eight.
- The section 87A rebate has a marginal relief band. At ₹12,10,000 of taxable income the slabs give ₹61,500 and the payable figure is ₹10,000.
Each of those is a solved problem that gets re-solved badly in private. This solves them once, in the open, with the citation next to the number.
Important
This is a calculation library, not tax advice. It implements our reading of the applicable statutes and is provided without warranty. Verify against your own compliance obligations before relying on it for a filing.
use Crmleaf\Payroll\Calculators\GratuityCalculator;
use Crmleaf\Payroll\Money;
$result = (new GratuityCalculator())->calculate(
lastDrawnSalary: Money::fromRupees(45_000), // basic + DA
yearsOfService: 7,
monthsOfService: 8, // over six months, so eight years
);
$result->gratuity->format(); // '₹2,07,692.31'
$result->completedYears; // 8
$result->explain(); // '(15 × 45,000.00 × 8) ÷ 26 = ₹2,07,692.31'
$result->taxExempt->toRupees(); // 207692.31Every calculator is a final class, constructible with new and no arguments,
with a single calculate() taking named parameters.
foreach ($result->steps() as $step) {
echo $step->label, ': ', $step->formula, ' → ', $step->amount?->format(), "\n";
echo ' ', $step->citation, "\n";
}A payroll figure has to be defensible. An employee asking why ₹1,250 went to the pension fund rather than ₹2,499 is entitled to the reasoning, and so is an auditor, so the reasoning is part of the return value rather than something you reconstruct afterwards.
toArray() gives the same thing as snake_case scalars, which is what the JSON
API and the Blade components consume.
| Provident fund | Employee, employer, pension and insurance shares with the wage ceiling |
| State insurance | Both shares, the wage limit, and the contribution-period rule |
| Income tax | Both regimes, slabs, 87A with marginal relief, surcharge, cess |
| Tax deducted at source | Monthly instalments, mid-year joiners, regime comparison |
| Professional tax | Every state that levies it, at its own frequency |
| Gratuity | Covered and uncovered establishments, six-month rounding, exemption |
| Bonus | Eligibility limit and calculation ceiling, kept distinct |
| Leave encashment | The section 10(10AA) least-of-four test |
| Cost to company | A full structure, with the components reconciling to the total |
| Full and final settlement | Dues, gratuity, leave, notice recovery, deductions |
| Provident fund penalties | Section 7Q interest and 14B damages, on both bases |
| Compliance calendar | Statutory due dates, as an iCalendar feed |
| Salary templates | Structure, register, slip and master, as column schemas |
Two more answer a commercial question rather than a statutory one, so no rate table governs them and neither takes a date:
| Return on investment | A payroll cycle run by hand against the same cycle automated |
| Savings | Two per-employee costs compared across the length of a contract |
Money::fromRupees(15_000)->percentage(8.33)->paise; // 124950, exactly
Money::fromRupees(1_234_567.89)->format(); // '₹12,34,567.89'Payroll divides by 26 for gratuity, takes 8.33% for the pension share, and spreads an annual tax figure over twelve months. In floats that accumulates error which eventually surfaces as a one-rupee mismatch on a challan, so every amount is an integer number of paise and every division rounds explicitly.
The statutes disagree about rounding, so there are three named methods rather
than one: roundUpToRupee() for state insurance, roundToRupee() for tax
deducted at source, and roundToTenRupees() for section 288B. Each is applied
where the law says so.
Nothing here is "the current rate". Everything is "the rate on this date", because payroll routinely recomputes the past: a revised settlement six months after separation, an arrear paid in a later year, an audit of last year's challans.
(new PfCalculator())->calculate(
basicSalary: Money::fromRupees(30_000),
asOf: '2013-06-01',
)->wageCeiling->toRupees(); // 6500 - the ceiling before it was raised in 2014Ten tables live in resources/rates/, each version carrying its own
effective_from, effective_to and a cited source. Changing a rate means
adding a dated version, never editing a past one.
asOf accepts a DateTimeImmutable, a YYYY-MM-DD string, or a financial-year
label such as '2025-26'.
A Finance Act arrives after the financial year it governs has begun, so for part of every year the newest income tax table names the previous year. Payroll cannot stop and wait for a gazette, so the table is carried forward - and the result says so rather than letting you assume otherwise:
$result = (new IncomeTaxCalculator())->calculate(
grossSalary: Money::fromRupees(12_00_000),
);
$result->notes;
// ['Rates for FY 2025-26 are being applied to a date in FY 2026-27, because no
// version has been published for FY 2026-27 yet. Verify against the current
// Finance Act before relying on this figure for filing.']The same warning is reachable directly with RateTable::isProvisionalFor() and
provisionalWarning(). Only the income tax table is dated by financial year;
the others change by notification and carry no such assumption.
Three years of service is a valid answer of nil gratuity, with a reason. A negative salary is a bug in the caller and throws.
$result = (new GratuityCalculator())->calculate(
lastDrawnSalary: Money::fromRupees(45_000),
yearsOfService: 3,
);
$result->eligible; // false
$result->gratuity->isZero(); // true
$result->ineligibilityReason; // 'Gratuity needs 5 years of continuous service. …'crmleaf/laravel-payroll- service provider, publishable config, an opt-in JSON API, Blade components and PDF documents.@crmleaf/payroll-js- the same calculations in TypeScript, reading the same rate tables, agreeing to the paisa. A CI job fails the build if the two ever drift.- One package per tool -
crmleaf/gratuity-calculatorand the rest, if you want a single calculator with its own routes and views.
Issues and pull requests both belong here. See CONTRIBUTING.md for how a rate change is dated and cited.
A wrong figure is the most valuable kind of report: give the inputs, the figure you got, the figure you expected, and the rule that says so. The first three make it reproducible; the fourth makes it a test case.
MIT © CRMLeaf.