skaidb — PHP driver
Repository: github.com/porcupin26/skaidb-php — this page is the driver's README at
v1.0.1; the repository also holds its docs, examples and changelog. Install:
composer require skaidb/skaidb
The official skaidb driver for PHP. The API is
modeled on PDO/PDOStatement: prepare(), execute([...]), fetch(),
fetchAll(), rowCount(). Pure PHP, one file, zero dependencies (only
the bundled hash extension; openssl for TLS).
- PHP 8.1 or newer. No Composer or PECL packages required.
- Speaks skaidb's binary protocol directly: SCRAM-SHA-256 auth, server-side prepared statements with typed parameters, one-round-trip batches, streaming result sets, full type fidelity (Int, Float, Decimal, UUID, Bytes, Timestamp, Array, Document).
- Multi-seed failover, transparent reconnect, TLS, connection pooling, stream subscriptions.
Full documentation: this README, the docs/ folder
(getting started · API reference ·
types · TLS · streaming ·
pooling), runnable examples/, the
changelog, the
wire protocol specification and the
skaidb documentation.
Install
The package is skaidb/skaidb. It is not registered on Packagist yet, so
point Composer at the GitHub repository:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/porcupin26/skaidb-php" }
],
"require": {
"skaidb/skaidb": "^1.0"
}
}
composer require skaidb/skaidb:^1.0
Composer resolves ^1.0 to the v1.0.1 tag and wires src/Skaidb.php into
vendor/autoload.php. (Once the package is on Packagist the repositories
entry becomes unnecessary; composer require skaidb/skaidb installs the same
thing.)
Without Composer, take src/Skaidb.php from a clone or from the
release asset and
require it directly:
require '/path/to/skaidb-php/src/Skaidb.php';
Quick start
<?php
require 'vendor/autoload.php'; // or require 'src/Skaidb.php';
use Skaidb\Connection;
$db = new Connection('localhost', 7000, 'skaidb', 'secret');
$db->exec('CREATE TABLE users (PRIMARY KEY (id))');
$stmt = $db->prepare('INSERT INTO users (id, name, tags) VALUES (?, ?, ?)');
$stmt->execute([1, 'Ada', ['admin']]);
$stmt = $db->prepare('SELECT id, name, tags FROM users WHERE id = ?');
$stmt->execute([1]);
print_r($stmt->fetch()); // ['id' => 1, 'name' => 'Ada', 'tags' => ['admin']]
$db->close();
Connecting
new Connection(
string $host = 'localhost', // node hostname
int $port = 7000, // binary protocol port
string $user = 'anonymous', // 'anonymous' for a server with auth disabled
string $password = '',
int|string $consistency = 'QUORUM', // 'ONE' | 'QUORUM' | 'ALL' or 0 | 1 | 2
float $timeout = 10.0, // connect and read timeout, seconds
?string $database = null, // optional: `USE <database>` right after the handshake
bool $tls = false, // see TLS below
?string $tlsCa = null,
bool $tlsInsecure = false,
string $tlsServerName = 'skaidb',
array $seeds = [] // ['db1:7000', 'db2:7000']; wins over host/port when given
);
Every argument is positional, so PHP named arguments read best:
$db = new Connection(user: 'app', password: $pw, database: 'app',
seeds: ['db1:7000', 'db2:7000', 'db3:7000'],
tlsCa: '/etc/skaidb/skai-ca.crt');
The constructor connects and runs the SCRAM-SHA-256 handshake; it throws
Skaidb\SkaidbException on a connect or authentication failure. With a
non-empty password the server's signature is verified too (mutual auth).
Seeds and failover
skaidb is leaderless: every node accepts reads and writes, so a seed list is
only "somewhere to land". The seeds are shuffled and tried in turn until one
connects; if none does the constructor throws no reachable endpoint in
<list>: <last error>. The same walk runs again on reconnect, so a connection
survives the node it was talking to going away.
TLS
| Arguments | Meaning |
|---|---|
| (none) | Plaintext. A server with client_tls = required refuses this. |
tls: true |
TLS, server certificate verified against the system trust store. |
tlsCa: '/path/ca.crt' |
TLS, certificate verified against this PEM bundle. Implies tls. |
tlsInsecure: true |
TLS with no certificate verification: encrypts, authenticates nothing. Development only. Implies tls. |
tlsServerName |
SNI and the name the certificate is verified against (default skaidb). Must match a SAN on the server certificate, which is usually not the address you dialled — skaidb's own certificates carry DNS:skaidb. |
See docs/tls.md.
Consistency
consistency selects how many replicas must acknowledge a write or be
consulted for a read: ONE, QUORUM (the default) or ALL. Set it per
connection or per statement:
$db = new Connection(/* ... */, consistency: 'ONE');
$db->setConsistency('ALL'); // from now on
$stmt->setConsistency('QUORUM'); // this statement only
foreach ($db->stream('SELECT ...', 'ONE') as $row) { ... }
Connection::ONE, Connection::QUORUM and Connection::ALL hold the
numeric codes (0, 1, 2).
Statements and parameters
$db->exec(string $sql): int // run; returns the affected-row count (0 for DDL)
$db->query(string $sql): Statement // run without parameters; fetch the result
$db->prepare(string $sql): Statement // then $stmt->execute([...])
$stmt->execute(array $params = []): bool
$stmt->fetch(): ?array // next row as ['column' => value], or null
$stmt->fetchAll(): array // every remaining row
$stmt->fetchColumn(int $i = 0): mixed // one cell of the next row, or false when exhausted
$stmt->rowCount(): int // rows affected (mutations) or rows returned (SELECT)
$stmt->columnCount(): int
$stmt->columns(): array // column names
$stmt->resultSets(): ?array // every set of a CALL that EMITs, else null
Placeholders are ?, positional, in order; a ? inside a string literal is
left alone. Statements on one connection run one at a time.
With parameters the driver prepares the statement on the server (once
per distinct SQL text per connection, cached up to 240 entries) and sends the
values as typed binary — no string interpolation, no injection surface.
That is also the only way to send an Array or a Document, which have no
SQL literal form. The statement's parameter count must match what you pass
(a mismatch throws before anything runs).
Statements the server declines to prepare (DDL, USE and other session
statements) fall back to client-side quoting of the same ? parameters, so
every statement kind accepts parameters; arrays and documents cannot travel
that way and throw (the message names the reason the server would not
prepare the statement). Prepared ids are scoped to a connection and the cache
is dropped on reconnect.
Batches — many rows, one round-trip
$n = $db->prepare('INSERT INTO t (id, tags) VALUES (?, ?)')
->executeBatch([[1, ['a']], [2, []], [3, ['b', 'c']]]); // 3
Runs a prepared statement once per row in a single request and returns the
total affected count. Rows autocommit individually: on the first
failing row the server answers with an error naming the row index and how
many rows applied before it, and those earlier rows stay applied — use
idempotent statements. Only preparable statements (SELECT/INSERT/
UPDATE/DELETE) can be batched; the whole request must fit one 64 MiB
frame. An empty batch returns 0 without a round-trip.
Multiple result sets
A CALL of a procedure whose body runs EMIT <select> answers with every
emitted set plus the call's final result:
$stmt = $db->query('CALL report()');
$stmt->fetchAll(); // the LAST set (the call's own result)
$stmt->resultSets(); // every set, in emission order: [['columns' => [...], 'rows' => [...]], ...]
Streaming large results — $db->stream($sql, $consistency = null)
query() buffers the whole result; a large scan is also bounded by the
server's scan budgets. stream() asks the server to deliver the rows in
chunks and yields them one at a time, holding one chunk in memory:
foreach ($db->stream('SELECT id, v FROM readings') as $row) {
process($row); // ['id' => ..., 'v' => ...]
}
It takes no parameters (the streaming opcode carries SQL text only). A
non-row statement streamed this way yields nothing. An error before any row
is an ordinary statement error; an error partway through (a node dying
mid-scan, a scan budget tripping) is thrown after the rows already yielded,
which are valid. On a cluster, name the columns: a bare SELECT * only
streams page by page at consistency ONE.
The connection is busy for the whole stream. Any other statement on it
throws connection is busy streaming a result set until the stream ends.
Abandoning the stream — break, return, an exception, or letting the
generator go out of scope — runs the generator's cleanup, which drains the
frames still in flight so the connection is back at a request boundary;
draining a huge scan costs the rest of that scan, so close() the
connection instead if that trade is wrong. See docs/streaming.md.
Following a stream — $db->subscribe($name, $after = null, $poll = 0.5)
For tables with a CREATE STREAM, subscribe() yields the stream's events
as they arrive, forever:
foreach ($db->subscribe('big_orders', after: $lastSeenId) as $ev) {
// $ev = ['id' => ..., 'op' => ..., 'k' => ..., 'ts' => ..., 'doc' => ...]
save($ev['id']); // pass it back as $after to resume exactly here
}
It polls the stream's log (_stream_<name>) with a keyset cursor, 500 events
per page; id is the position — an opaque string that sorts in log
order, so $after takes a saved id or null. break out of the loop to
stop. For push delivery subscribe to $stream/<db>/<name> with any MQTT
client instead — the events are identical.
Pooling — new Pool($connectionArgs, $maxsize = 10)
use Skaidb\Pool;
$pool = new Pool(['db1', 7000, 'app', $pw, 'QUORUM', 10.0, 'app'], 8);
$n = $pool->withConnection(fn (Connection $c) => $c->query('SELECT count(*) AS n FROM t')->fetchColumn());
$conn = $pool->acquire();
try { $conn->exec('...'); } finally { $pool->release($conn); }
$pool->close();
$connectionArgs are the Connection constructor arguments (positional, or
named as ['user' => ..., 'seeds' => [...]]), so pooled connections inherit
seeds, TLS and the session database. maxsize bounds the connections kept
idle, not the number checked out: a burst opens extras and the surplus
is closed on release. PHP is share-nothing per request, so a pool pays off
in a worker or a long-running CLI job. See docs/pooling.md.
Type mapping
| skaidb | PHP (results) | Bind (parameters) |
|---|---|---|
| Null | null |
null |
| Bool | bool |
bool |
| Int (i64) | int |
int |
| Float | float |
float (NaN/Infinity refused) |
| Decimal | string, exact ('123.45') |
new Skaidb\Decimal('123.45') |
| String | string |
string |
| Bytes | string (binary) |
new Skaidb\Bytes($raw) |
| Uuid | string, canonical lowercase 8-4-4-4-12 |
new Skaidb\Uuid('123e4567-…') |
| Timestamp | DateTimeImmutable in UTC, millisecond precision |
any DateTimeInterface |
| Array | array (list) |
a list array (array_is_list) |
| Document | array (associative, insertion order) |
an associative array |
A plain PHP string binds as a String; wrap it in Bytes, Decimal or
Uuid to send the typed value the server cannot express as a SQL literal.
Decimal is exact across the full 128-bit mantissa without gmp or bcmath.
Details in docs/types.md.
Errors and reconnect
Every error the driver raises is a Skaidb\SkaidbException (an Exception
subclass, modeled on PDOException).
- Statement errors (
SELECT nope→no such column …) throw; the connection stays usable. - Transport errors throw
connection closed by server/connection timed outand mark the connection broken, not closed. The next statement re-dials (through the seed walk), re-authenticates, re-sendsUSE <database>and clears the prepared-statement cache. Because the failed statement may have executed, the driver never retries it for you: retry a write only if it is idempotent.isUsable()reports the state. - Protocol desync (an unexpected frame): the driver drops the connection rather than hand the next caller a misaligned reply; the next statement reconnects.
close()is terminal: a closed connection never reconnects.
Transactions
Every statement autocommits; the driver adds no transaction API of its own
(no beginTransaction/commit). Whatever transaction statements your server
version supports are plain SQL sent through exec() on one connection —
note that on a cluster the server does not accept BEGIN. Batch rows
autocommit one by one, as described above.
Client identification
After authenticating, the driver sends a Hello frame that fills the server's
drivers table: client_name php, client_version = Skaidb\Skaidb::VERSION
(the package version, 1.0.1). An older server without the opcode ignores
it. Prepared statements need server ≥ 0.17.0 (older servers get the
client-side fallback automatically), batches ≥ 0.87.0, streaming a server
with the streaming opcode, multiple result sets a server with EMIT.
Development
php tests/run.php # unit tests against an in-process fake server, no skaidb needed
SKAIDB_HOST=127.0.0.1 SKAIDB_PORT=7000 SKAIDB_USER=admin SKAIDB_PASSWORD=pw \
php tests/live/live.php # end-to-end against a real node (skipped when SKAIDB_HOST is unset)
php examples/basic.php host 7000 user password
CI runs the suite on PHP 8.1, 8.2, 8.3 and 8.4, validates composer.json,
installs the package through Composer the way the Install section says, and
checks that the Hello version is derived from Skaidb::VERSION. Tagging
vX.Y.Z runs the publish workflow, which refuses a tag that does not equal
Skaidb::VERSION, builds the source zip and attaches it to a GitHub
Release. Packagist has no upload: registering the repository once on
packagist.org (Submit → this repository's URL) makes every tag a version
there. The workflow's last step asks Packagist to re-crawl the repository
when the PACKAGIST_USERNAME and PACKAGIST_TOKEN repository secrets are
set (the packagist.org account name and the API token from its profile page)
and waits until the version is listed; without them, while the package is
not on Packagist, it logs that as a notice and the GitHub release and the
VCS install above are the channels.