jetcod/eloquent-keygen
Laravel package that integrates Snowflake ID generation with Eloquent models, giving you automatic, distributed, unique primary keys via a base model or trait, with per-model opt-out and configurable settings for scalable applications.
Install and publish
composer require jetcod/eloquent-keygen
php artisan vendor:publish # pick the jetcod/eloquent-keygen entry interactively
Do not paste the README's --provider="eloquent-key-generator-config" into a runbook — that string is not a service provider class name and the command will fail. If you need the FQCN for automation, get it from the source:
grep -rl "ServiceProvider" vendor/jetcod/eloquent-keygen/src
ls config | grep -i -E 'keygen|snowflake' # confirm the published config filename
Check the runtime matrix before you commit to a date. v1.2.0 requires 64-bit PHP 8.1+, Laravel 10–13, and godruoyi/php-snowflake ≥ 3.2. Generation moved from saving to creating, and PrimaryKeyGenerator::generate() now declares : string.
Read three files first — the package is small enough that this is minutes, not hours:
Snowflake trait (vendor/jetcod/eloquent-keygen/src/Traits/Snowflake.php) — where the creating hook is registered and whether it sets $incrementing / $keyType for you.PrimaryKeyGenerator class — the public seam you'll call directly for bulk inserts and tests.First use case: one new, non-critical table. Pick a table that hasn't shipped.
// database/migrations/xxxx_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary(); // NOT $table->id()
$table->unsignedBigInteger('customer_id');
$table->timestamps();
});
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Jetcod\Eloquent\Traits\Snowflake;
class Order extends Model
{
use Snowflake;
}
Verify in tinker before writing a single feature test:
>>> (new App\Models\Order)->getIncrementing(); // must be false
>>> (new App\Models\Order)->getKeyType(); // ideally "string"
>>> App\Models\Order::factory()->create()->id; // a ~19-digit string
If getIncrementing() is true, do not proceed — see the first gotcha below.
Prefer the trait over extending Jetcod\Eloquent\Model. The base class works, but it puts a third-party class into your model hierarchy, which makes the fallback (copying ~30 lines of trait) a refactor instead of a copy-paste. The trait keeps Illuminate\Database\Eloquent\Model as the parent, so every IDE, static-analysis rule, and package that sniffs for Illuminate\Database\Eloquent\Model keeps working.
Keep the package as the convention, not the architecture. Treat it as a replaceable utility: pin the exact version, and know that vendoring the trait is roughly half a day if the maintainer goes quiet. Two stars, zero dependents, one maintainer — fine for a pilot, not for a platform-strategy slide.
Mixed-schema rollout (legacy auto-increment tables + new Snowflake tables). This is the realistic adoption path, and the per-model escape hatch makes it safe:
class LegacyInvoice extends Model // extends the package base model
{
protected function snowflake(): bool
{
return false; // keep auto-increment while you plan the backfill
}
}
Pilot on one service, revert per model. That reversibility is the whole reason this is approvable.
Generating IDs before commit. This is the actual day-to-day win — queued jobs, outbox rows, and event payloads that need to reference a row that doesn't exist yet:
$generator = app(\Jetcod\Eloquent\Generators\PrimaryKeyGenerator::class); // confirm FQCN in source
$id = $generator->generate(); // string
CreateOrderJob::dispatch($id, $payload);
Bulk imports. Model::insert(), upsert(), insertOrIgnore(), and DB::table()->insert() bypass model events entirely, so no IDs are generated. Either loop with Model::create() per chunk, or generate IDs yourself and include them in the insert payload:
$rows = collect($csv)->map(fn ($r) => [
'id' => $generator->generate(),
'customer_id'=> $r['customer_id'],
'created_at' => now(),
'updated_at' => now(),
]);
Order::insert($rows->chunk(500)->first()->all());
Worker/datacenter allocation is a deployment concern, not a package concern. Set SNOWFLAKE_WORKER_ID per process or pod from a stable source — StatefulSet ordinal, an explicit env matrix, or the orchestrator's node identity. Log the resolved worker ID at boot and expose it on a health endpoint so a bad rollout is visible in minutes rather than discovered as duplicate-key errors weeks later.
Add a separate public identifier now, not later. Snowflake IDs leak creation timestamps and, at default settings, node identity. Any user-visible ID — URLs, API payloads, share links, support screenshots — should be a public_id (ULID/UUIDv7), decided in the same conversation as this rollout.
Casting and API output. Set protected $keyType = 'string'; if the trait doesn't already. Laravel merges the key type into getCasts(), so $model->id and toArray()/JSON output become strings — which is what you want, because a 19-digit ID exceeds JavaScript's Number.MAX_SAFE_INTEGER and will silently lose precision in the browser.
Tests. Keep factories free of id. For deterministic tests, bind a fake generator in the container rather than asserting on generated values:
$this->app->bind(PrimaryKeyGenerator::class, fn () => new class {
public function generate(): string { return '900000000000000001'; }
});
$incrementing is the blocker, not the config. If Eloquent thinks the model auto-increments, performInsert() calls insertAndSetId(), which overwrites your generated ID with lastInsertId(). Confirm getIncrementing() is false and getKeyType() is 'string'. If the trait already declares these properties, redeclaring them in your model with a different value is a PHP fatal error — check the trait first, then declare only what's missing.
Schema type is a blocking pre-flight check. The id column must be BIGINT UNSIGNED and every foreign key referencing it must match. A signed/unsigned mismatch in MySQL surfaces as a foreign-key constraint failure at migration or insert time. This is a migration project for existing tables, not a config change.
Config caching will bite you in containers. php artisan config:cache bakes env() values into bootstrap/cache/config.php. If that runs at image build time, every pod ships the same SNOWFLAKE_WORKER_ID and you get silent collisions. Either run config:clear + config:cache at container start, or don't cache config at all in clusters where worker IDs are injected per pod.
The env var name for the file lock directory is SNOWFLAKE_File_LOCK_DIRECTORY — mixed case, straight from the README. .env keys are case-sensitive on Linux; a "corrected" SNOWFLAKE_FILE_LOCK_DIRECTORY silently won't apply. Also note v1.2.0 creates the default directory through Laravel's filesystem service — read-only container filesystems will fail here, so pick FileLockResolver deliberately or override the path.
Sequence resolvers do different things. LaravelSequenceResolver (default) keeps per-millisecond sequence state in your cache store — a per-node file/array cache is fine only because the worker ID is part of the ID. Distinct worker IDs are what actually protect you; the resolver protects the same-worker-same-millisecond case. FileLockResolver needs a lock path every process can reach, which usually means a shared filesystem across pods. Choose with the deployment topology in mind, not the default.
A misconfigured worker ID fails silently. Two nodes sharing an ID in the same millisecond produce a duplicate primary key — an insert error at best, a hard-to-reproduce one at worst. There's no validation in the package. Make "unique worker ID per process" an explicit deploy checklist item.
Clock skew. Snowflake depends on wall-clock time. NTP stepping backwards can produce non-monotonic or duplicate IDs. Configure slew rather than step on hosts running this, and treat clock sync as a correctness requirement, not hygiene.
Route binding, validation, and comparisons. whereNumber() route constraints and the integer validation rule will reject numeric-string IDs. Use string/numeric. And remember IDs coming from raw DB::table() queries may be int while $model->id is string — === comparisons across those two sources will fail.
Anything that assumes small IDs breaks. Fixtures with id => 1, seeders using Model::insert(), assertDatabaseHas('orders', ['id' => 1]), and insertGetId() on the query builder all need auditing. latest('id') and cursorPaginate() get better — Snowflake IDs are roughly time-ordered and make excellent keyset-pagination cursors.
If you override bootSnowflake() on a model, you silently disable ID generation — trait boot methods are overridable. Same for a snowflake() method with an incompatible signature; the trait requires : bool.
Don't mix with HasUuids/HasUlids or a model whose $primaryKey isn't id. Verify the trait writes to getKeyName() before relying on a custom primary key name.
Extension points worth knowing: the PrimaryKeyGenerator binding (swap for tests or custom algorithms) and snowflake(): bool per model. That's the entire integration surface — nothing else to learn, and nothing else to break.
Upgrading to 1.2.x changes write semantics. Pre-1.2 behavior hooked saving; anything that relied on an ID being regenerated during an update will now keep its original ID. That's the correct behavior, but if you have code that save()s a cloned record and expects a fresh ID, switch it to replicate() (which fires creating) and test it.
How can I help you explore Laravel packages today?