karlomikus / rqlite
A PHP client for rqlite
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- fakerphp/faker: ^1.23
- guzzlehttp/guzzle: ^7.0
- mockery/mockery: ^1.6
- pestphp/pest: ^3.5
- phpstan/phpstan: ^2.2
- rector/rector: ^2.8
- symfony/var-dumper: ^7.1
Suggests
- guzzlehttp/guzzle: Enables Rqlite::withGuzzle() with a PSR-18 client and PSR-17 factories
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-11 18:26:15 UTC
README
A PHP client for rqlite, the lightweight distributed database built on SQLite. It supports streaming associative results, typed result objects, query/execute/request options, and structured rqlite errors.
This library is still in development. The API will change before a stable release. I recommend that you use specific version constraints in your composer.json rather than ^0.2 or dev-main.
Requirements
- PHP 8.2, 8.3, 8.4 or 8.5
- A PSR-18 HTTP client with PSR-17 factories (Guzzle 7+ ships both)
Installation
composer require karlomikus/rqlite guzzlehttp/guzzle
Quick start
<?php use Kami\Rqlite\Rqlite; $rqlite = Rqlite::withGuzzle('http://localhost:4001'); $results = $rqlite->query([ 'SELECT * FROM foo', ]); // Results and rows are streamed as generators. foreach ($results as $result) { foreach ($result->rows as $row) { var_dump($row); } } // The top-level time is populated after the stream is fully consumed. var_dump($results->time);
Supported features
| Area | Support |
|---|---|
| Endpoints | /db/query, /db/execute, /db/request, /status |
| Query results | Associative form only: types map and rows objects |
| Parameters | Positional and named parameters, forwarded as-is |
| Transactions | Execute and unified request endpoints |
| Timings | Query, execute, and request endpoints |
| Read consistency | none, weak, strong, linearizable, auto |
| Query options | See Query options |
| Execute options | See Execute options |
| Request options | See Unified request endpoint |
| Error formats | Legacy error and structured error_v2 |
| Streaming | Response bodies, results, and query/request rows |
Results are always requested in associative form. The standard rqlite
columns / values form is not currently exposed.
Statements and parameters
Each statement is an array containing SQL followed by either positional parameters or a single associative array of named parameters:
// Positional parameters $rqlite->query([ ['SELECT id, name FROM users WHERE id = ?', 1], ]); // Named parameters $rqlite->query([ ['SELECT id, name FROM users WHERE name = :name', ['name' => 'fiona']], ]);
Parameter arrays are forwarded to rqlite as-is. Use a list for positional parameters and an associative array for named parameters.
Query options
query() accepts a QueryOptions object. Options with a false or null
value are omitted from the request. Durations accept duration strings such as
500ms or 2s, or a number of seconds; numeric zero is treated as unset.
use Kami\Rqlite\ErrorFormat; use Kami\Rqlite\QueryOptions; use Kami\Rqlite\ReadConsistencyLevel; $results = $rqlite->query( ['SELECT * FROM foo'], new QueryOptions( timings: true, pretty: true, readConsistencyLevel: ReadConsistencyLevel::Strong, timeout: '2s', raftIndex: true, errors: ErrorFormat::V2, ), );
| Option | Wire parameter | Default | Description |
|---|---|---|---|
timings |
timings |
false |
Include statement timings |
pretty |
pretty |
false |
Request pretty-printed JSON |
blobAsArray |
blob_array |
false |
Return BLOBs as arrays of byte values |
readConsistencyLevel |
level |
null (omitted) |
ReadConsistencyLevel enum value |
timeout |
timeout |
null |
Request-forwarding timeout |
linearizableTimeout |
linearizable_timeout |
null |
Linearizable read timeout |
freshness |
freshness |
null |
Read freshness duration |
freshnessStrict |
freshness_strict |
false |
Require the requested freshness |
raftIndex |
raft_index |
false |
Ask rqlite to include the Raft index |
errors |
errors |
null |
ErrorFormat::V1 or ErrorFormat::V2 |
associative is always sent as true and is not configurable.
Execute options
execute() accepts an ExecuteOptions object:
use Kami\Rqlite\ExecuteOptions; $results = $rqlite->execute( [ ['INSERT INTO foo (name) VALUES (?)', 'fiona'], ['INSERT INTO foo (name) VALUES (?)', 'bob'], ], new ExecuteOptions( transaction: true, timings: true, timeout: '5s', ), );
| Option | Wire parameter | Default | Description |
|---|---|---|---|
transaction |
transaction |
false |
Wrap all statements in a transaction |
pretty |
pretty |
false |
Request pretty-printed JSON |
timings |
timings |
false |
Include statement timings |
queue |
queue |
false |
Queue the write |
wait |
wait |
false |
Wait for a queued write to be applied |
timeout |
timeout |
null |
Request-forwarding timeout |
dbTimeout |
db_timeout |
null |
Per-statement SQLite timeout |
raftIndex |
raft_index |
false |
Ask rqlite to include the Raft index |
errors |
errors |
null |
ErrorFormat::V1 or ErrorFormat::V2 |
wait only has an effect when queue is enabled.
Unified request endpoint
request() sends read and write statements through /db/request:
use Kami\Rqlite\RequestOptions; $results = $rqlite->request( [ ['INSERT INTO foo (name) VALUES (?)', 'fiona'], ['SELECT id, name FROM foo WHERE name = ?', 'fiona'], ], new RequestOptions(transaction: true), ); foreach ($results as $result) { // Read statements populate rows; write statements leave this empty. foreach ($result->rows as $row) { var_dump($row); } // Write statements populate these; read statements leave them null. var_dump($result->lastInsertId, $result->rowsAffected); }
| Option | Wire parameter | Default |
|---|---|---|
transaction |
transaction |
false |
pretty |
pretty |
false |
timings |
timings |
false |
blobAsArray |
blob_array |
false |
readConsistencyLevel |
level |
null (omitted) |
timeout |
timeout |
null |
linearizableTimeout |
linearizable_timeout |
null |
freshness |
freshness |
null |
freshnessStrict |
freshness_strict |
false |
raftIndex |
raft_index |
false |
errors |
errors |
null |
Queued writes (queue / wait) are not supported by /db/request.
Results are always associative. associative is not configurable.
Status
$status = $rqlite->status();
status() returns the decoded /status response as an array.
Result types
| Method | Result object | Fields |
|---|---|---|
query() |
AssociativeQueryResult |
types, rows generator, time |
execute() |
ExecuteResult |
lastInsertId, rowsAffected, time |
request() |
RequestResult |
types, rows generator, lastInsertId, rowsAffected, time |
Results is a single-pass iterator over result objects and exposes the
top-level time after the stream is fully consumed.
Typed rows
Results and the associative result classes are generic, so PHPStan or another
tool that understands generics can narrow rows to your own array shape:
use Kami\Rqlite\Result\AssociativeQueryResult; use Kami\Rqlite\Result\Results; /** @var Results<AssociativeQueryResult<array{id: int, name: string}>> $results */ $results = $rqlite->query([ 'SELECT id, name FROM foo', ]); foreach ($results as $result) { foreach ($result->rows as $row) { // $row is array{id: int, name: string} } }
When no type is specified, rows default to array<string, mixed>.
RequestResult supports the same row generics.
Streaming
Response bodies are parsed incrementally, so large result sets do not need to be loaded into memory in full.
query(),execute(), andrequest()returnResults, a single-pass iterator.- Query and request results and their rows are generators. Consume a result's rows before advancing to the next result.
- The top-level
Results::$timeand each query/request result'stimeare populated after the corresponding stream has been consumed. - Do not collect query or request results with
iterator_to_array($results)without consuming their rows first. Iterate with nestedforeachloops instead.
PSR-7 interop
backup() returns the original response's Psr\Http\Message\StreamInterface.
The caller is responsible for consuming and closing it.
use Kami\Rqlite\BackupOptions; $body = $rqlite->backup(new BackupOptions(format: 'sql')); file_put_contents('backup.sql', $body->getContents()); $body->close();
The read side works too: load() and boot() accept StreamInterface in
addition to stream resources and strings. Upload streams and resources are
consumed and ownership is transferred; do not use them afterwards. A PSR-7
stream is detached when possible.
use GuzzleHttp\Psr7\Utils; $rqlite->load(Utils::streamFor(fopen('backup.sql', 'rb')));
HTTP client
PSR-18
Rqlite::withGuzzle($baseUrl) uses Guzzle's HTTP client and PSR-17 factories.
Its default client enables response streaming. To configure Guzzle, pass a
client as the second argument (set stream yourself if needed):
use GuzzleHttp\Client; use Kami\Rqlite\Rqlite; $rqlite = Rqlite::withGuzzle('http://localhost:4001', new Client([ 'stream' => true, 'timeout' => 10, ]));
Guzzle is optional. Alternatively, inject any PSR-18 client and PSR-17 request and stream factories directly:
use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Kami\Rqlite\Rqlite; $factory = new HttpFactory(); $rqlite = new Rqlite( 'http://localhost:4001', new Client([ // Configure auth, TLS, timeouts, or a proxy here. ]), $factory, $factory, );
Works with any PSR-18 client and PSR-17 request and stream factories (Guzzle
7+ ships both, and GuzzleHttp\Psr7\HttpFactory implements both factories).
The base URL must be an absolute HTTP or HTTPS URL without a query or fragment;
an optional path prefix is retained. Configure auth, TLS, and timeouts on the
injected client.
Redirect handling is left to the client; Guzzle's PSR-18 sendRequest()
implementation does not follow redirects and returns non-2xx responses
instead of throwing, as the PSR-18 spec requires. Response bodies are streamed
incrementally when the client supports it.
PSR-3 logger wrapper
use Kami\Rqlite\Adapters\LoggerAdapter; $client = new LoggerAdapter(new Client(['stream' => true]), $logger); $rqlite = Rqlite::withGuzzle('http://localhost:4001', $client);
LoggerAdapter is a PSR-18 decorator. It logs the request method and URI at
debug level, plus the response status and duration. URI user info, query and
fragment are omitted; bodies and headers are never read or logged. Transport
errors are logged by exception type and rethrown. URI paths may still contain
sensitive information.
Any PSR-18 client or decorator can be injected directly. Response bodies must be readable PSR-7 streams for incremental parsing.
Error handling
All client exceptions extend Kami\Rqlite\Exception\RqliteException:
TransportException— the request could not be completed, such as a connection failure or timeout.HttpException— rqlite returned a non-2xx response. The original PSR-7 response is available as$exception->response; usegetStatusCode(),getHeaderLine(), andgetBody()to inspect it.StatementException— rqlite reported an error for a specific statement.$exception->statementIndexis the zero-based statement index.$exception->errorMessageis the raw rqlite message.$exception->sqliteErrorcontainscode,extendedCode, andsystemErrnowhen SQLite reported result codes.
DecodingException— the response was not valid JSON or had an unexpected shape.RqliteException— a request-level error, for exampleno leader.
When migrating from Http\Response, replace statusCode, header($name),
and text() with getStatusCode(), getHeaderLine($name), and
getBody()->getContents(). Unlike header(), getHeaderLine() returns an
empty string when the header is absent.
Both the legacy error field and the structured error_v2 field introduced in
rqlite v10.6.0 are supported. When both are present, error_v2 is preferred.
The client throws on the first statement-level error instead of returning
per-statement error metadata. Use the errors option to control which error
fields rqlite renders; the client can decode either form.
Because responses are streamed, decoding and statement errors may be raised
while iterating Results or row generators rather than when query(),
execute(), or request() is called. HTTP and transport errors are raised
immediately.
Handling large numbers
Integers larger than PHP_INT_MAX are decoded as strings to avoid precision
loss. For example, a BIGINT value may come back as "9223372036854775808"
rather than a float. Convert it to the type your schema expects.
Not currently supported
The following rqlite features are not implemented yet:
- Non-associative query and request results (
columns/types/values) /debug/vars,/nodes,/readyz,/remove, and version lookup- Connection retry and load balancing across multiple nodes
- Single-statement helpers
- Queued writes on
/db/request - Exposing
raft_indexandsequence_numberfrom responses - Per-statement error inspection without throwing
Development
A minimal PHP CLI image with Composer is included for local development:
docker compose build docker compose run --rm php composer install docker compose run --rm php vendor/bin/pest
Unit tests are run by default. Integration tests are excluded from the default run and execute against rqlite. Most integration tests only need a single node:
docker compose up -d --wait myrqlite-service-1 docker compose run --rm php vendor/bin/pest --group=integration --exclude-group=cluster
The cluster test additionally verifies replication across all three nodes:
docker compose up -d --wait myrqlite-service-1 myrqlite-service-2 myrqlite-service-3 docker compose run --rm php vendor/bin/pest --group=integration
The integration tests use the compose service hostnames. When running Pest outside Docker, point it at the published ports instead:
RQLITE_URL=http://localhost:4001 \
RQLITE_URL_2=http://localhost:4011 \
RQLITE_URL_3=http://localhost:4021 \
vendor/bin/pest --group=integration
# Single node only
RQLITE_URL=http://localhost:4001 \
vendor/bin/pest --group=integration --exclude-group=cluster
CI runs the single-node integration suite against an rqlite service container.