taha-rafi/php-zkteco
A PHP library for communicating with ZKTeco biometric attendance and access control devices over the network. Connect to a device to read user records, attendance logs, and device info, sync time, and manage settings, giving PHP applications a simple way to pull and work with data from ZKTeco termin...
Install and let Laravel 8–12 auto-discovery do the wiring:
composer require .../php-zkteco
php artisan vendor:publish --tag=zkteco-config
Then set your device credentials in .env and confirm they map correctly in config/zkteco.php (host, port, protocol, comm key). Run php artisan config:cache after any config change — the facade resolves the client from cached config, and stale config is the most common "it worked in tinker but not in the queue" cause.
Where to look first:
config/zkteco.php — the entire surface area of tunables (protocol, timeouts, comm key, webhook secret, cloud sync endpoint) is visible here before you read a line of source.src/Laravel/ZKTecoServiceProvider.php and src/Laravel/Facades/ZKTeco.php — the provider tells you what is bound as singleton vs. factory, and the facade's @method docblock is your fastest API reference.First use case: a scheduled command that pulls attendance from one device into an Eloquent table. That single flow exercises connection, CommKey auth, log retrieval, and your dedupe strategy — the four things that break in production. Get that working before touching writes or deletion.
Inject the client, don't sprinkle the facade. The facade is fine in controllers and commands, but for anything you want to test, resolve the underlying client from the container and type-hint it. You can then swap it with a fake in tests instead of hitting real hardware in CI.
One connection per job, not per record. Open the device connection inside a queued job, process all records for that device, close it. Long-lived connections across a worker's lifetime will silently die and surface as timeouts on the next job.
Multi-device fleets: fan out by device. Keep a devices array in config (or a DB table), and dispatch one job per device with Bus::batch() so a single unreachable terminal doesn't block the rest:
Bus::batch(
collect(config('zkteco.devices'))->map(fn ($d) => new SyncDeviceAttendance($d))
)->dispatch();
Add ->allowFailures() — one dead terminal is normal, not an incident.
Attendance ingest → idempotent upsert. Devices retain logs until explicitly cleared, so every poll re-reads the same rows. Use a unique index on (device_sn, user_id, timestamp) and upsert(). Never insert().
Writing ATT_LOG (CMD_SET_DATA 3001) is for backfill and migration — seeding a replacement terminal from your database, or reconciling after a device swap. Treat it as a one-way migration step in a command with a --dry-run flag, not something in a request path.
Selective deletion (CMD_TIME_ATTLOG_DELE 10005) is a two-phase operation: export and persist locally first, verify row counts match, then delete. Never delete on the same pass that reads — if the read is partial you will destroy data you never stored.
Protocol choice: TCP for anything transactional (writes, deletions) because you get delivery confirmation. UDP for high-volume read polling on legacy/firmware-limited devices where speed matters more than ordering. Make it per-device config, not global — mixed fleets are the norm.
Push-based devices → AdmsServer + webhooks. For terminals that can POST to you, run the built-in AdmsServer rather than polling. Verify every inbound webhook with HMAC-SHA256 over the raw request body and hash_equals(); store the secret in config/env, never in the DB. Register the webhook route outside web middleware so CSRF doesn't reject device traffic.
Cloud POST sync is the escape hatch for edge sites: let the on-prem Laravel app talk to the device, and have it forward normalized records to your central API. Keep the payload schema identical to your local table so the receiver can reuse validation.
nc -zv <host> 4370 from the queue worker's host, not your laptop.getDeviceInfo-style call) before concluding a device has no attendance.--confirm flag.config/zkteco.php. A hung socket will eat a queue worker for the full default timeout and cascade into a backed-up queue.config:cache after publish. Editing config/zkteco.php on a cached deployment has no effect. Add config recaching to your deploy script, not your muscle memory.AppServiceProvider rather than forking the package. The facade resolves whatever is bound, so a decorator gives you request/response logging for free during debugging.How can I help you explore Laravel packages today?