tes/biotime-sdk

Framework-agnostic PHP SDK for the ZKTeco BioTime device API — token auth, attendance, employees, devices, departments, positions, areas, and resignations.

Maintainers

Package info

github.com/Thomas-Emad/biotime-sdk

pkg:composer/tes/biotime-sdk

Transparency log

Statistics

Installs: 111

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.3 2026-08-08 07:34 UTC

This package is auto-updated.

Last update: 2026-08-08 07:36:25 UTC


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.

PHP Version License: MIT

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 and all() 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 (or AuthenticationException for 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) with data mapped to DTOs.
  • all(...) — walks every page automatically and returns a flat DTO[] 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 (regsin instead of resign) 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, with data mapped to DTOs — use this when you need count/next to build your own pagination UI.
  • all(...) walks every page under the hood (via AbstractResource::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 ASC
  • ordering => '-first_name'first_name DESC
  • ordering => 'id'id ASC
  • ordering => '-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 (extends ApiException, so catching ApiException also 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:

  1. Fork the repository on GitHub.

  2. Clone your fork:

    git clone https://github.com/YOUR_USERNAME/biotime-sdk.git
    cd biotime-sdk
  3. Create a new branch:

    git checkout -b feature/my-change
  4. Install dependencies:

    composer install
  5. Make your changes and run the tests:

    composer test
  6. Commit and push your branch:

    git add .
    git commit -m "Describe your change"
    git push origin feature/my-change
  7. Open a Pull Request from your fork to the main branch 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.