smi2/phpclickhouse
PHP client for ClickHouse with sync and async queries, native parameter binding, bulk inserts, generators, gzip compression, cluster support, streaming, and progress callbacks. Requires PHP 8.1+, ext-curl, ext-json.
Check the runtime requirement first. 1.26.610+ requires PHP 8.1 (composer.json now pins php: ^8.1, and the CI matrix drops 8.0). If you are still on PHP 8.0, pin 1.26.4 (the last 8.0-compatible release) — otherwise composer update will fail or silently hold you back on the typed >= 1.24.406 line.
Installation:
composer require smi2/phpclickhouse
Bind the client once (in a service provider, not in controllers):
// app/Providers/ClickHouseServiceProvider.php
public function register(): void
{
$this->app->singleton(\ClickHouseDB\Client::class, fn () => new \ClickHouseDB\Client([
'host' => config('clickhouse.host'),
'port' => config('clickhouse.port'),
'username' => config('clickhouse.username'),
'password' => config('clickhouse.password'),
'database' => config('clickhouse.database'),
]));
}
First Query — note that with the new type wrappers you can pass typed values instead of raw string interpolation:
$users = app(\ClickHouseDB\Client::class)->select(
'SELECT * FROM users WHERE id = :id',
['id' => new \ClickHouseDB\Type\UInt64(1)]
);
return $users->fetchOne();
README.md version-compatibility table (recently corrected — trust it over older blog posts).doc/types.md — the authoritative reference for the new StringableType / ScalarNumericType wrappers and the IEEE 754 notes.tests/Type/Ieee754MantissaTest.php — 31 executable cases that document exactly how floats/decimals round, overflow, and serialize. It is the fastest way to learn the numeric edge cases.The most valuable day-one upgrade is replacing hand-built SQL strings with typed values. Type objects (the legacy base class) are interpolated into SQL unescaped; the new StringableType implementations are escaped by ValueFormatter, by bindings, and by native parameters. That makes the wrappers the default choice for anything originating from a request:
use ClickHouseDB\Type\{StringType, UInt32, DateTime, Enum8};
$client->insert('events', [[
new StringType($request->input('name')), // escaped
new UInt32($request->integer('user_id')), // fromString() validates ^-?\d+$
new DateTime($request->date('at')), // optional non-mutating TZ conversion
new Enum8('click'),
]], ['name', 'user_id', 'at', 'kind']);
Inject the singleton and expose typed methods. Keep the type construction at the boundary so callers cannot accidentally pass a raw string where escaping matters:
class EventRepository
{
public function __construct(private \ClickHouseDB\Client $client) {}
public function byUser(int $userId): iterable
{
return $this->client->selectGenerator(
'SELECT * FROM events WHERE user_id = :id',
['id' => new \ClickHouseDB\Type\UInt32($userId)]
);
}
public function bulkInsert(array $rows): void
{
$this->client->insert('events', $rows, ['name', 'user_id', 'at', 'kind']);
}
}
The new ScalarNumericType family covers Int8–Int256, UInt8–UInt256, Float32/Float64, and Decimal32–Decimal256. Bool intentionally maps to the pre-existing Boolean class rather than introducing a second boolean type.
use ClickHouseDB\Type\{Int64, Float64, Decimal64};
$client->insert('metrics', [[
new Int64($delta),
new Float64($ratio),
new Decimal64('123.4500', 4), // exact — do not use Float64 for money
]], ['delta', 'ratio', 'amount']);
fromString() on these validates input (^-?\d+$ for integers, is_numeric() for floats/decimals), which is what closes the injection vector — use it when hydrating from CSV/queue payloads.
StringType (named that way because String is reserved in PHP).FixedString(N) — enforces strict byte length, not character length.Date, DateTime — DateTime supports an optional timezone conversion that is non-mutating (returns a new instance).Enum8, Enum16.
All implement StringableType, so they are escaped everywhere a value is rendered into SQL.Recent CH 26 tests verify per-query max_execution_time is visible to getSetting() without touching global settings. Prefer this over mutating the client, which leaks across a long-lived queue worker:
$client->select('SELECT * FROM big', [], [
'max_execution_time' => 60,
'stacktrace' => 1,
]);
DatabaseException now exposes getServerVersion() and getServerStackTrace(). The server stack trace is deliberately kept out of getMessage() — you must call the getter, and the trace is only populated when the query is sent with ['stacktrace' => 1]:
try {
$client->select('SELECT * FROM missing', [], ['stacktrace' => 1]);
} catch (\ClickHouseDB\Exception\DatabaseException $e) {
logger()->error('ClickHouse error', [
'message' => $e->getMessage(),
'server' => $e->getServerVersion(),
'serverTrace' => $e->getServerStackTrace(),
]);
}
Do not blanket-enable stacktrace on every query in production — it adds server-side work; enable it per call site or via a config flag you can toggle while debugging.
Bind the client as a singleton, resolve it in handle() (not the constructor) so serialization stays cheap, and use selectGenerator() for large result sets. Keep selectAsync() for fan-out reads where you genuinely iterate the statement; a job that resolves the client in its constructor risks serializing connection state.
Mirror writes into ClickHouse from Eloquent events, but enqueue rather than insert inline for anything user-facing. Constructing typed values in the job — not in the model — keeps HTTP requests fast and puts validation next to the retry logic.
php: ^8.1 also means your CI images and any platform.php config in composer.json must move. Pin 1.26.4 if you cannot upgrade.StringableType values are escaped. Legacy Type objects are still interpolated raw. Audit every place you build a Type from request data — that class has no validation and no escaping.String is a reserved word in PHP. Import ClickHouseDB\Type\StringType; do not alias it to String.0.1 + 0.2 != 0.3 for Float32/Float64. This is asserted in the test suite and documented in the README. Use Decimal* for money and any value where exactness matters.Float32 exact integers up to 2^24, Float64 up to 2^53, BFloat16 up to 2^8 (CH 24.11+ only — do not use it on older servers).Decimal precision trap on read. json_decode() loses precision beyond ~16 significant digits, and Decimal arrives as an unquoted JSON number on every supported server. If you need exact reads of large decimals, fetch them via toString() in the SELECT rather than relying on the decoded PHP value.Decimal truncates toward zero, not with rounding.inf / nan become null in JSON output. Handle null defensively in aggregations.FixedString(N) validates byte length — a 10-character UTF-8 string of multibyte characters will fail or be rejected. Pad/truncate intentionally and assert byte length before constructing.Enum8/Enum16 reject values outside the declared set; the resulting error is a server-side exception, so validate against your own enum list first if you want a clean 422 rather than a 500.getServerVersion() tells you immediately whether you are hitting a schema/feature mismatch (e.g. BFloat16 on a pre-24.11 server) that the raw message does not reveal.getServerStackTrace() returns empty, you forgot ['stacktrace' => 1] on that specific query.setDebug(true) plus getLastQuery() for ad-hoc profiling; use the progress callback for long-running reads rather than guessing from PHP memory usage.selectGenerator() for anything you cannot bound in memory; rows() for small, known-sized result sets.LIMIT/OFFSET loops on large tables — the server still scans. Prefer keyset pagination or selectGenerator().Decimal, BFloat16) varies by server version.tests/Type/Ieee754MantissaTest.php expectations — that suite encodes the rounding and overflow contract your custom types must not violate. If you add a type, implement StringableType (escaped) or ScalarNumericType (validated) rather than extending the legacy Type class, and you inherit the safety guarantees automatically.How can I help you explore Laravel packages today?