1. Install and pin
composer require esazykin/clickhouse-builder
The last tagged release is from December 2017, so before you commit: open composer.json in the package and check its illuminate/support constraint against your Laravel version, and check the PHP constraint against your runtime. If you're on Laravel 8+/PHP 8, expect to either patch a fork or add a maintained alternative behind your own interface (see Gotchas). Treat composer.lock as load-bearing once it works.
2. Register and configure the connection
On Laravel <5.5 (or with package discovery disabled) add the provider and facade alias manually — copy the exact class names from the package's composer.json extra.laravel block rather than guessing. Then publish the config and set the connection:
// config/clickhouse.php (typical shape)
'default' => [
'host' => env('CLICKHOUSE_HOST', '127.0.0.1'),
'port' => env('CLICKHOUSE_PORT', 8123), // 8123 = HTTP, 9000 = native
'database' => env('CLICKHOUSE_DB', 'default'),
'username' => env('CLICKHOUSE_USER', 'default'),
'password' => env('CLICKHOUSE_PASSWORD', ''),
],
ClickHouse runs over its own TCP/HTTP protocol — this is not a MySQL connection, so don't point it at a MySQL driver or reuse your app DB credentials.
3. First query — where to look first
Open the package's src/ and read the builder class top to bottom: the public method list is your API surface, and the grammar/compiler class is where SQL is assembled. That ten-minute read tells you exactly which clauses are supported.
use ClickHouse;
$rows = ClickHouse::select('event', 'count() as total')
->from('events')
->where('date', '>=', '2024-01-01')
->groupBy('event')
->orderBy('total', 'desc')
->limit(10)
->get();
Best first use case: a read-only reporting endpoint. It exercises select/where/groupBy/orderBy/limit, gives you immediate feedback, and can't corrupt anything.
Wrap it in a repository, not in controllers. Return plain arrays or collections — there is no Eloquent model here, no relations, no lazy loading. One class per domain query keeps the ClickHouse dialect (and the abandoned dependency) out of your application code.
final class EventReportRepository
{
public function topEvents(Carbon $from, int $limit = 10): array
{
return ClickHouse::from('events')
->where('date', '>=', $from->toDateString())
->select('event', 'count() as total')
->groupBy('event')
->orderBy('total', 'desc')
->limit($limit)
->get();
}
}
Push aggregation into ClickHouse, not PHP. The win over MySQL is the function library; use raw selects for it:
->selectRaw('countIf(status >= 500) as errors')
->selectRaw('quantile(0.95)(duration_ms) as p95')
->selectRaw('toStartOfHour(created_at) as bucket')
Build filters dynamically, but whitelist columns. A filter map keeps queries readable; never let a request key become a column name.
$query = ClickHouse::from('events');
$allowed = ['event', 'country', 'status'];
foreach ($filters as $column => $value) {
if (in_array($column, $allowed, true)) {
$query->where($column, $value);
}
}
Log the SQL before you run it. toSql() / bindings are your only cheap debugger; expensive ClickHouse queries are expensive to discover by accident in production.
Batch inserts, chunked. Rows must be uniform assoc arrays with identical keys; format DateTime as 'Y-m-d H:i:s' strings, not objects. Chunk to roughly 10k–100k rows per call and insert against a Buffer or Null-engine table when ingestion volume spikes.
foreach (array_chunk($rows, 50000) as $chunk) {
ClickHouse::table('events')->insert($chunk);
}
Hydrate Eloquent from IDs, not joins. Query ClickHouse for aggregate/ID results, then User::whereIn('id', $ids) in MySQL. Cross-store joins are fragile and ClickHouse's join semantics are not MySQL's.
Keep DDL in versioned .sql files plus an Artisan bootstrap command (createTable, dropTable, truncate exist) rather than in migrations. ClickHouse has no transactions, so "rollback" is a lie you don't want in a migration.
composer update surprise you.selectRaw/whereRaw concatenate strings — there are no placeholder bindings for raw fragments. Interpolating user input is a SQL injection. Keep all raw fragments as literals in your code.ALTER TABLE ... UPDATE/DELETE is an asynchronous mutation, not an interactive statement; don't build UI flows that assume read-after-write.(int), (float)) at the repository boundary instead of sprinkling casts in views.FINAL sparingly — it's correct but slow.ORDER BY and does not enforce uniqueness. Dedupe before insert, or use ReplacingMergeTree and accept that deduplication happens at merge time.max_execution_time / max_memory_usage per-query where the builder exposes settings, and set the HTTP client timeout higher than your slowest report. Long LIMIT n OFFSET m scans are painful — paginate by keyset instead.toSql() and inspect the bindings array together; mismatch between ? count and bindings is the classic silent failure in this style of builder.config/clickhouse.php, run php artisan config:cache or clear it — a stale cache looks exactly like a wrong host.ClickHouse, you'll get confusing resolution. Alias it explicitly in config/app.php.clickhouse-server and assert on toSql() output for unit tests. Anything else is testing your mock.PREWHERE/SAMPLE; the grammar class is where the SQL actually gets emitted.How can I help you explore Laravel packages today?