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.

Maintainers

Package info

github.com/arifur9993/attendance-engine-php

pkg:composer/arifur9993/attendance-engine

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-05-13 12:18 UTC

This package is auto-updated.

Last update: 2026-05-13 12:40:43 UTC


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+.

PHP Zero deps PHPStan License GitHub

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.

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.