danielhe4rt/scylloquent
Eloquent-style models and a fluent query builder for ScyllaDB, giving Laravel developers familiar syntax over wide-column NoSQL. Define models, run expressive queries, and work with Cassandra-compatible data and CQL without leaving your existing Laravel workflow.
Scylloquent wires a Cassandra/ScyllaDB connection into Laravel's Eloquent so you can talk CQL from a familiar Model-ish surface. It is a small, early-stage package (29 stars, MIT), so treat the README and src/ as the source of truth and pin an exact version in composer.json.
Prerequisites before you touch the code
docker run --rm -p 9042:9042 scylladb/scylla and then docker exec -it <id> cqlsh.ext-cassandra, or the ScyllaDB-maintained fork). Without the extension installed the connection resolver has nothing to talk to, and the failure mode is usually a confusing "connection not configured" error rather than a missing-extension error.Minimal steps
composer require danielhe4rt/scylloquent
php artisan vendor:publish --tag=scylloquent-config # check tag name in README
Then register a connection in config/database.php alongside your existing ones:
'scylla' => [
'driver' => 'scylladb', // confirm the driver key in src/
'hosts' => ['127.0.0.1:9042'],
'keyspace' => 'app',
'consistency' => \Cassandra::CONSISTENCY_LOCAL_QUORUM,
],
Create the keyspace and table with CQL, not with Laravel migrations:
CREATE KEYSPACE IF NOT EXISTS app
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};
CREATE TABLE app.events (
tenant_id uuid,
bucket date,
event_id timeuuid,
type text,
payload text,
PRIMARY KEY ((tenant_id, bucket), event_id)
) WITH CLUSTERING ORDER BY (event_id DESC);
Where to look first
README.md for the connection name, model base class, and any artisan commands.src/ for the connection resolver and the model class you are expected to extend — this tells you which Eloquent features were actually implemented versus inherited and left broken.tests/ for canonical usage; with a package this size, the tests are often better documentation than the README.Best first use case: a high-write, append-only table keyed by tenant + time bucket (audit log, event stream, sensor readings, per-user notification feed). Scylla's value is linear write throughput and partition-local reads; pick a workload where every query already knows its partition key and you will get a good result on day one.
Model definition. Expect to declare the primary key explicitly rather than relying on $primaryKey/$incrementing. Cassandra has no auto-increment, so use uuid/timeuuid client-side:
use Scylloquent\Model; // exact namespace: check src/
class Event extends Model
{
protected $connection = 'scylla';
protected $table = 'events';
protected $primaryKey = ['tenant_id', 'bucket', 'event_id']; // composite
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['tenant_id', 'bucket', 'event_id', 'type', 'payload'];
}
Design queries around partitions, not around the model. The healthiest pattern is a thin model plus a repository/service that only exposes partition-scoped methods:
class EventRepository
{
public function recentForTenant(string $tenantId, \DateTimeImmutable $day, int $limit = 50)
{
return Event::query()
->where('tenant_id', $tenantId)
->where('bucket', $day->format('Y-m-d'))
->orderBy('event_id', 'desc') // must match CLUSTERING ORDER
->limit($limit)
->get();
}
}
Anything that isn't partition-scoped should not exist in this repository — if a product requirement needs it, add a second table instead.
Denormalize deliberately. This is the single biggest workflow change. One write fans out to N tables, each shaped for one read pattern:
$this->events->insert($event); // by tenant + day
$this->tenantFeed->insert($event); // by tenant, newest first
$this->byType->insert($event); // by type + day
Keep the fan-out in a single job or service method so it is greppable and easy to backfill. ScyllaDB BATCH statements are supported and are a good fit here — but remember a batch is not a transaction: it is atomic only within a single partition, and cross-partition batches are an anti-pattern.
Run it beside MySQL, not instead of it. The pragmatic production shape is: relational DB owns users, billing, and anything needing joins or multi-row transactions; Scylla owns the firehose tables. Two connections, two model base classes, clear naming (App\Models\... vs App\Models\Scylla\...), and an explicit rule about which one is authoritative. Denormalized copies into Scylla are derived data and must be rebuildable.
Write path through the queue. For high-volume ingestion, dispatch() the write to a job rather than writing inline in a request. It keeps request latency off the Scylla driver, gives you retry semantics, and makes backfills a queue operation rather than a script.
Time bucketing. Use a bucket date (or month) column in the partition key for time-series tables. Unbounded partitions grow until compaction becomes a problem and large partitions start hurting the coordinator; per-day or per-month buckets keep partitions bounded and make retention a DROP/TTL decision instead of a delete scan.
Schema changes. Treat CQL DDL as code, kept in a database/cql/ folder and applied by a small artisan command or a cqlsh -f step in deploy. Laravel migrations have no place here. Remember that ALTER TABLE ... ADD is cheap and safe, DROP COLUMN is not, and you cannot change a primary key in place — that requires a new table plus a backfill.
WHERE on non-key columns fails. Cassandra will reject or demand ALLOW FILTERING, which is almost always a design smell. If you find yourself reaching for it, you need another table.with(), whereHas(), belongsTo do not work. Relationships are a relational concept. Any code path that touches them will either throw or silently misbehave. Guard against it by never letting Scylla models be passed to generic code that assumes Eloquent's full contract (Blade components, Nova, API resources that lazy-load).save() on a partial model will null out columns. Scylla writes are upserts of the full row for the columns you send. A model loaded with select('a','b') then saved may overwrite other columns with defaults. Prefer explicit update() with a known column set, or a dedicated insert/upsert path.OFFSET pagination, and count() is expensive. Use the driver's paging state (fetchSize + paging token) for cursors. Never build ?page=N UI on top of Scylla without a token-based backing.timestamp is UTC-normalized and has millisecond precision; Eloquent's datetime casts and created_at conventions will round-trip but can surprise you when comparing against MySQL rows. Store an explicit bucket date and keep the fine-grained timestamp as timeuuid or timestamp and be consistent.default_time_to_live on the table for log-like data instead of writing delete jobs. Per-write TTL is also available if the driver exposes it — check src/ for a ttl() builder method before hand-rolling one.LOCAL_QUORUM reads/writes are the usual production default; ONE is fast but will show you stale data after a write, which looks like an ORM bug and isn't. Make the level configurable per connection.cqlsh is your php artisan tinker. Reproduce the query by hand there first, look at system_traces.sessions for slow queries, and enable the driver's query logging temporarily. If a model "returns nothing", the partition key is wrong 90% of the time — usually a type mismatch (string vs uuid, DateTime vs date string).ScyllaQueryBuilder macro layer, and possibly a custom connection resolver. Keeping those in app/ (not patching vendor/) is what makes the next composer update survivable.composer.lock plus a smoke test that exercises one insert and one partition read will save you an afternoon.User::where('email', $x)->first() against a Scylla model because it looks like Eloquent. Document the constraint at the top of the model file, or name the classes so the distinction is obvious (ScyllaEventRepository, not Event).How can I help you explore Laravel packages today?