sonvq/cassandra
Laravel integration for Apache Cassandra that brings fluent query building and Eloquent-style access to distributed wide-column data. Manage connections, run CQL queries, and model Cassandra tables through a familiar, PHP-friendly API for scalable NoSQL workloads.
Requirements first, not composer first. This package wraps the DataStax PHP driver, so the real installation step is the C extension:
# the actual blocker for most people
pecl install cassandra
# or
apt-get install php8.x-cassandra # distro-dependent
echo "extension=cassandra.so" > /etc/php/8.x/mods-available/cassandra.ini
php -m | grep cassandra
Only then:
composer require sonvq/cassandra
php artisan vendor:publish --provider="Sonvq\Cassandra\CassandraServiceProvider"
Where to look first. Three files, in this order:
config/cassandra.php — hosts, port, keyspace, credentials, consistency, timeout, connect options. This is the whole tuning surface; there is no queue/pool config to discover later.src/Connector, src/CassandraConnection) — tells you exactly which driver options are forwarded and which are silently dropped.Illuminate\Database\Query\Builder surface.Register the connection in config/database.php alongside MySQL, so you can run Cassandra next to your relational DB rather than replacing it:
'connections' => [
'cassandra' => [
'driver' => 'cassandra',
'hosts' => [env('CASSANDRA_HOST', '127.0.0.1')],
'port' => env('CASSANDRA_PORT', 9042),
'keyspace' => env('CASSANDRA_KEYSPACE', 'app'),
'consistency'=> Cassandra::CONSISTENCY_LOCAL_QUORUM,
'timeout' => 10,
],
],
First use case. Do not start with a model. Start with a raw, prepared query to prove the driver and keyspace are wired correctly:
use Sonvq\Cassandra\Facades\Cassandra;
$rows = Cassandra::connection('cassandra')
->select('SELECT * FROM users WHERE id = ?', [$uuid]);
Once that returns rows, you know the extension, the connector, and the keyspace are all fine — and any later failure is in the builder layer, which narrows debugging enormously.
Treat Cassandra as a second connection, not a second ORM. The realistic day-to-day pattern is: relational models live in MySQL/Postgres via Eloquent, and Cassandra is used through explicit connections for the tables that genuinely need it (event logs, time-series, activity feeds, high-write counters).
$events = DB::connection('cassandra')->table('user_events')
->where('user_id', $userId)
->where('bucket', $bucket)
->limit(50)
->get();
Model the table around the query, then write the model. Cassandra requires the partition key to be pinned in the WHERE clause. Design order is: query → table → class. If you find yourself wanting WHERE email = ? on a table partitioned by user_id, that's a second table, not a second index.
CREATE TABLE user_events (
user_id uuid,
bucket date, -- month/day bucket keeps partitions bounded
ts timestamp,
type text,
payload text,
PRIMARY KEY ((user_id, bucket), ts)
) WITH CLUSTERING ORDER BY (ts DESC);
Prefer raw prepared statements for anything hot. The builder is convenient but each call re-parses CQL. For write-heavy paths, keep a prepared statement handle and reuse it:
$stmt = Cassandra::connection('cassandra')
->prepare('INSERT INTO user_events (user_id, bucket, ts, type, payload) VALUES (?,?,?,?,?)');
foreach ($batch as $row) {
$session->execute($stmt, ['arguments' => array_values($row)]);
}
Use the schema/migration layer only for bootstrapping. It's fine for local dev and CI setup, but CQL schema changes are not transactional and there is no down-migration story worth relying on. Keep authoritative .cql files in the repo and run them through cqlsh in deploy scripts; treat the package's schema builder as a convenience for tests.
Batch by partition key. The package will let you issue a batch of writes, but an unlogged batch spanning partitions is a performance trap. Use batches only when all writes share a partition key:
Cassandra::connection('cassandra')->batch(function ($b) use ($rows, $userId, $bucket) {
foreach ($rows as $r) {
$b->insert('user_events', [
'user_id' => $userId, 'bucket' => $bucket,
'ts' => $r['ts'], 'type' => $r['type'], 'payload' => json_encode($r),
]);
}
});
Isolate the driver behind a repository. With this package's maturity level, the smartest integration decision is to never let Cassandra:: or the builder leak into controllers. One repository class per table gives you a single place to fix when a builder method turns out to be unimplemented or behaves differently than Eloquent.
Paginate with paging state, not OFFSET. Cassandra has no OFFSET. Use limit() plus the driver's paging state, or a ts < $lastSeen cursor — the latter is far more robust and survives restarts.
The Eloquent surface is partial. Extending the provided base model gets you attributes and hydration, but do not assume $model->save() handles upserts, timestamps, events, or relations the way you expect. Cassandra's INSERT is an upsert, so updateOrCreate-style logic is usually redundant — and quietly wrong if you rely on it for created_at semantics.
No transactions. DB::transaction() and beginTransaction() are meaningless here (LWT/IF clauses are the only atomicity primitive, and the package exposes them poorly if at all). Any code path that assumes rollback must be restructured before it touches Cassandra.
No auto-increment, no foreign keys, no joins. ->join() will not work. Denormalize aggressively; that's the intended design, not a limitation to work around.
Consistency level is per-connection, not per-query, in the default config. If one read needs QUORUM and a bulk analytics read is fine with ONE, you'll end up declaring two connections pointing at the same hosts. Worth doing explicitly rather than trying to mutate consistency mid-request.
Silent empty results from a missing partition key. If a query omits part of the composite partition key, you usually get zero rows or a driver error rather than a helpful message. When results are unexpectedly empty, log the generated CQL and the bound values before blaming the package.
allow filtering is the smell. If you're tempted to add it to make a query work, the data model is wrong. It's a full scan with extra steps.
Timestamps and UUIDs need care. PHP's DateTime does not map cleanly to timestamp in all driver versions; convert to Cassandra\Timestamp or store epoch millis as bigint and be consistent. For ids, use Ramsey\Uuid\Uuid::uuid4() and generate on the PHP side — never rely on the database.
Connection lifecycle. The driver holds a session per connection. In long-running workers (Horizon, Octane, queue daemons), a dropped node leaves you with a dead session and no automatic reconnect in older driver builds. Wrap long jobs with a reconnect or set php artisan queue:restart into your deploy, and verify reconnection behavior explicitly under Octane before committing to it.
Debugging is cqlsh, not dd(). When the package misbehaves, reproduce the exact CQL in cqlsh first. That instantly separates "wrong query" from "wrong package behavior."
Extension points, given the small surface. With ~80 stars and no dependents, treat the package as a thin adapter you may need to fork. The useful seams are: the connector (swap in your own session/retry policy), the grammar (add CQL you need), and the base model. Favor subclassing those over patching vendor code, and pin the version in composer.json — an unpinned minor bump on a package this size can be a breaking change in practice.
Have an exit plan. Because there are zero dependents, you are effectively the only integration test. Keep all Cassandra access behind your repositories so that replacing this package with direct ext-cassandra calls — or with a better-maintained wrapper — is a one-directory change rather than a codebase-wide rewrite.
How can I help you explore Laravel packages today?