Search by

karlomikus / rqlite

karlomikus1

A PHP client for rqlite

Package info

github.com/karlomikus/rqlite-php

pkg:composer/karlomikus/rqlite

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

0.3.0 2026-11-11 18:25 UTC

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.

PHP Version License Packagist Version GitHub Actions Workflow Status

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(), and request() return Results, 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::$time and each query/request result's time are 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 nested foreach loops 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; use getStatusCode(), getHeaderLine(), and getBody() to inspect it.
  • StatementException — rqlite reported an error for a specific statement.
    • $exception->statementIndex is the zero-based statement index.
    • $exception->errorMessage is the raw rqlite message.
    • $exception->sqliteError contains code, extendedCode, and systemErrno when SQLite reported result codes.
  • DecodingException — the response was not valid JSON or had an unexpected shape.
  • RqliteException — a request-level error, for example no 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_index and sequence_number from 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.