easybdit / laraveleasyattendance
Drop-in attendance, HR, and payroll core for any Laravel app: check-in/out, corrections, ZKTeco device sync (pull + ADMS push), shifts, leave, holidays, overtime, salary generation, and reports.
Fund package maintenance!
Requires
- php: ^8.2
- ext-sockets: *
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- filament/filament: ^5.8
- larastan/larastan: ^3.11
- laravel/pint: ^1.20
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
- filament/filament: Required to use the optional Filament admin panel resources (Easybdit\LaravelEasyAttendance\Filament\EasyAttendancePlugin) — see the README's Filament Admin Panel section.
Provides
None
Conflicts
None
Replaces
None
README
A complete Laravel attendance management package for employee check-in and check-out, biometric attendance, ZKTeco device synchronization, shifts, leave management, holidays, overtime, attendance reports, salary generation, and payroll workflows. Built for modern Laravel applications that need a flexible employee attendance and HR solution.
Built for Laravel 11, Laravel 12, and Laravel 13 with PHP 8.2+, Laravel Easy Attendance provides a flexible attendance system that can work with your existing User model or its built-in Employee model.
It supports both ZKTeco Pull mode and Push/ADMS mode, making it suitable for offices, schools, factories, corporate HR systems, ERP applications, and other employee attendance environments.
Extracted and redesigned from a production HR system's attendance module, and validated end-to-end against real ZKTeco hardware (see Tested against real devices).
Why Laravel Easy Attendance?
Laravel Easy Attendance is designed as a reusable attendance and HR package for Laravel applications. It covers the complete flow from raw check-in/check-out punches to daily attendance summaries, leave, overtime, special working days, and salary generation.
Key features
- Employee check-in and check-out tracking
- Laravel attendance management with reusable Eloquent models and traits
- Biometric attendance integration with ZKTeco devices
- ZKTeco Pull mode over IP
- ZKTeco Push / ADMS synchronization
- Attendance correction requests and approval workflow
- Shift and employee schedule management
- Leave and holiday management
- Daily attendance summaries: present, late, absent, leave, holiday, and day off
- Overtime detection, approval, and salary calculation
- Special working-day pay
- Salary slip generation from attendance data
- Attendance and salary reports
- Laravel events for attendance, correction, leave, overtime, and device-sync workflows
- Configurable feature flags so you can enable only the modules you need
- Polymorphic attendance subjects for
User,Employee,Staff, or another model - CSV export for every report, CSV bulk-import for employees, printable payslip/attendance-sheet views, and ready-made notification content for the key events — all zero external dependencies
- Leave balance tracking (allowed/used/remaining per leave type per year), Department & Designation models, and full localization (English shipped, publishable for any other language)
- Optional Filament v5 admin panel — ~14 resources covering every module, feature-flag-gated, with one-click approve/reject on Leave/Overtime/Corrections
Use cases
Laravel Easy Attendance can be used for:
- Employee attendance systems
- HR and payroll applications
- School and college staff attendance
- Office attendance management
- Factory and industrial workforce tracking
- Corporate ERP systems
- Biometric attendance systems
- ZKTeco attendance integrations
- Multi-purpose Laravel business applications
Requirements
- PHP 8.2 or higher
- Laravel 11, 12, or 13
- MySQL, MariaDB, SQLite, or another supported Laravel database driver
Contents
- Why Laravel Easy Attendance?
- Install
- Quick start
- Core concept: the subject model
- How a punch is resolved
- Corrections
- Events
- ZKTeco Biometric Attendance & Device Sync
- HR & Payroll Core: Shifts, Leave, Holidays, Attendance Summaries, Overtime & Salary
- Exports, bulk import, notifications & print views
- Filament Admin Panel
- Table names
- Configuration reference
- Routes reference
- Tested against real devices
- Zero external dependencies
- Testing
- Building a UI
- Roadmap
- Frequently Asked Questions
- Keywords
Install
composer require easybdit/laraveleasyattendance php artisan attendance:install
attendance:install publishes config/attendance.php and offers to run migrations. Everything works with zero config after that — the subject model defaults to your app's auth user model.
Quick start
Add the trait to whichever model represents "who checks in" (defaults to your auth user model):
use Easybdit\LaravelEasyAttendance\Traits\HasAttendance; class User extends Authenticatable { use HasAttendance; }
$user->checkIn(); $user->checkOut(); $user->attendanceOn('2026-09-07'); // ['first_in' => Carbon, 'last_out' => Carbon] $user->attendances; // MorphMany<Attendance>
Or via the built-in routes (auth-protected, prefixed /attendance):
POST /attendance/check-in
POST /attendance/check-out
GET /attendance/today
Core concept: the subject model
Every other attendance package on Packagist hardcodes employee_id. This one doesn't — attendances.subject_id + subject_type is a polymorphic relation (morphs('subject')), so a punch can belong to User, Employee, Staff, or anything else you point it at:
// .env ATTENDANCE_SUBJECT_MODEL=App\Models\Employee
Add HasAttendance to that model instead of User. Nothing else in the package needs to change — controllers, events, and device sync all resolve the subject model from config, never a hardcoded class.
How a punch is resolved
attendances is a punch log, not a one-row-per-day table — every check-in and check-out is its own row (type: check_in/check_out, source: manual/device/correction). A device logs every raw punch of the day (in, out, in, out, ...), not a single clean pair, and a manual correction shouldn't just sit beside a bad device punch — it needs to actually win.
Attendance::resolveDayWindow($punches) (used by attendanceOn() and available for your own reports) handles this: within a day's punches, manual entries take priority over device/api entries in each direction — first-in is the earliest manual check-in if one exists, otherwise the earliest check-in of any source; last-out mirrors that for check-out. This is what makes an approved correction actually override a stray device punch instead of just adding a second, conflicting record.
Corrections
$correction = $user->requestAttendanceCorrection([ 'date' => '2026-09-07', 'requested_in' => '09:05', 'requested_out' => '18:10', 'reason' => 'forgot to punch', ]); $correction->approve($reviewerId, 'looks fine'); // or ->reject(...)
Approving doesn't just flip a status flag — it creates real check_in/check_out punches (source: 'correction') from the requested times, so the correction flows through the exact same resolveDayWindow() logic as any other punch instead of living as a separate "note" your reports have to special-case.
Corrections are optional (config('attendance.features.corrections')) and routed at /attendance/corrections; approve/reject sit behind a separate review_middleware you can point at your own admin gate.
Events
| Event | Fired when |
|---|---|
AttendanceRecorded |
Any punch is created — manual, device, or correction-approved |
AttendanceCorrectionRequested |
A subject submits a correction |
AttendanceCorrectionReviewed |
A correction is approved or rejected ($correction->status tells which) |
AttendanceDeviceSyncFailed |
A device fails to sync, escalating (Nth failure, then every Mth after — see config) |
LeaveRequested |
An employee submits a leave request |
LeaveReviewed |
A leave request is approved or rejected ($leave->status tells which) |
OvertimeReviewed |
An overtime record is approved or rejected — only approved reaches a salary slip |
AttendanceMarkedLate |
A day's summary (re)builds as late and wasn't already — a rebuild of an already-late day doesn't re-fire this |
The package has no opinion on notifications — listen for these and send however your app already does.
ZKTeco Biometric Attendance & Device Sync
The one rule that matters for both modes below: nothing shows up in attendances until a sync actually runs. Adding a device just registers it — it does not fetch anything by itself. Pull mode fetches only when you call pull (or the scheduled command runs); push mode only stores data once the physical device actually calls your server. Always: connect/register → sync → then query the data — never the other way round.
0. Turn the feature on (once)
ATTENDANCE_FEATURE_DEVICE_SYNC=true
php artisan migrate
This adds the attendance_devices table and device_id/device_user_id columns on attendances (unique-constrained, so the same physical punch can never be imported twice even if you sync it twice).
Your subject model (User, Employee, ...) also needs a PIN column — this is what matches an incoming punch to a person. Add it yourself, e.g.:
Schema::table('users', function (Blueprint $table) { $table->string('device_user_id')->nullable()->unique(); });
Then set each person's device PIN ($user->device_user_id = '1001'). A punch whose PIN matches nobody is skipped and reported back, not silently dropped — you'll see it in the pull response's unmatched list, or in the log on a push.
Mode A — Pull (server connects out to the device)
Use this when your server can reach the device's IP directly (same network / VPN). No extra composer package needed — the ZK protocol client ships built into this package (see Zero external dependencies); just ext-sockets, which PHP almost always has enabled already.
- Register the device:
$device = AttendanceDevice::create([ 'name' => 'Main Gate', 'ip' => '192.168.1.50', 'port' => 4370, 'status' => 'active', ]);
orPOST /attendance/deviceswith the same fields. Nothing is fetched yet at this point. - (optional) Test the connection first, before pulling any data — confirms the device is reachable without importing anything:
POST /attendance/devices/{id}/test - Pull — this is the step that actually fetches and stores the data:
or on a schedule so backlogs stay small:POST /attendance/devices/{id}/pull// routes/console.php Schedule::command('attendance:sync-devices')->everyFiveMinutes();
The response tells you exactly what happened:{"success": true, "message": "134 logs fetched · 12 new · 2 PIN(s) not matched...", "imported": 12, "unmatched": [...]}. - Now query the data (see Viewing synced data below) — before this step there is nothing to see for this device.
Mode B — Push / ADMS (the device connects to you)
Use this when your server can't reach the device directly (remote site, no static IP, no VPN) — the device dials home to you instead. No extra composer package needed.
- Register the device with its serial number (found on the device itself / its admin menu):
AttendanceDevice::create(['name' => 'Branch Office', 'serial_number' => 'ABCD1234', 'status' => 'active']);
- Point the device at your server: on the device, Menu → Comm → Cloud Server Setting → Server Mode
ADMS, Server Address = your app's domain, Enable = on. - Wait for the device to call in. It hits these fixed paths itself, on its own schedule (typically every 30s–a few minutes) — nothing to trigger from your side:
GET|POST /iclock/cdata (handshake, then the actual punch data) GET /iclock/getrequest (heartbeat / command poll) - Check
is_online/last_seen_aton the device to confirm it has connected — that tells you the connection is live, before you check for data:$device->fresh()->is_online; // true once it's called in within the last 90s $device->fresh()->last_synced_at; // set the first time it actually sends punch data
- Now query the data — populated automatically as the device pushes, no action needed on your end once step 2 is configured correctly.
Security note: a device firmware can't carry anything beyond its serial number, so that's the main gate on these routes by protocol necessity — they're public and unauthenticated. Three mitigations ship on top of that: throttle middleware on all four routes (config('attendance.device_sync.adms_throttle'), default throttle:60,1), a cap on how many ATTLOG lines one push batch can contain (adms_max_lines_per_push, default 5000 — the rest is logged and dropped, not queued), and an optional IP check (adms_verify_ip, off by default since many push devices sit behind NAT/a dynamic IP) that rejects a push whose source IP doesn't match the device's registered ip column when one is set.
Viewing synced data
Regardless of which mode filled it in, synced punches are ordinary Attendance rows (source: 'device') on the matched subject — query them the same way as manual punches:
$user->attendances()->where('source', 'device')->get(); // raw punch log $user->attendanceOn('2026-09-07'); // resolved first-in/last-out for a day $device->attendances()->latest('time')->first(); // most recent punch from a specific device
Both sync modes funnel through one shared AttendanceDeviceSyncService::ingestLogs(), so a punch is handled identically no matter which direction it arrived from — same matching, same dedup, same AttendanceRecorded event.
HR & Payroll Core: Shifts, Leave, Holidays, Attendance Summaries, Overtime & Salary
Everything above works against any subject model with just a punch log. This layer is different — it's built around the package's own Employee model (salary, allowances, a device PIN) because computing "present vs. late vs. absent" and generating a payslip genuinely needs real employee data, not an arbitrary model. Off by default; turn the whole stack on with one var:
ATTENDANCE_FEATURE_HR_CORE=true
php artisan migrate
(Each piece — employees, shifts, holidays, leave, summaries, salary, overtime, special_working_days — is also an individually toggleable ATTENDANCE_FEATURE_* flag, in case you only want some of them.)
Every class below is under Easybdit\LaravelEasyAttendance\:
use Easybdit\LaravelEasyAttendance\Models\{Employee, Shift, EmployeeShift, Holiday, LeaveType, Leave, OvertimeRecord, SpecialWorkingDay}; use Easybdit\LaravelEasyAttendance\Services\{AttendanceSummaryService, SalaryService};
The pieces, in the order you'll normally set them up:
-
Employee — the subject everything else attaches to.
$employee = Employee::create([ 'employee_code' => 'E-100', 'name' => 'Nusrat Jahan', 'device_user_id' => '9001', // matches device sync's pin_column 'basic_salary' => 30000, 'allowances' => ['house_rent' => 5000, 'medical' => 1000], 'status' => 'active', ]);
Employeeitself usesHasAttendance, so$employee->checkIn(),->checkOut(), device sync — everything from the sections above — works on it directly. Also manageable over HTTP:GET/POST /attendance/employees,GET/PUT/DELETE /attendance/employees/{id}— the index is paginated and supports?search=&status=&department_id=&per_page=(see Routes reference).Department & Designation (optional,
ATTENDANCE_FEATURE_DEPARTMENTS) — proper models instead of the plaindesignationstring column above (which still works fine on its own if that's all you need):$dept = Department::create(['name' => 'Engineering']); $role = Designation::create(['name' => 'Software Engineer', 'department_id' => $dept->id]); // department_id is optional — a designation can be org-wide $employee->update(['department_id' => $dept->id, 'designation_id' => $role->id]); $employee->department; // belongsTo Department $employee->designationRecord; // belongsTo Designation — named to not collide with the plain `designation` string attribute
Deleting a
Departmentnulls out (never blocks) anyDesignation/Employeepointing at it. CRUD over HTTP:GET/POST/PUT/DELETE /attendance/departments...,.../designations.... -
Shift — working hours + late grace + off days.
$shift = Shift::create(['name' => 'General', 'start_time' => '09:00', 'end_time' => '17:00', 'late_grace_minutes' => 10, 'off_days' => ['Friday']]);
Or
GET/POST /attendance/shifts,PUT/DELETE /attendance/shifts/{id}. -
Schedule (
EmployeeShift) — assign a shift to an employee for a date range (open-endedend_date= still current):EmployeeShift::create(['employee_id' => $employee->id, 'shift_id' => $shift->id, 'start_date' => '2026-08-01']);
No assignment covering a date?
ShiftResolverfalls back toconfig('attendance.default_shift')— summaries work from day one, before you've set up a single shift. OrGET/POST /attendance/employees/{id}/schedule. -
Holiday — a date nobody's expected to work, with no punch needed to explain the day.
Holiday::create(['name' => 'Independence Day', 'date' => '2026-03-26', 'is_recurring_yearly' => true]);
-
Leave — request → approve/reject, same pattern as attendance corrections:
$leave = $employee->requestLeave(['start_date' => '2026-09-03', 'end_date' => '2026-09-03', 'reason' => 'personal']); $leave->approve($reviewerId); // or ->reject(...)
An approved leave outranks everything else for that date — even a stray punch. Or
GET/POST /attendance/employees/{id}/leaves,POST /attendance/leaves/{id}/approve|reject.Leave balance (allowed/used/remaining per
LeaveType, per calendar year — a leave counts against the year of itsstart_date):$employee->leaveBalances(2026); // every leave type: [['leave_type' => 'Casual', 'allowed' => 10, 'used' => 3, 'remaining' => 7], ...]
Or
GET /attendance/employees/{id}/leave-balance?year=2026. -
Attendance summary — the actual present/late/absent/leave/holiday/day_off computation, one row per employee per day:
php artisan attendance:build-summaries 2026-09-07 # one date, every active employee(new AttendanceSummaryService)->buildOne($employee, '2026-09-07'); (new AttendanceSummaryService)->buildForMonth($employee, 2026, 9);
Priority order per day: leave → holiday → day off → absent (no punch) → late/present (from the shift-vs-first-punch comparison). Run
attendance:build-summariesafter every device sync (or schedule it) so summaries stay current. -
Overtime — auto-detected from each day's summary (
ot_minutes: last-out past the shift's end time), but lands as apendingOvertimeRecord— it only reaches a payslip once approved, so a punch-clock quirk can't quietly inflate pay:$ot = OvertimeRecord::where('employee_id', $employee->id)->where('date', '2026-10-08')->first(); $ot->approve($reviewerId, 'confirmed with supervisor'); // or ->reject(...)
Rate follows the BD Labour Act convention this package was first built under —
hourly_rate = basic_salary / (salary_divisor × 8), OT paysrate_multiplier ×that, capped atmax_hours_per_day(config('attendance.overtime'), all adjustable). Rebuilding a summary never reopens a record someone already approved/rejected — only an untouched auto/pending row gets updated. -
Special working days — an employee specifically asked to work a day that's normally off (their shift's off day, or a company
Holiday) gets extra pay for it, on top of ordinary salary, instead of that day just quietly counting as a plain "present":SpecialWorkingDay::create(['employee_id' => $employee->id, 'date' => '2026-10-09', 'is_payable' => true]);
type(day_off/holiday/other) is auto-detected from the date itself against that employee's own shift — not chosen by hand, so it can't drift out of sync. Payment defaults to a plain day's rate (config('attendance.special_working_days')—daily_rate/fixed_amount/multiplier, per type), or set a custompayment_amounton the record to override it. Only pays if the employee actually punched in that day — marking a date special doesn't create attendance, it just adds pay to attendance that already happened. -
Salary — generated from that month's summaries (rebuilds them first, so a slip always reflects the latest synced attendance) — includes approved overtime and payable special-working-day pay automatically:
php artisan attendance:generate-salary 2026 9 # every active employee php artisan attendance:generate-salary 2026 9 --employee=5 # just one
Default rule (override by reading
SalaryService— this is a starting point, not a full payroll engine): each absent day docks onebasic_salary / working_days_per_month; every Nth late day docks one more — the common "3 late = 1 absent" office policy (config('attendance.salary.late_deduction_ratio'), default3).net_salary = basic_salary + allowances - deductions + overtime_amount + special_pay_amount, snapshotted onto theSalarySlipso a later raise never reshapes an already-generated one. -
Reports — read-only JSON over the summary/salary tables (presentation is up to your own app/GUI):
GET /attendance/reports/daily?date=2026-09-07
GET /attendance/reports/monthly?year=2026&month=9
GET /attendance/reports/employee/{employee}?from=2026-09-01&to=2026-09-07
GET /attendance/reports/salary?year=2026&month=9
Full worked example
All eight pieces together, one employee, one week — this is a real tinker run, output included, so you can see exactly what each step produces. (Overtime + special working days are a second, separate run below, on their own employee/month, so the numbers stay easy to follow.)
$employee = Employee::create([ 'employee_code' => 'E-100', 'name' => 'Nusrat Jahan', 'device_user_id' => '9001', 'basic_salary' => 30000, 'allowances' => ['house_rent' => 5000, 'medical' => 1000], 'status' => 'active', ]); $shift = Shift::create(['name' => 'General', 'start_time' => '09:00', 'end_time' => '17:00', 'late_grace_minutes' => 10, 'off_days' => ['Friday']]); EmployeeShift::create(['employee_id' => $employee->id, 'shift_id' => $shift->id, 'start_date' => '2026-08-01']); Holiday::create(['name' => 'Independence Day', 'date' => '2026-09-05']); $leave = $employee->requestLeave(['start_date' => '2026-09-03', 'end_date' => '2026-09-03', 'reason' => 'personal']); $leave->approve(); // A normal week: present, a late day, on-leave, present, a holiday, present — Sunday's a // working day here (only Friday is off) — then the 7th is skipped entirely (genuinely absent). $employee->checkIn(['time' => '2026-09-01 09:05:00']); $employee->checkOut(['time' => '2026-09-01 17:10:00']); $employee->checkIn(['time' => '2026-09-02 09:45:00']); $employee->checkOut(['time' => '2026-09-02 17:00:00']); $employee->checkIn(['time' => '2026-09-04 08:55:00']); $employee->checkOut(['time' => '2026-09-04 17:05:00']); $employee->checkIn(['time' => '2026-09-06 09:00:00']); $employee->checkOut(['time' => '2026-09-06 17:00:00']); foreach (['2026-09-01','2026-09-02','2026-09-03','2026-09-04','2026-09-05','2026-09-06','2026-09-07'] as $d) { echo $d.': '.(new AttendanceSummaryService)->buildOne($employee, $d)->status.PHP_EOL; }
2026-09-01: present
2026-09-02: late (checked in 09:45, 45 min past the 09:00 shift start)
2026-09-03: leave (the approved leave — outranks everything)
2026-09-04: present
2026-09-05: holiday (no punch needed — Holiday already explains the day)
2026-09-06: present
2026-09-07: absent (no punch, not a holiday/leave/off-day)
$slip = (new SalaryService)->generate($employee, 2026, 9); // present=4 absent=21 late=1 leave=1 // basic=30000.00 deduction=21000.00 net=15000.00 // (21 absent days × 30000/30 = 21000 deduction — buildForMonth() filled in every // unpunched day of September as absent/day_off, not just the 7 days above)
Overtime + special working day, worked example
Same shift setup, a different employee, basic 26000 (per-day rate = 26000/30 ≈ 866.67):
// 3 late days in October — the late-ratio deduction (config default: every 3rd = 1 absent) kicks in. foreach (['2026-10-05', '2026-10-06', '2026-10-07'] as $d) { $employee->checkIn(['time' => $d.' 09:45:00']); $employee->checkOut(['time' => $d.' 17:00:00']); } // Worked till 19:30 on the 8th — 2.5h past the 17:00 shift end. $employee->checkIn(['time' => '2026-10-08 09:00:00']); $employee->checkOut(['time' => '2026-10-08 19:30:00']); // Asked to work Friday the 9th (the shift's off_day) — and did. $employee->checkIn(['time' => '2026-10-09 09:00:00']); $employee->checkOut(['time' => '2026-10-09 17:00:00']); $special = SpecialWorkingDay::create(['employee_id' => $employee->id, 'date' => '2026-10-09', 'is_payable' => true]); // $special->type === 'day_off' — auto-detected foreach (['2026-10-05','2026-10-06','2026-10-07','2026-10-08','2026-10-09'] as $d) { (new AttendanceSummaryService)->buildOne($employee, $d); } $ot = OvertimeRecord::where('employee_id', $employee->id)->where('date', '2026-10-08')->first(); // status=pending ot_hours=2.00 (capped from 2.5) ot_rate=250.0000 ot_amount=500.00 $ot->approve(); $slip = (new SalaryService)->generate($employee, 2026, 10);
present=5 absent=22 late=3
basic=26000.00 deduction=19933.33 (23 days × 866.67 — 22 absent + 1 from 3 late ÷ 3)
overtime_hours=2.00 overtime_amount=500.00
special_pay_amount=866.67 (one day's rate, for showing up on an off day)
net_salary=7433.34 (26000 − 19933.33 + 500 + 866.67)
Exports, bulk import, notifications & print views
Everything here is zero-dependency, same as the rest of the package — no maatwebsite/excel, no dompdf.
CSV export
Add &format=csv to any of the four report endpoints for a downloadable file instead of JSON:
GET /attendance/reports/daily?date=2026-11-01&format=csv
GET /attendance/reports/monthly?year=2026&month=11&format=csv
GET /attendance/reports/employee/{employee}?from=...&to=...&format=csv
GET /attendance/reports/salary?year=2026&month=11&format=csv
Plain fputcsv() streamed to the response (Http\Controllers\Concerns\ExportsCsv) — opens directly in Excel/Sheets.
CSV import (bulk-add employees)
POST /attendance/employees/import (multipart, field name "file")
Header row: employee_code, name, email, phone, designation, device_user_id, basic_salary, joined_at, status (any order; unrecognized columns are ignored) — plus any allowance_* column (e.g. allowance_house_rent) becomes a key in that employee's allowances map. A bad row is skipped and reported, not fatal to the rest of the file:
{"imported": 48, "skipped": 2, "errors": [{"row": 5, "message": "The employee code has already been taken."}]}
Use EmployeeCsvImporter directly if you'd rather trigger this from an Artisan command or a job than the HTTP endpoint.
Notifications
The package fires events (see Events) but has no opinion on who to notify — that's your app's call. What it does provide: ready-made content for each notifiable event, using only illuminate/notifications (core Laravel, no new package) over the mail and database channels:
| Notification | For event |
|---|---|
AttendanceMarkedLateNotification |
AttendanceMarkedLate |
LeaveRequestedNotification |
LeaveRequested |
LeaveReviewedNotification |
LeaveReviewed |
OvertimeReviewedNotification |
OvertimeReviewed |
AttendanceDeviceSyncFailedNotification |
AttendanceDeviceSyncFailed |
Wire one up in your own EventServiceProvider (or anywhere — they're plain Illuminate\Notifications\Notification classes):
use Easybdit\LaravelEasyAttendance\Events\AttendanceMarkedLate; use Easybdit\LaravelEasyAttendance\Notifications\AttendanceMarkedLateNotification; Event::listen(AttendanceMarkedLate::class, function ($event) { $hrUsers = User::where('role', 'hr')->get(); Notification::send($hrUsers, new AttendanceMarkedLateNotification($event->employee, $event->date, $event->lateMinutes)); });
The database channel needs the standard Laravel notifications table — php artisan notifications:table && php artisan migrate if you haven't already got one.
Print views (payslip & attendance sheet)
GET /attendance/salary/{slip}/print
GET /attendance/reports/monthly/print?year=2026&month=11
Plain HTML with a "Print / Save as PDF" button that calls the browser's own print dialog — every modern browser saves that straight to PDF, no server-side PDF library involved. Views are published (--tag=attendance-views, landing in resources/views/vendor/attendance/) so you can restyle or rebrand them freely.
Localization
Every user-facing string the package itself generates — notification subjects/lines, print-view labels, and the day-status labels (Present/Late/.../P/L/...) — comes from Laravel's own translation system, shipped in English (resources/lang/en/{attendance,notifications,print}.php), using only illuminate/translation (core Laravel).
Add another locale without forking the package:
php artisan vendor:publish --tag=attendance-lang
This lands the files at lang/vendor/attendance/en/. Copy that en/ folder to e.g. lang/vendor/attendance/bn/ and translate the strings — Laravel picks the right one based on App::setLocale() / config('app.locale') automatically, same as any other translation file.
__('attendance::notifications.late.subject', ['name' => $employee->name]); __('attendance::attendance.status_short.present'); // 'P'
Filament Admin Panel
A full click-to-manage UI over every module this package ships — check-in/out, corrections, device sync, employees, shifts, leave, holidays, overtime, special working days, and salary slips — built entirely on the package's own public API (models, config, the approve()/reject() methods on Leave/OvertimeRecord/AttendanceCorrection).
Filament itself is a suggested, not required, dependency — this package works standalone with zero UI, and the ~30 resource/page classes under Easybdit\LaravelEasyAttendance\Filament\ are never touched unless you opt in:
composer require filament/filament
// app/Providers/Filament/AdminPanelProvider.php use Easybdit\LaravelEasyAttendance\Filament\EasyAttendancePlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(EasyAttendancePlugin::make()); }
That's it — every resource is individually gated by the same attendance.features.* flag its table/routes already check (see Feature toggles), so a minimal check-in/out-only install just shows the Attendance resource, and turning on ATTENDANCE_FEATURE_HR_CORE lights up Employees, Departments, Designations, Shifts, Holidays, Leave Types, Leave, Overtime, Special Working Days, and Salary Slips without any extra config on the Filament side. A resource for a module you haven't enabled is hidden from navigation and refuses direct URL access — not just hidden, actually blocked.
Highlights:
- Leave, Overtime, and Attendance Correction resources ship one-click Approve/Reject row actions that call the package's own
approve()/reject()model methods — the same code path the HTTP API uses, so events fire and salary/attendance stay consistent either way. - Dashboard stats widget (
Widgets\AttendanceOverviewWidget) — today's present/late/absent breakdown (or today's punch count, if you haven't enabled Attendance Summaries), active employee count, and a combined pending-approvals count across Leave/Overtime/Corrections. Feature-gated the same way as every resource, and query-cached for 30s so a dashboard several admins keep open doesn't turn into acount()on every page load. - Navigation badges on Leave, Overtime, and Corrections show their live pending count — same 30s cache as the dashboard widget, invalidated immediately by the resource's own Approve/Reject action so it doesn't sit stale after you just acted on it. Backed by a new migration indexing
statuson all three tables (none of their existing composite indexes lead withstatus, so a bare pending-count query would otherwise be a full table scan at scale). - Attendance Summaries is view/list only (no create/edit/delete) — it's generated data, rebuilt by
attendance:build-summaries, so the UI doesn't pretend you can hand-edit it. - The
Attendance/AttendanceCorrectionsubject picker targets your single configuredattendance.subject_modelwith a searchable Select, instead of a two-step polymorphic type-then-record picker — matches how the overwhelming majority of installs use it. - Every resource uses Filament's single-page "Manage" pattern (list + modal create/edit) — no separate edit-page routes to add to your panel. The one exception is Employee, which also gets a profile page (reached via the table's View action) showing a Leaves tab — an employee's leave history, with the same Approve/Reject actions, right from their profile instead of only from the flat LeaveResource list.
Table names
Every table this package owns is prefixed easyattendance_ by default (easyattendance_employees, easyattendance_shifts, easyattendance_leaves, ...) — a generic-sounding name like "employees" or "leaves" is exactly the kind of table your app, or another package, might already have, so this package never installs one unprefixed.
If you still hit a conflict, or want the package to read a table you already have, override just that one entry in config('attendance.table_names') — same shape as spatie/laravel-permission's table_names config, so nothing new to learn if you've used that:
// config/attendance.php 'table_names' => [ 'employees' => 'hr_employees', // only this one renamed, everything else stays default // ... ],
Set it before your first php artisan migrate — every migration and every model reads from this same config, so changing a value here is all it takes; no model or migration file to touch yourself. Renaming a table after you've already migrated needs a real migration on your end (rename the table, update the config to match).
Configuration reference
config/attendance.php, after php artisan vendor:publish --tag=attendance-config:
| Key | Default | Purpose |
|---|---|---|
subject_model |
your auth user model | The model attendance belongs to |
table_names |
easyattendance_* for every table |
Per-table override — see Table names |
routes.enabled |
true |
Turn off the built-in HTTP routes entirely |
routes.prefix |
attendance |
URL prefix for all routes |
routes.middleware |
['web','auth'] |
Applied to every route below the prefix |
routes.review_middleware |
['web','auth'] |
Extra gate on correction/device management routes — point at your own admin can: |
features.corrections |
true |
Correction request/approve/reject |
features.device_sync |
false |
ZKTeco pull + push/ADMS |
features.employees / shifts / holidays / leave / summaries / salary / overtime / special_working_days / departments |
false |
HR core, each individually toggleable — or set ATTENDANCE_FEATURE_HR_CORE=true to flip all nine at once |
device_sync.pin_column |
device_user_id |
Column on the subject model's table holding the device PIN |
device_sync.online_threshold_seconds |
90 |
How recently a push device must have been seen to count "online" |
device_sync.notify_after_failures / notify_every |
2 / 5 |
AttendanceDeviceSyncFailed escalation schedule |
device_sync.adms_verify_ip |
false |
Reject an ADMS push whose source IP doesn't match the device's registered ip |
device_sync.adms_throttle |
throttle:60,1 |
Rate limit applied to all four ADMS routes |
device_sync.adms_max_lines_per_push |
5000 |
Cap on ATTLOG lines processed from one push batch |
default_shift |
09:00–18:00, 15min grace, Friday off | Fallback used by ShiftResolver when no roster entry covers a date |
salary.working_days_per_month |
30 |
Divides basic_salary into a per-day rate for deductions |
salary.late_deduction_ratio |
3 |
Every Nth late day docks one more day's pay |
salary.deduct_for_absent / deduct_for_late |
true / true |
Turn either deduction rule off |
overtime.salary_divisor / rate_multiplier |
26 / 2 |
OT hourly rate = basic_salary / (divisor × 8), paid at multiplier × that |
overtime.max_hours_per_day |
2 |
Caps auto-detected OT per day, however late the last punch |
overtime.auto_detect |
true |
Auto-create a pending OvertimeRecord whenever a summary has ot_minutes |
special_working_days.day_off_payment_type / holiday_payment_type |
daily_rate |
daily_rate | fixed_amount | multiplier, per special-day type |
special_working_days.*_fixed_amount / *_multiplier |
1000 / 1.0 |
Used when the payment type above is fixed_amount / multiplier |
Routes reference
| Method | URI | Feature |
|---|---|---|
| POST | /attendance/check-in |
core |
| POST | /attendance/check-out |
core |
| GET | /attendance/today |
core |
| GET/POST | /attendance/corrections |
corrections |
| POST | /attendance/corrections/{id}/approve|reject |
corrections (behind review_middleware) |
| GET/POST/PUT/DELETE | /attendance/devices... ⚡ |
device_sync (behind review_middleware) |
| GET/POST | /iclock/cdata, /iclock/getrequest, /iclock/devicecmd |
device_sync — public, no prefix, fixed paths (device firmware calls these directly) |
| GET | /attendance/reports/daily|monthly|employee/{id}|salary |
summaries (salary route also needs features.salary), behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/employees... ⚡ |
employees, behind review_middleware |
| GET/POST | /attendance/employees/{id}/schedule |
employees + shifts, behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/shifts... ⚡ |
shifts, behind review_middleware |
| GET/POST | /attendance/employees/{id}/leaves |
employees + leave, behind review_middleware |
| POST | /attendance/leaves/{id}/approve|reject |
leave, behind review_middleware |
| GET | /attendance/employees/{id}/leave-balance |
employees + leave, behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/departments... ⚡ |
departments, behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/designations... ⚡ |
departments, behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/holidays... ⚡ |
holidays, behind review_middleware |
| GET/POST/PUT/DELETE | /attendance/leave-types... ⚡ |
leave, behind review_middleware |
| GET | /attendance/employees/{id}/overtime |
employees + overtime, behind review_middleware |
| POST | /attendance/overtime/{id}/approve|reject |
overtime, behind review_middleware |
| GET/POST | /attendance/employees/{id}/special-working-days |
employees + special_working_days, behind review_middleware |
| PUT/DELETE | /attendance/special-working-days/{id} |
special_working_days, behind review_middleware |
| POST | /attendance/employees/import |
employees, behind review_middleware (CSV bulk-add) |
| GET | /attendance/reports/monthly/print |
summaries, behind review_middleware (printable attendance sheet) |
| GET | /attendance/salary/{id}/print |
salary, behind review_middleware (printable payslip) |
All the employee/shift/leave/overtime/special-working-day routes above take an explicit {employee} — they're HR/admin management endpoints, not "my own" self-service, since Employee is a separate concept from whatever attendance.subject_model your Auth::user() actually is (see Core concept: the subject model). There's deliberately no store() for overtime — records are only ever auto-detected (see OvertimeRecord::detectFromSummary()), never created by hand over HTTP.
⚡ Paginated (employees, shifts, departments, designations, holidays, leave-types, devices) — GET on these indexes returns a standard Laravel paginator object (data, current_page, last_page, per_page, total, …), not a flat array. Pass ?per_page= to control page size (default 25, capped at 100) and ?page= to move through pages. GET /attendance/employees additionally accepts ?search= (matches name, employee code, or email) and ?status=/?department_id= filters.
Breaking change note (pre-1.0): earlier versions returned a flat JSON array from these index endpoints. If you're upgrading, read the paginated response's items from
datainstead of the top-level array — see the Changelog.
Tested against real devices
Not just unit-tested against fixtures — validated end to end against actual production ZKTeco hardware, following the exact register → connect/sync → query sequence documented above:
- Push (ADMS): simulated a real device's ATTLOG push (its genuine serial number) against
/iclock/cdata— handshake accepted, punch ingested, heartbeat updated. - Pull (IP), connection only: registered a live device, tested the connection — succeeded, confirmed zero attendance rows existed for it beforehand (nothing is fetched just by registering/testing).
- Pull (IP), full sync: same device,
pull— 7,136 real attendance logs fetched in ~13 seconds. PINs with no matching subject were correctly reported (not silently dropped) and produced no rows. - Pull (IP), matched subject: assigned a subject a real PIN seen in that log, pulled again — 23 real historical punches (spanning roughly 4 months of real dates) landed on that subject and were immediately queryable via
$user->attendances()and$user->attendanceOn($date). - Pull (IP), built-in ZK client: re-ran the same live-device pull after replacing the external ZK library with this package's own vendored client (see below) — 7,172 real logs fetched in ~7 seconds, identical behavior, zero external package involved.
Zero external dependencies
This package requires only Laravel itself (illuminate/support, illuminate/database) and the ext-sockets PHP extension (near-universally enabled already) — nothing else, for either sync mode:
- Push/ADMS never needed anything extra — it's plain HTTP, handled by
AdmsPushController. - Pull talks the ZKTeco UDP protocol directly via
Support\Zk\ZkClient, a from-scratch-in-this-package client trimmed to exactly what device sync needs (connect, fetch attendance logs, fetch enrolled users, set the push comm key). It started as a wrapper around coding-libs/zkteco-php; the wire-protocol logic (packet framing, checksum, record parsing) is ported from it under MIT license — see THIRD-PARTY-NOTICES.md — unchanged on purpose, since it's what's validated against real hardware above, but no longer an installable dependency: one less thing that can go missing or version-conflict in your project.
Testing
composer install
composer test
Runs against sqlite in-memory by default (Orchestra Testbench) — no service container to stand up locally. Override via real env vars, no file to edit, for any other driver — this is what CI itself uses (MySQL 8, via a service container in .github/workflows/tests.yml):
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_DATABASE=your_test_db DB_USERNAME=... DB_PASSWORD=... composer test
67 tests / 196 assertions cover: punch resolution priority (manual over device), correction approve/reject creating real punches, every event, device push matching/unmatched-PIN/idempotency, pull-mode failure escalation, the check-in/correction HTTP routes, the HR core — summary status priority (leave > holiday > day off > absent > late/present), late-minute math, recurring-yearly holidays, an approved leave overriding a stray punch, overtime/special-working-day pay (the late-ratio deduction boundary, OT capped-and-approval-gated, special-day type auto-detection, pay withheld unless the employee showed up), every management HTTP route (employee/shift/schedule/leave/holiday/leave-type/overtime/special-working-day), CSV export/import, notification content, and both print views — plus leave balance arithmetic (including the not-negative clamp), department/designation CRUD and the nulls-out-not-blocks delete behavior, and translated notification/print content (including a caught IP:port placeholder-collision bug). Runs on GitHub Actions against MySQL 8 on PHP 8.2/8.3/8.4 on every push.
A cross-database gotcha this suite caught: every date-cast column (Holiday::date, Leave::start_date/end_date, EmployeeShift::start_date/end_date, AttendanceSummary::date, OvertimeRecord::date) gets written by Eloquent through the connection's full datetime format (e.g. "2026-09-01 00:00:00"), not a bare date. MySQL's DATE columns silently truncate that back down on insert; SQLite stores it verbatim, so an exact-string where('date', ...) only ever matches on MySQL. Every such comparison in this codebase uses whereDate() instead, which compares just the date part at the SQL level regardless of which of those actually got stored — worth knowing if you query these columns yourself.
Building a UI
This package is headless by design (JSON + two print views) — no bundled Vue/React/Livewire UI, and no plan to maintain three separate framework-specific packages. docs/frontend-examples.md has real, copy-adjustable code for the same check-in/check-out widget and report table built four ways: Vue 3, React, Livewire (calls the package's PHP directly — no HTTP round-trip, no build step), and plain Blade + vanilla JS — plus a quick "which one should I pick" guide and how CSRF/auth works for each.
Roadmap
- Maintain releases and improve compatibility across supported Laravel versions.
- A pluggable
ShiftResolver/leave/holiday contract for teams who want summaries against their own existing shift/roster system instead of this package'sShift/EmployeeShift.
Frequently Asked Questions
Is this a Laravel attendance package?
Yes. Laravel Easy Attendance provides employee check-in/check-out, attendance logs, daily summaries, corrections, shifts, leave, overtime, salary generation, and attendance reports for Laravel applications.
Does it support ZKTeco biometric devices?
Yes. The package supports both ZKTeco Pull mode and ZKTeco Push / ADMS mode. Pull mode connects from the Laravel server to the device, while Push / ADMS allows the device to send attendance data to the Laravel application.
Do I need to install any other package for ZKTeco support?
No. Both sync modes work with zero external packages — the ZK protocol client is built directly into this package (see Zero external dependencies). All you need is ext-sockets, a standard PHP extension almost every install already has enabled.
Can I use my existing User model?
Yes. The attendance system uses a polymorphic subject relation, so you can attach attendance to your existing User, Employee, Staff, or another model.
Does it include HR and payroll features?
The optional HR core includes employees (with Department & Designation), shifts, schedules, holidays, leave (with balance tracking — allowed/used/remaining per type per year), attendance summaries, overtime, special working-day pay, and salary-slip generation.
Which Laravel versions are supported?
The package is designed for Laravel 11, Laravel 12, and Laravel 13 and requires PHP 8.2 or higher.
Is it available in languages other than English?
Every string the package generates itself (notifications, print views, day-status labels) goes through Laravel's own translation system, shipped in English. Publish it (--tag=attendance-lang) and add your own locale folder — no code changes, no extra package. See Localization.
Keywords
Laravel attendance, Laravel attendance package, Laravel attendance management, employee attendance, employee attendance system, biometric attendance, ZKTeco attendance, ZKTeco Laravel integration, ZKTeco ADMS, biometric attendance system, attendance management system, HR management, HRMS, department management, designation management, leave management, leave balance, shift management, overtime management, payroll, salary management, attendance reports, Laravel HR package, multi-language attendance system, localization, Filament attendance plugin, Filament HR panel, Filament admin panel package.
License
MIT.