arifur9993 / attendance-engine
The attendance brain for PHP apps. Pure-function resolver for shifts, breaks, overtime, overnight, rosters & compliance. Zero deps. PHP 8.2+. PHP twin of @attendance-engine/core.
Requires
- php: ^8.2
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^2.34
- phpstan/phpstan: ^1.11
README
⏱️ attendance-engine
The attendance brain your PHP app has been missing.
Punches in → answers out. Shifts, breaks, overtime, overnight, rosters, compliance. Pure functions. Zero runtime dependencies. PHP 8.2+.
Why this exists
"Was this person late?" sounds easy. It isn't.
Overnight shifts that cross midnight. Missing clock-outs. Duplicate biometric reads landing 12 seconds apart. Grace windows that vary by team. Rotating rosters. Unpaid lunch breaks that may or may not be taken. Daylight-saving boundaries. Time-zone drift between the device, the server, and the database.
Every HR/payroll/attendance product in the world re-solves these same dozen edge cases — usually buried in a 4,000-line Laravel controller that nobody dares touch.
attendance-engine is that logic, lifted out, tested to death, and shipped as a pure-function library.
It's the PHP twin of @attendance-engine/core (TypeScript). Same contract, same fixtures, same to-the-minute answers — so your Node frontend and your PHP backend never disagree on whether Rahim was 3 minutes late on Tuesday.
✨ What you get
| ⚡ One function, one answer | Engine::resolveDay() takes raw punches, returns a typed DayResult. No globals, no state, no surprises. |
| 🌙 Overnight-safe | Shifts that cross midnight, punches that bucket onto the right duty-date. We've thought about this so you don't have to. |
| 🧮 Overtime, breaks, lateness | All the math: late-after-grace, unpaid-break deduction, early-out, OT (shift-based / fixed-hours / daily-cap), rounding units. |
| 📅 Ranges + summaries | Engine::resolveRange() for a whole month, Engine::summarize() for the payroll card. |
| 🗓️ Rosters built-in | Engine::generateRoster() expands a weekly pattern into a date-keyed shift map. |
| 🌐 Timezone-safe | The engine never touches date_default_timezone_set(). Every timestamp carries its own offset. No DST drift. |
| 🪶 Zero deps | Pure PHP. No Carbon, no Symfony, no Composer plugins. Drop into Laravel, Symfony, Slim, or vanilla. |
| 🧪 Tested | PHPStan level 8 + Pest. CI matrix on 8.2 / 8.3 / 8.4. |
📦 Install
composer require arifur9993/attendance-engine
That's it. The package is namespaced under Arifur9993\AttendanceEngine\ and ships with PSR-4 autoloading.
⚡ 60-second quick start
use Arifur9993\AttendanceEngine\Engine; use Arifur9993\AttendanceEngine\Types\Punch; use Arifur9993\AttendanceEngine\Types\ShiftConfig; use Arifur9993\AttendanceEngine\Types\ResolveDayInput; $result = Engine::resolveDay(new ResolveDayInput( date: '2026-06-01', punches: [ new Punch(at: '2026-06-01T08:57:00+06:00'), new Punch(at: '2026-06-01T18:04:00+06:00'), ], shift: new ShiftConfig(start: '09:00', end: '18:00', graceIn: 10), )); echo $result->status; // 'present' echo $result->workedMinutes; // 547 echo $result->lateByMinutes; // 0 echo $result->otMinutes; // 4 // JSON-ready for your API return response()->json($result); // implements JsonSerializable
🧠 Real-world recipes
1. A whole month, one call
use Arifur9993\AttendanceEngine\Engine; use Arifur9993\AttendanceEngine\Types\{ResolveRangeInput, ShiftConfig, AttendancePolicy}; $nineToSix = fn (string $date): ?ShiftConfig => in_array(date('w', strtotime($date)), [0, 6], true) ? null // weekend off : new ShiftConfig(start: '09:00', end: '18:00', graceIn: 10); $range = Engine::resolveRange(new ResolveRangeInput( from: '2026-06-01', to: '2026-06-30', punches: $monthOfPunches, // flat list — auto-bucketed onto duty-dates shiftFor: $nineToSix, policy: new AttendancePolicy(tzOffsetMinutes: 360), holidays: ['2026-06-15' => true], )); $summary = Engine::summarize($range); echo $summary->workedHours(); // 162.5 echo $summary->daysLate; // 2 echo $summary->daysAbsent; // 1
2. Rotating roster from a weekly pattern
use Arifur9993\AttendanceEngine\Engine; use Arifur9993\AttendanceEngine\Types\ShiftConfig; $day = new ShiftConfig(start: '09:00', end: '18:00'); $night = new ShiftConfig(start: '22:00', end: '06:00'); $roster = Engine::generateRoster( from: '2026-06-01', to: '2026-06-07', pattern: [ 0 => null, // Sun off 1 => $day, 2 => $day, 3 => $night, 4 => $night, 5 => $day, 6 => null, // Sat off ], ); // Now pass it through: $shiftFor = fn (string $d) => $roster[$d] ?? null;
3. Round noisy biometric punches before resolving
use Arifur9993\AttendanceEngine\Engine; $clean = Engine::applyRounding($rawPunches, unitMinutes: 5, strategy: 'nearest'); // 08:57:23 → 08:55:00, 18:04:11 → 18:05:00 $result = Engine::resolveDay(/* ... */ punches: $clean);
4. Catch a missing-break compliance issue
$violations = Engine::evaluateBreakCompliance($day, [ 'minBreakMinutes' => 30, 'afterWorkedMinutes' => 300, // 5 hours ]); if ($violations) { Log::warning('Break compliance', ['employee' => $id, 'issues' => $violations]); }
5. Drop into Laravel — no provider needed
// app/Services/AttendanceService.php use Arifur9993\AttendanceEngine\Engine; class AttendanceService { public function dayFor(Employee $e, string $date): DayResult { return Engine::resolveDay(new ResolveDayInput( date: $date, punches: $e->punches()->forDate($date)->toEngineList(), shift: $e->shiftFor($date)->toEngineConfig(), policy: app(AttendancePolicy::class), )); } }
The engine is 100% static & pure — no DI binding required. It plays the same with Symfony, Slim, Mezzio, or plain PHP.
📚 API at a glance
| Function | What it does |
|---|---|
Engine::resolveDay(ResolveDayInput) → DayResult |
One day, in/out punches → status + minutes. |
Engine::resolveRange(ResolveRangeInput) → RangeResult |
A whole [from, to] window resolved at once, with smart punch bucketing. |
Engine::summarize(RangeResult) → RangeSummary |
Aggregate totals: worked / OT / late / absent / status counts. |
Engine::applyRounding(array, unit, strategy) → Punch[] |
Round punches to nearest / up / down by N minutes. |
Engine::evaluateBreakCompliance(DayResult, rule) → string[] |
Returns a list of compliance violations (empty = clean). |
Engine::generateRoster(from, to, weeklyPattern) → array |
Expand a weekly 0..6 pattern into a date → shift map. |
All value objects implement JsonSerializable and ->toArray(), so they round-trip cleanly through HTTP responses, queues, and caches.
⏰ Timezone discipline (read this)
ISO-8601 timestamps must carry an explicit offset:
✅ 2026-06-01T08:57:00+06:00
✅ 2026-06-01T08:57:00Z
✅ 2026-06-01T08:57:00-05:00
❌ 2026-06-01T08:57:00 ← throws TimeParseError
❌ 2026-06-01 08:57 ← throws TimeParseError
The engine never calls date_default_timezone_get(). Every calculation is anchored on the offset you provide. This is on purpose — it's the only way to stop "but it worked on staging" timezone bugs from sneaking into payroll.
🆚 Same answers as the TypeScript engine
This is the PHP twin of @attendance-engine/core. The two ports share fixtures and contracts. If your Next.js frontend and your Laravel backend both compute workedMinutes for the same punches, you get the same integer, to the minute.
| Feature | TypeScript (@attendance-engine/core) |
PHP (this package) |
|---|---|---|
resolveDay |
✅ | ✅ |
resolveRange |
✅ | ✅ |
summarize |
✅ | ✅ |
generateRoster |
✅ | ✅ |
applyRounding |
✅ | ✅ |
evaluateBreakCompliance |
✅ | ✅ (generic) |
| Jurisdiction packs (CA, EU) | 🔜 | 🔜 |
| Cross-port fixture parity | partial | partial |
💼 Who's using it
attendance-engine is the calculation core behind production HRMS / payroll setups handling:
- Mixed day/night/rotational rosters across Bangladesh, India, UAE, and SE Asia.
- Multi-tenant SaaS deployments where each tenant brings its own grace window + OT policy.
- Compliance-sensitive sectors (manufacturing, healthcare, retail) where "approximately worked" isn't good enough.
If you ship it in production, open an issue — I'll add your logo here.
🤝 Contributing
PRs welcome. The golden rule: any behavior change comes with a test that locks it in.
- Setup, style, and pull-request checklist →
CONTRIBUTING.md - Releasing / Packagist sync →
PUBLISHING.md - Changelog →
CHANGELOG.md
git clone https://github.com/arifur9993/attendance-engine-php.git cd attendance-engine-php composer install composer test # Pest composer analyse # PHPStan level 8 composer format # Pint
📦 Compatibility
| PHP | 8.2 · 8.3 · 8.4 |
| Frameworks | Laravel 10/11/12, Symfony 6/7, Slim 4, vanilla PHP |
| Dependencies | none — pure PHP, stdlib only |
👤 Author
Built by Md. Arifur Rahman (@arifur9993) — same author as the TypeScript engine. Comments, bug reports, feature ideas: open an issue or ping me on GitHub.
If this package saved you a week of "but the punches are in two timezones" debugging, ⭐ star the repo — it's the only marketing this library will ever do.
📜 License
MIT — see LICENSE. Use it commercially, fork it, vendor it, ship it.