oralunal / laravel-clickhouse
Adapter of the most popular library https://github.com/smi2/phpClickHouse to Laravel
Requires
- php: ^8.5
- laravel/framework: ^13
- smi2/phpclickhouse: ^1.26
Requires (Dev)
- laravel/boost: ^2.0
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
Replaces
None
README
laravel-clickhouse
Laravel adapter for ClickHouse, built on smi2/phpClickHouse for HTTP transport and query execution. Apart from Laravel, that is the only dependency. The query builder, the schema builder and the enum base class it uses are bundled under this package's namespace; see Credits.
Features
- Eloquent-flavored
BaseModel(create,save,insertBulk,insertAssoc,where, pagination) Oralunal\LaravelClickHouse\Migrationbase class for ClickHouse DDL migrations (single-node and cluster)php artisan schema:dump [--prune]support for squashing migrations into a schema filephp artisan clickhouse:install-skillsinstalls coding-agent skills:/lc-upgrade-2x-to-3xfor the 2.x → 3.x upgrade and/plc-upgrade-1x-to-2xfor 1.x → 2.x- Query builder integration with
settings(),chunk(), and ClickHouse-specific grammar - Column casts (currently
boolean) applied on insert - Model events:
creating,created,saved - Retry-on-network-error support (
retriesconfig key) - Buffer engine support via
$tableForInserts/$tableSources - In-memory buffered inserts: accumulate rows with
Model::buffer()and send them as a single HTTP request withModel::flushBuffer()(auto-flushed on script shutdown) OPTIMIZE,TRUNCATE,ALTER TABLE ... DELETE,ALTER TABLE ... UPDATEhelpers- Multi-instance and cluster-mode connections with active-node rotation
- Publishable default config —
.envis enough for most setups
Underneath, smi2/phpClickHouse handles HTTP transport (curl-only, no PDO). More: https://github.com/smi2/phpClickHouse#features
Prerequisites
- PHP 8.5+
- Laravel 13+
- ClickHouse server 24.x (older 20+ versions usually work but are no longer tested)
Installation
Upgrading from 2.x? Until 2.0.2 the package was published as
oralunal/phpclickhouse-laravel, with the PhpClickHouseLaravel namespace.
Switch the package and install the coding-agent skills:
composer remove oralunal/phpclickhouse-laravel --no-update composer require oralunal/laravel-clickhouse:^3.0 --with-all-dependencies php artisan clickhouse:install-skills
Then run /lc-upgrade-2x-to-3x in your coding agent. On 1.x, run
/plc-upgrade-1x-to-2x before it. To upgrade by hand, see UPGRADE.md.
1. Install via composer:
composer require oralunal/laravel-clickhouse
The service provider is registered automatically via Laravel package
auto-discovery. If you have auto-discovery disabled, add
Oralunal\LaravelClickHouse\ClickhouseServiceProvider::class to
bootstrap/providers.php (Laravel 11+) or config/app.php (Laravel 10 and below).
2. Configure the connection.
The simplest setup — just set these in your .env:
CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT=8123 CLICKHOUSE_DATABASE=default CLICKHOUSE_USERNAME=default CLICKHOUSE_PASSWORD= # only if you use an https connection CLICKHOUSE_HTTPS=true
The service provider merges sensible defaults into
config('database.connections.clickhouse') for you. No config edits needed
for a single-node setup.
If you want to customize defaults beyond what env vars cover, publish the config:
php artisan vendor:publish --tag=clickhouse-config
That drops a config/clickhouse.php into your app. Values you set there
override the packaged defaults, and you can add further connections to the
same file. Alternatively, you can define the connection yourself in
config/database.php, which outranks both:
'clickhouse' => [ 'driver' => 'clickhouse', 'host' => env('CLICKHOUSE_HOST'), 'port' => env('CLICKHOUSE_PORT', '8123'), 'database' => env('CLICKHOUSE_DATABASE', 'default'), 'username' => env('CLICKHOUSE_USERNAME', 'default'), 'password' => env('CLICKHOUSE_PASSWORD', ''), 'timeout_connect' => env('CLICKHOUSE_TIMEOUT_CONNECT', 2), 'timeout_query' => env('CLICKHOUSE_TIMEOUT_QUERY', 2), 'https' => (bool) env('CLICKHOUSE_HTTPS', null), 'retries' => env('CLICKHOUSE_RETRIES', 0), 'settings' => [ // optional 'max_partitions_per_insert_block' => 300, ], 'fix_default_query_builder' => true, ],
Usage
You can use smi2/phpClickHouse directly:
/** @var \ClickHouseDB\Client $db */ $db = DB::connection('clickhouse')->getClient(); $statement = $db->select('SELECT * FROM summing_url_views LIMIT 2');
More about $db: https://github.com/smi2/phpClickHouse/blob/master/README.md
Or use the Eloquent-like ORM
1. Add a model:
<?php namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable extends BaseModel { // Optional. Derived from class name when omitted: MyTable => my_tables. protected $table = 'my_table'; }
2. Add a migration:
<?php class CreateMyTable extends \Oralunal\LaravelClickHouse\Migration { public function up() { static::write(' CREATE TABLE my_table ( id UInt32, created_at DateTime, field_one String, field_two Int32 ) ENGINE = MergeTree() ORDER BY (id) '); } public function down() { static::write('DROP TABLE my_table'); } }
Or use the Schema Builder:
<?php use Oralunal\LaravelClickHouse\ClickhouseSchemaBuilder\Expression; use Oralunal\LaravelClickHouse\ClickhouseSchemaBuilder\Tables\MergeTree; class CreateMyTable extends \Oralunal\LaravelClickHouse\Migration { public function up() { static::createMergeTree('my_table', fn(MergeTree $table) => $table ->columns([ $table->uInt32('id'), $table->datetime('created_at', 3)->default(new Expression('now64()')), $table->string('field_one'), $table->int32('field_two'), ]) ->orderBy('id') ); } public function down() { static::write('DROP TABLE my_table'); } }
For another engine of the MergeTree family, such as ReplacingMergeTree,
pass its name and parameters to engine():
use Oralunal\LaravelClickHouse\ClickhouseSchemaBuilder\Engine; static::createMergeTree('my_table', fn(MergeTree $table) => $table ->columns([ $table->uInt32('id'), $table->uInt64('version'), ]) ->orderBy('id') ->engine(Engine::REPLACING_MERGE_TREE, 'version') );
On a cluster connection the engine becomes
ReplicatedReplacingMergeTree. Read the deduplicated rows with
MyTable::select()->final(), or merge them with MyTable::optimize(true).
3. Insert data.
One row:
$model = MyTable::create(['model_name' => 'model 1', 'some_param' => 1]); # or $model = MyTable::make(['model_name' => 'model 1']); $model->some_param = 1; $model->save(); # or $model = new MyTable(); $model->fill(['model_name' => 'model 1', 'some_param' => 1])->save();
Bulk insert:
# Non-assoc MyTable::insertBulk([['model 1', 1], ['model 2', 2]], ['model_name', 'some_param']); # Assoc MyTable::insertAssoc([['model_name' => 'model 1', 'some_param' => 1], ['some_param' => 2, 'model_name' => 'model 2']]);
4. Query builder:
$rows = MyTable::select(['field_one', new RawColumn('sum(field_two)', 'field_two_sum')]) ->where('created_at', '>', '2020-09-14 12:47:29') ->groupBy('field_one') ->settings(['max_threads' => 3]) ->getRows();
Known issues
Some of the problems are described here.
Advanced usage
Columns casting
Before insertion, the column is converted to the data type specified in
$casts. This only applies to inserts, not selects. Supported: boolean.
Casts apply to insertAssoc() / buffer() by column name, and to
insertBulk() by matching $casts keys against the $columns list you pass.
namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable extends BaseModel { /** * The columns that should be cast. * * @var array */ protected $casts = ['some_bool_column' => 'boolean']; } // Then you can insert the data like this: MyTable::insertAssoc([ ['some_param' => 1, 'some_bool_column' => false], ]);
Events
Events are dispatched under the same names as Eloquent model events, but only a subset is fired, and which ones depends on how you insert:
| Call | Events fired |
|---|---|
MyTable::create([...]) |
creating, saved, created |
MyTable::make([...])->save() |
saved |
Returning false from a creating listener cancels create(). save() does
not fire creating, so it cannot be cancelled that way. Observers and the
$dispatchesEvents map are Eloquent-only and are not supported.
Retries
You can retry requests on non-200 responses (e.g., transient network errors).
In .env:
CLICKHOUSE_RETRIES=2
retries is optional; default is 0 (a single attempt, no retries). 1
means one attempt + one retry on error (two total).
Working with huge rows
Chunk results like in Laravel:
// Split the result into chunks of 30 rows $rows = MyTable::select(['field_one', 'field_two']) ->chunk(30, function ($rows) { foreach ($rows as $row) { echo $row['field_two'] . "\n"; } });
Buffer engine for insert queries
See https://clickhouse.tech/docs/en/engines/table-engines/special/buffer/
<?php namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable extends BaseModel { // Optional; derived from class name when omitted. protected $table = 'my_table'; // All inserts go to $tableForInserts, selects read from $table. protected $tableForInserts = 'my_table_buffer'; }
If you also want to read from the buffer table, set its name as $table:
<?php namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable extends BaseModel { protected $table = 'my_table_buffer'; }
In-memory buffered inserts
Different from the Buffer table engine above — this is a process-local row buffer kept in PHP memory. Useful when you want to coalesce many small writes into a single HTTP request without setting up a Buffer table on the ClickHouse side.
MyTable::buffer(['model_name' => 'model 1', 'some_param' => 1]); MyTable::buffer(['model_name' => 'model 2', 'some_param' => 2]); // ... add as many as you like, possibly from different code paths ... MyTable::flushBuffer(); // single insertAssocBulk HTTP request
buffer() accepts either a single associative row or an array of rows:
MyTable::buffer([ ['model_name' => 'model 1', 'some_param' => 1], ['model_name' => 'model 2', 'some_param' => 2], ]);
The cast pipeline used by insertAssoc() is applied at buffer time, so
$casts keeps working for buffered rows too.
If you forget to call flushBuffer(), the package flushes every model's
buffer automatically at script shutdown (via Laravel's
Application::terminating() hook plus a register_shutdown_function
fallback for non-HTTP scripts). Errors during auto-flush are logged via
report() rather than thrown, since the response has typically already
been sent.
If a manual flushBuffer() call fails (network error, schema mismatch,
etc.), the exception bubbles up and the buffer is preserved so you
can retry:
try { MyTable::flushBuffer(); } catch (\Throwable $e) { // rows are still in MyTable::getBufferedRows() — fix the issue and retry }
Each model class has its own buffer keyed by class name, so different models can buffer concurrently without interfering. Available helpers:
| Method | Purpose |
|---|---|
MyTable::buffer($rowOrRows) |
Append a row (or rows) to the buffer |
MyTable::flushBuffer() |
Send the buffer; returns Statement or null if empty |
MyTable::bufferCount() |
Number of rows currently buffered for this model |
MyTable::getBufferedRows() |
Snapshot of buffered rows (debug / inspection) |
MyTable::clearBuffer() |
Discard the buffer without sending |
BaseModel::flushAllBuffers(silent: false) |
Flush every model that has buffered rows |
OPTIMIZE Statement
See https://clickhouse.com/docs/en/sql-reference/statements/optimize/
MyTable::optimize($final = false, $partition = null);
TRUNCATE Statement
Remove all data from a table:
MyTable::truncate();
Deletions
See https://clickhouse.com/docs/en/sql-reference/statements/alter/delete/
MyTable::where('field_one', 123)->delete();
Using the buffer engine with OPTIMIZE / ALTER TABLE DELETE:
<?php namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable extends BaseModel { // SELECT and INSERT on $table protected $table = 'my_table_buffer'; // OPTIMIZE, TRUNCATE, and where()->update() / where()->delete() on $tableSources protected $tableSources = 'my_table'; }
Updates
See https://clickhouse.com/docs/en/sql-reference/statements/alter/update/
MyTable::where('field_one', 123)->update(['field_two' => 'new_val']); // or an expression MyTable::where('field_one', 123) ->update(['field_two' => new RawColumn("concat(field_two,'new_val')")]);
Helpers for inserting different data types
// Array data type MyTable::insertAssoc([ ['id' => 1, 'field_one' => 'str', 'field_array' => new InsertArray(['a', 'b'])], ]);
insertAssoc() takes column => value rows. For positional rows, use
insertBulk() with an explicit column list:
MyTable::insertBulk([[1, 'str', new InsertArray(['a', 'b'])]], ['id', 'field_one', 'field_array']);
Working with multiple ClickHouse instances in a project
config/clickhouse.php is a map from connection name to connection
config. The service provider merges every entry into
config('database.connections.<name>'), so you can declare additional
ClickHouse connections alongside the default one in a single file.
1. Publish the config if you haven't already:
php artisan vendor:publish --tag=clickhouse-config
Then add a second connection in config/clickhouse.php:
return [ 'clickhouse' => [ // ... default connection ], 'clickhouse2' => [ 'driver' => 'clickhouse', 'host' => env('CLICKHOUSE2_HOST', '127.0.0.1'), 'port' => env('CLICKHOUSE2_PORT', '8123'), 'database' => 'default', 'username' => 'default', 'password' => '', 'timeout_connect' => 2, 'timeout_query' => 2, 'https' => false, 'retries' => 0, 'fix_default_query_builder' => true, ], ];
Precedence, highest first: config/database.php's connections array, then
your published config/clickhouse.php, then the packaged defaults. So adding
the same shape to config/database.php works too, and overrides both.
2. Add a model pointing at it:
<?php namespace App\Models\Clickhouse; use Oralunal\LaravelClickHouse\BaseModel; class MyTable2 extends BaseModel { protected $connection = 'clickhouse2'; protected $table = 'my_table2'; }
3. Add a migration bound to that connection:
<?php return new class extends \Oralunal\LaravelClickHouse\Migration { protected $connection = 'clickhouse2'; public function up() { static::write('CREATE TABLE my_table2 ...'); } public function down() { static::write('DROP TABLE my_table2'); } };
Cluster mode
Important!
- Each ClickHouse node must share the same database name, username, and password.
- Reads and writes go to the first reachable node.
- Migrations execute on all nodes. If any node is unreachable, the migration throws.
ReplicatedMergeTreeuses the{replica}and{shard}macros — those must be defined on each ClickHouse server (inconfig.xmlorconfig.d/*.xml), not in this package. Example config:<macros> <shard>01</shard> <replica>clickhouse01</replica> </macros>
Seetests/docker/clickhouse01/config.xmlin this repo for a working example, or the ClickHouse docs: https://clickhouse.com/docs/en/operations/settings/settings#server_settings-macros
Your config/database.php should look like:
'clickhouse' => [ 'driver' => 'clickhouse', 'cluster' => [ [ 'host' => 'clickhouse01', 'port' => '8123', ], [ 'host' => 'clickhouse02', 'port' => '8123', ], ], // Optional. When set, Migration::createMergeTree() adds // ON CLUSTER '<name>' to the DDL it compiles. It must match a cluster // declared in your ClickHouse server config (remote_servers). // Without it the table is still created on every node, because // migrations are dispatched to each node in turn. // If you set it, also call ->ifNotExists() in createMergeTree() — see below. 'cluster_name' => 'company_cluster', 'database' => env('CLICKHOUSE_DATABASE', 'default'), 'username' => env('CLICKHOUSE_USERNAME', 'default'), 'password' => env('CLICKHOUSE_PASSWORD', ''), 'timeout_connect' => env('CLICKHOUSE_TIMEOUT_CONNECT', 2), 'timeout_query' => env('CLICKHOUSE_TIMEOUT_QUERY', 2), 'https' => (bool) env('CLICKHOUSE_HTTPS', null), 'retries' => env('CLICKHOUSE_RETRIES', 0), 'settings' => [ // optional 'max_partitions_per_insert_block' => 300, ], 'fix_default_query_builder' => true, ],
With cluster_name set, add ->ifNotExists() to createMergeTree():
static::createMergeTree('my_table', fn(MergeTree $table) => $table ->ifNotExists() ->columns([...]) ->orderBy('id') );
Migrations are dispatched to each node in turn, and ON CLUSTER already
creates the table on every node from the first dispatch — so without
IF NOT EXISTS the second node's identical statement fails with
TABLE_ALREADY_EXISTS.
Migration:
<?php return new class extends \Oralunal\LaravelClickHouse\Migration { public function up() { static::write(" CREATE TABLE my_table ( id UInt32, created_at DateTime, field_one String, field_two Int32 ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/default.my_table', '{replica}') ORDER BY (id) "); } public function down() { static::write('DROP TABLE my_table'); } };
You can read the current node and rotate to the next:
$row = new MyTable(); echo $row->getThisClient()->getConnectHost(); // will print 'clickhouse01' $row->resolveConnection()->getCluster()->slideNode(); echo $row->getThisClient()->getConnectHost(); // will print 'clickhouse02'
Squashing migrations with schema:dump
Laravel's built-in schema:dump command works on ClickHouse connections.
You don't need a separate command or the clickhouse-client binary:
php artisan schema:dump --database=clickhouse
# Dump the schema and delete all existing migration files
php artisan schema:dump --database=clickhouse --prune
This writes database/schema/clickhouse-schema.sql. The file has one
CREATE statement for each table, dictionary, view and materialized view in
the connection's database, ordered so that each object comes after the ones it
reads from. If the migrations table is on this connection, its rows are
appended at the end; pass --without-migration-data to leave them out.
References written as <database>.<table> lose the database prefix when they
point at the connection's own database, so you can load the file into a
database with a different name. Engine arguments that name the database as a
separate string, such as Buffer('analytics', 'events', ...), are left as they
are.
When php artisan migrate runs against a ClickHouse database where no
migrations have run yet, it loads the dump first. After that, it runs only the
migrations created after the dump. On a cluster connection, the statements
are sent to every node, the same way migrations are.
--prune is Laravel's own behavior. It deletes the whole database/migrations
directory, including migrations for your other connections.
Which connection holds the migrations table
Laravel's migrate, migrate:fresh and schema:dump act on one connection:
the default one, or the one you pass with --database. That connection also
holds the migrations table. In this section it is called the primary
connection. The package also works when ClickHouse is not the primary.
ClickHouse is the primary connection. For example, DB_CONNECTION=clickhouse,
or --database=analytics for a ClickHouse connection with another name. All
commands work directly:
php artisan migrate --database=analytics php artisan schema:dump --database=analytics --prune php artisan migrate:fresh --database=analytics
If your ClickHouse connection has a name other than clickhouse, set it on the
migrations: protected $connection = 'analytics';.
ClickHouse is a secondary connection. For example, MySQL is the default connection and some migrations write to ClickHouse. A ClickHouse connection counts as secondary when one of these is true:
- a migration in
database/migrations, or in a path registered withloadMigrationsFrom(), sets$connectionto it; database/schemahas a dump for it.
Secondary connections follow the primary one:
| Command | What happens to secondary ClickHouse connections |
|---|---|
php artisan schema:dump [--prune] |
Each one is dumped to database/schema/<connection>-schema.sql, next to the primary dump. |
php artisan migrate (primary dump gets loaded) |
An empty one is loaded from its dump. If it has no dump, its migrations are run again. A connection that still holds tables is left as it is. |
php artisan migrate:fresh |
Each one is emptied too. It is then rebuilt from its dump if the primary dump gets loaded, otherwise by running the migrations again. |
ClickHouse connections that no migration or dump refers to are never emptied or loaded.
migrate:fresh and db:wipe drop every table, materialized view, view and
dictionary in the ClickHouse database, in an order ClickHouse accepts, on every
node. Views are dropped even without --drop-views: the dump and the
migrations recreate them, and that would fail while they still exist.
Parallel tests. With php artisan test --parallel or pest --parallel,
each test process gets its own database on every secondary ClickHouse
connection, the way Laravel does it for the default connection. The database
is named <database>_test_<token>, for example analytics_test_1. A process
creates it for its first test case that uses RefreshDatabase,
LazilyRefreshDatabase, DatabaseMigrations, DatabaseTransactions or
DatabaseTruncation, and all of that process's test cases use it. So the
migrate:fresh of one process never drops the tables of another.
--recreate-databasesdrops these databases before the run, and--drop-databasesdrops them after it.--without-databaseskeeps the configured database.- When ClickHouse is the primary connection, Laravel itself switches it to
<database>_test_<token>.
The ClickHouse user needs permission to create and drop databases. Test runs
without --parallel use the configured database.
Credits
This package bundles code from these MIT-licensed projects. Each bundled directory keeps the original license next to the code.
| Bundled as | Origin | License |
|---|---|---|
Oralunal\LaravelClickHouse\ClickhouseBuilder (src/ClickhouseBuilder) |
the-tinderbox/ClickhouseBuilder, via the glushkovds and oralunal forks (v1.0.0) | src/ClickhouseBuilder/LICENSE |
Oralunal\LaravelClickHouse\ClickhouseSchemaBuilder (src/ClickhouseSchemaBuilder) |
glushkovds/php-clickhouse-schema-builder v1.1.1 by Denis Glushkov | src/ClickhouseSchemaBuilder/LICENSE |
Oralunal\LaravelClickHouse\Enum\Enum (src/Enum) |
myclabs/php-enum 1.8.5 by My C-Labs | src/Enum/LICENSE |
The package itself is a fork of glushkovds/phpclickhouse-laravel.
Contributing
The package is developed against Orchestra Testbench with a local ClickHouse in Docker. To run the test suite locally:
docker compose -f docker-compose.test.yaml up -dcomposer installcomposer test
See docs/howto_run_local_test.md for
prerequisites, cluster-test notes, and using vendor/bin/testbench /
Laravel Boost during development.
See CONTRIBUTING.md for branch and commit conventions, the
CHANGELOG.md policy, and the release process.