tes / biotime-sdk
Framework-agnostic PHP SDK for the ZKTeco BioTime device API — token auth, attendance, employees, devices, departments, positions, areas, and resignations.
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- illuminate/support: ^10.0 || ^11.0
- phpunit/phpunit: ^10.0
Suggests
- illuminate/support: Required only if you want the Laravel service provider (auto-registered singleton + publishable config).
README
A small, framework-agnostic PHP SDK for the ZKTeco BioTime device API.
It wraps token authentication, automatic 401 retry, pagination, and DTO mapping around the device's REST API, so you can write:
$biotime = new BioTime(); $employee = $biotime->employees()->find('EMP001'); echo $employee->firstName;
instead of hand-rolling Guzzle calls and re-authenticating every time your token expires.
Table of contents
Features
- 🔐 Automatic token auth — authenticates once, caches the token (PSR-16
or in-memory), and transparently re-authenticates on a
401. - 📄 Pagination handled for you — every resource exposes
list()for a single page andall()to walk every page and return typed DTOs. - 🧩 Typed DTOs — every response row is mapped to a readonly DTO
(
Employee,Device,AttendanceRecord, ...) while still giving you the full raw payload via->raw. - 🚦 Consistent error handling — every non-2xx response throws
ApiException(orAuthenticationExceptionfor auth failures) with the status code and raw response body attached. - 🧱 Framework-agnostic core with an optional, auto-discovered Laravel service provider — use it in plain PHP, Symfony, Slim, or Laravel without any adapter code.
- 📤 Bulk device/employee actions — reboot terminals, clear commands/captures, resync employees, adjust department/area, process resignations and reinstatements — all first-class methods, not raw arrays.
- 📦 Zero business logic in your app — the SDK owns request shaping, auth, retries, and pagination; you just call methods and get DTOs back.
Requirements
- PHP
^8.1 ext-json- A ZKTeco BioTime device (or BioTime server) reachable over HTTP(S), with API credentials (username/password used to obtain a device token)
Installation
composer require tes/biotime-sdk
If you're using Laravel, the service provider is auto-discovered — there's nothing else to register. See Using inside Laravel.
Configuration
No connection is made until you actually call a resource method (e.g.
attendances()->all()) — constructing BioTime or Config never touches the
device. Config just needs to know where to load its values from, and
resolves them lazily.
Option A — plain PHP, using a .env file
Copy the example env file and fill in your device's details:
cp vendor/tes/biotime-sdk/src/.env.example .env
BIOTIME_IP=192.168.1.50 BIOTIME_PORT=8081 BIOTIME_USERNAME=admin BIOTIME_PASSWORD=secret BIOTIME_HTTPS=false BIOTIME_VERIFY_HTTPS=true BIOTIME_TIMEOUT=15
BIOTIME_VERIFY_HTTPS controls whether the HTTP client verifies the HTTPS
server certificate.
use BioTime\BioTime; // Loads ./config/biotime.php if it exists, otherwise reads BIOTIME_* env vars $biotime = new BioTime();
Option B — a config/biotime.php file
Copy src/config/biotime.php into your project's config/ directory and
edit it directly — env vars are still picked up as fallback values, so it's
safe to commit.
Option C — build Config explicitly
Useful for multiple devices, a custom cache implementation, or tests:
use BioTime\BioTime; use BioTime\Config; $config = Config::fromArray([ 'ip' => '192.168.1.50', 'port' => 8081, 'username' => 'admin', 'password' => 'secret', 'https' => false, 'verify' => true, 'timeout' => 15, 'cache' => $psr16Cache, // optional, any PSR-16 CacheInterface ]); // or: $config = Config::fromFile(__DIR__ . '/config/biotime.php'); $config = Config::fromEnv('BIOTIME_'); // reads BIOTIME_IP, BIOTIME_PORT, ... $biotime = new BioTime($config);
| Key | Env var | Default | Description |
|---|---|---|---|
host |
BIOTIME_IP |
127.0.0.1 |
Device/server hostname or IP |
port |
BIOTIME_PORT |
8081 |
Device/server port |
username |
BIOTIME_USERNAME |
— | API username |
password |
BIOTIME_PASSWORD |
— | API password |
https |
BIOTIME_HTTPS |
false |
Use https:// instead of http:// |
verify |
BIOTIME_VERIFY_HTTPS |
false |
Verify the HTTPS server certificate |
timeout |
BIOTIME_TIMEOUT |
15 |
Guzzle request timeout (seconds) |
token_ttl |
BIOTIME_TOKEN_TTL |
43200 (12h) |
Cached token TTL, in seconds |
cache |
— | null (in-memory) |
Any PSR-16 CacheInterface |
Quick start
use BioTime\BioTime; $biotime = new BioTime(); // Attendance records for a date range, single page $page = $biotime->attendances()->list( '2026-07-01', '2026-07-31', page: 1, perPage: 50 ); // Every page, already mapped to DTOs $records = $biotime->attendances()->all('2026-07-01', '2026-07-31'); foreach ($records as $record) { echo "{$record->empCode} punched {$record->punchState} at {$record->punchTime}\n"; } // Employees $employee = $biotime->employees()->find('EMP001'); $biotime->employees()->update($employee->id, ['first_name' => 'Jane']); // Devices $devices = $biotime->devices()->all(); $biotime->devices()->reboot([1, 2, 3]); // Departments / positions / areas $departments = $biotime->departments()->all(); $positions = $biotime->positions()->all(); $areas = $biotime->areas()->all();
Resources
Every list-style resource follows the same two-method convention:
list(...)— one page, returns the raw envelope (count,next,previous,data) withdatamapped to DTOs.all(...)— walks every page automatically and returns a flatDTO[]array.
Attendances
$biotime->attendances()->list( startTime: '2026-07-01', endTime: '2026-07-31', page: 1, perPage: 50, filters: [ 'emp_code' => 'EMP001', 'terminal_sn' => 'ABC123', 'ordering' => '-punch_time', ], ); $biotime->attendances()->all( '2026-07-01', '2026-07-31', filters: [ 'ordering' => '-punch_time', ], ); $biotime->attendances()->find(123); $biotime->attendances()->delete(123); // Export raw csv/txt/xls bytes instead of JSON use BioTime\Enums\ExportType; $csvBytes = $biotime->attendances()->export(ExportType::CSV, [ 'start_time' => '2026-07-01', 'end_time' => '2026-07-31', ]); file_put_contents('attendance.csv', $csvBytes);
Employees
$biotime->employees()->list( page: 1, perPage: 50, filters: [ 'department' => 3, 'ordering' => 'first_name', ], ); $biotime->employees()->all( filters: [ 'areas' => 1, 'ordering' => '-first_name', ], ); $biotime->employees()->find('EMP001'); $biotime->employees()->findById(3); $biotime->employees()->create([ 'emp_code' => 'EMP002', 'first_name' => 'Jane', 'last_name' => 'Doe', ]); $biotime->employees()->update(3, ['first_name' => 'Janet']); $biotime->employees()->delete(3); // Bulk actions $biotime->employees()->adjustArea([1, 2], [3, 4]); $biotime->employees()->adjustDepartment([1, 2], departmentId: 5); $biotime->employees()->adjustResign( employeeIds: [1, 2], resignDate: '2026-07-31', resignType: 1, disableAtt: true, reason: 'Voluntary resignation', ); $biotime->employees()->delBioTemplate( [1, 2], fingerPrint: true, face: true ); $biotime->employees()->resyncToDevice([1, 2]);
Note:
adjustResign()calls the device's documented/personnel/api/employees/adjust_regsin/endpoint — the trailing typo (regsininstead ofresign) is present in ZKTeco's own API and is preserved here intentionally so the SDK matches the real endpoint.
Devices
$biotime->devices()->list( page: 1, perPage: 50, filters: [ 'state' => 1, 'ordering' => 'alias', ], ); $biotime->devices()->all( filters: [ 'ordering' => '-id', ], ); $biotime->devices()->find('A6KX192060002'); $biotime->devices()->get(5); $biotime->devices()->create([ 'sn' => '111111111', 'alias' => 'Front Door', 'ip_address' => '192.168.1.60', 'area' => 1, ]); $biotime->devices()->update(5, ['alias' => 'Back Door']); $biotime->devices()->delete(5); // Bulk terminal commands — all accept a list of device ids $biotime->devices()->clearCommand([1, 2]); $biotime->devices()->clearCapture([1, 2]); $biotime->devices()->clearAll([1, 2]); $biotime->devices()->uploadAll([1, 2]); $biotime->devices()->uploadTransaction([1, 2]); $biotime->devices()->reboot([1, 2]);
Departments
$biotime->departments()->list( page: 1, perPage: 50, filters: [ 'ordering' => 'dept_name', ], ); $biotime->departments()->all( filters: [ 'ordering' => '-dept_name', ], ); $biotime->departments()->find(3); $biotime->departments()->create([ 'dept_code' => 'ENG', 'dept_name' => 'Engineering', ]); $biotime->departments()->update(3, ['dept_name' => 'R&D']); $biotime->departments()->delete(3);
Positions
$biotime->positions()->list( page: 1, perPage: 50, filters: [ 'ordering' => 'name', ], ); $biotime->positions()->all( filters: [ 'ordering' => '-name', ], ); $biotime->positions()->find(2); $biotime->positions()->create([ 'position_code' => 'DEV', 'name' => 'Developer', ]); $biotime->positions()->update(2, ['name' => 'Senior Developer']); $biotime->positions()->delete(2);
Areas
$biotime->areas()->list( page: 1, perPage: 50, filters: [ 'ordering' => 'name', ], ); $biotime->areas()->all( filters: [ 'ordering' => '-name', ], ); $biotime->areas()->find(1); $biotime->areas()->create([ 'area_code' => 'HQ', 'name' => 'Headquarters', ]); $biotime->areas()->update(1, ['name' => 'Head Office']); $biotime->areas()->delete(1);
Resigns
$biotime->resigns()->list( page: 1, perPage: 50, filters: [ 'ordering' => '-resign_date', ], ); $biotime->resigns()->all( filters: [ 'ordering' => 'resign_date', ], ); $biotime->resigns()->findByEmployee(employeeId: 3); $biotime->resigns()->find(5); $biotime->resigns()->create([ 'employee' => 3, 'disableatt' => true, 'resign_type' => 1, 'resign_date' => '2026-06-01', 'reason' => 'Voluntary', ]); $biotime->resigns()->update(5, ['resign_date' => '2026-06-02']); $biotime->resigns()->delete(5); // Reinstate one or more previously resigned employees $biotime->resigns()->reinstatement([5, 6]);
Pagination
The API returns a standard envelope on every list endpoint:
{
"count": 42,
"next": "http://.../?page=2",
"previous": null,
"data": [ /* ... */ ]
}
list(...)returns this envelope as-is, withdatamapped to DTOs — use this when you needcount/nextto build your own pagination UI.all(...)walks every page under the hood (viaAbstractResource::fetchAll()) and returns a single flat array of DTOs — use this when you just want "everything that matches these filters."
// Single page, with envelope $page = $biotime->employees()->list( page: 2, perPage: 100, filters: [ 'ordering' => '-first_name', ], ); echo $page['count']; foreach ($page['data'] as $employee) { // ... } // Every page, flattened $allEmployees = $biotime->employees()->all( perPage: 200, filters: [ 'ordering' => 'first_name', ], );
Filtering and ordering
Resources that support filters accept an associative array through the
filters argument.
You can use the ordering filter to control the sort order of the API
response.
Ascending order
Pass the column name directly:
$employees = $biotime->employees()->list( filters: [ 'ordering' => 'first_name', ], );
This sorts the results in ascending order (ASC).
Descending order
Prefix the column name with -:
$employees = $biotime->employees()->list( filters: [ 'ordering' => '-first_name', ], );
This sorts the results in descending order (DESC).
Combining ordering with other filters
$employees = $biotime->employees()->list( page: 1, perPage: 50, filters: [ 'department' => 3, 'areas' => 1, 'ordering' => '-first_name', ], );
Examples:
ordering => 'first_name'→first_name ASCordering => '-first_name'→first_name DESCordering => 'id'→id ASCordering => '-id'→id DESC
The ordering value is passed to the BioTime API as part of the filters, so
the column name must be supported by the corresponding API endpoint.
DTOs vs raw data
Every DTO exposes the fields the SDK cares about as typed, readonly
properties, plus the complete original payload under ->raw in case the
device returns extra fields the DTO doesn't model:
$employee = $biotime->employees()->find('EMP001'); $employee->empCode; // 'EMP001' $employee->firstName; // 'Jane' $employee->department; // 'Engineering' $employee->raw; // full original array from the API
Error handling
All request failures throw an exception you can catch and inspect:
use BioTime\Exceptions\ApiException; use BioTime\Exceptions\AuthenticationException; try { $biotime->devices()->reboot([999]); } catch (AuthenticationException $e) { // Bad credentials, or the device rejected re-authentication } catch (ApiException $e) { echo $e->getStatusCode(); // e.g. 404 echo $e->getResponseBody(); // raw response body from the device }
AuthenticationException— thrown for token/auth failures specifically (extendsApiException, so catchingApiExceptionalso catches this).ApiException— thrown for any other non-2xx response.
On a 401, the client automatically clears the cached token,
re-authenticates once, and retries the original request before giving up and
throwing.
Using inside Laravel
The package auto-registers a Laravel service provider (via composer's
extra.laravel.providers), so in most apps there's nothing to wire up:
use BioTime\BioTime; $biotime = app(BioTime::class); // or type-hint BioTime in a controller/job constructor
The token is cached using Laravel's configured cache store, so it survives across requests instead of re-authenticating on every request.
Publish the config file if you'd rather edit it directly instead of relying
solely on .env:
php artisan vendor:publish --tag=biotime-config
That copies the package's config/biotime.php into your app's config/
directory, where Laravel will merge in .env values as usual.
You only need to register your own binding manually if you want non-default wiring (e.g. a specific cache store other than Laravel's default, or a custom Guzzle client for logging/middleware).
Testing / mocking the client
BioTime::__construct() accepts an optional Guzzle ClientInterface, so you
can inject a mock handler in tests without hitting a real device:
use BioTime\BioTime; use BioTime\Config; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Handler\HandlerStack; use GuzzleHttp\Psr7\Response; $mock = new MockHandler([ new Response(200, [], json_encode(['token' => 'fake-token'])), new Response(200, [], json_encode([ 'count' => 0, 'next' => null, 'previous' => null, 'data' => [], ])), ]); $httpClient = new Client([ 'handler' => HandlerStack::create($mock), ]); $biotime = new BioTime( Config::fromArray([ 'ip' => 'test', 'username' => 'x', 'password' => 'y', ]), $httpClient ); $biotime->employees()->all();
Project structure
src/
BioTime.php // Entry point — lazily instantiates each resource
Client.php // HTTP client: token injection, 401 retry, error handling
Config.php // Connection + cache configuration
Auth/
TokenManager.php // Authenticates and caches the device token
Resources/
AbstractResource.php // Shared `fetchAll()` pagination helper
Attendances.php
Employees.php
Devices.php
Departments.php
Positions.php
Areas.php
Resigns.php
DTO/
AttendanceRecord.php
Employee.php
Device.php
Department.php
Position.php
Area.php
Resign.php
Enums/
ExportType.php // csv | txt | xls
Exceptions/
ApiException.php
AuthenticationException.php
Laravel/
BioTimeServiceProvider.php // Auto-discovered, optional
config/
biotime.php // Publishable Laravel config / plain-PHP config file
.env.example
Contributing
Issues and pull requests are welcome.
If you want to contribute:
-
Fork the repository on GitHub.
-
Clone your fork:
git clone https://github.com/YOUR_USERNAME/biotime-sdk.git cd biotime-sdk -
Create a new branch:
git checkout -b feature/my-change
-
Install dependencies:
composer install
-
Make your changes and run the tests:
composer test -
Commit and push your branch:
git add . git commit -m "Describe your change" git push origin feature/my-change
-
Open a Pull Request from your fork to the
mainbranch of the original repository.
Please make sure your changes are focused, tested, and documented when appropriate.
License
The MIT License (MIT). See LICENSE for details.