juststeveking/laravel-key-factory
A Laravel package for generating unique, customisable keys for Eloquent models. Configure prefix, length, and allowed characters, then assign keys through a simple trait—ideal for API tokens, order numbers, and public IDs.
The package is small enough that "reading the docs" means reading two files: the published config and the HasKeyFactory trait. Install, publish, and read the config before writing any code.
composer require juststeveking/laravel-key-factory
php artisan vendor:publish --tag=key-factory-config
The published config/key-factory.php is the whole control surface: a map of Eloquent model class → generator class (a ULID generator, a UUID generator, a prefixed/random-string generator). Confirm the exact class names there, not in the README — the README is thin and the config is authoritative.
First use case is the one the package is actually shaped for: a non-critical model that needs an opaque, externally-visible identifier. Add the trait, declare the generator in config, and make sure the migration column type matches the generator's output.
use JustSteveKing\KeyFactory\Concerns\HasKeyFactory;
class Invoice extends Model
{
use HasKeyFactory;
}
Then, in a scratch branch, smoke-test the round trip before wiring anything else:
$invoice = Invoice::create([...]);
dump($invoice->id); // 26-char ULID, or prefixed string — whatever you configured
dump(strlen($invoice->id)); // must match your column length
If that dumps an integer, the trait isn't resolved, the config isn't being read, or the config is cached from before you published. That single check is the fastest diagnostic you have.
Prefer the sidecar-column integration over replacing the primary key. The lowest-risk pattern is keeping the standard auto-incrementing id and adding a separate keyed column (public_id, reference, slug), generated by the trait. You keep integer foreign keys, index density, and existing route() calls intact, while the outside world only ever sees the opaque value. Adding a new trait to an existing id column is the change that forces a schema decision, a backfill, and dual-writes — estimate that separately, because this package does not solve it.
Schema::table('invoices', function (Blueprint $table) {
$table->ulid('public_id')->unique()->after('id'); // length/type must match generator
});
Use the trait only where the config map has an entry. The package's value is the single place where per-model key strategy lives. If you adopt it, keep that discipline: every model's format is visible in one config file, reviewed in one diff. Don't let teams override generation inline in model boot() methods — that's exactly the per-team improvisation the config file exists to prevent.
Bind keys in API resources, not in controllers. Once public_id exists, Route::get('/invoices/{invoice:public_id}') and a JsonResource that emits public_id only is the whole integration. Controllers stay untouched, and the leak of sequential IDs stops at one layer.
Generators are swappable extension points. Because the config maps to a class, a custom generator is a matter of implementing the same contract and pointing the config at it. That's also the migration path if you later reimplement first-party: swap the class, keep the call sites.
Factories and seeders inherit the behavior for free — the key is set on model creation, so Invoice::factory()->create() gets one without extra work. Assert the shape in a test rather than the value:
expect($invoice->public_id)->toBeString()->toHaveLength(26);
Where a platform standard exists, follow it. If your team has (or should have) an ADR saying "new externally-visible models use ULIDs," implement that with native HasUlids and cite this package as the pattern precedent. The day-to-day outcome — opaque, sortable, prefixed-if-you-want identifiers — is identical, minus the dependency.
Assume stale Composer constraints until proven. Last release was November 2022, single maintainer, zero dependents. Validate composer require against your actual Laravel/PHP matrix in a throwaway branch first; if it resolves only by pinning an old framework, that's the end of the evaluation.
The generator does not guarantee uniqueness — your index does. Treat any collision-safety claim as advisory. Put a unique() index on the column, handle the duplicate-key exception with a retry, and test under concurrency (parallel workers, queue fan-out, imports) before trusting it. Bulk inserts that bypass model events won't fire the trait at all; generate keys explicitly there.
Column type, length, and collation are a one-way door. ulid() columns sort and paginate by keyset; uuid() columns are random v4 unless you force v7/ordered; prefixed strings need string(..., N) sized for prefix + body. Getting this wrong surfaces as silent truncation or index bloat, not as an error. Decide once, in the migration, deliberately.
Check for trait and event conflicts before rollout. HasKeyFactory boots alongside HasUuids/HasUlids, SoftDeletes, observers, and any boot() hook that also sets the key attribute. Precedence is not obvious and the failure mode is silent — the key comes out in the wrong format, or a creating listener overwrites what the trait generated. Add one test per model asserting the format, and run it after every framework upgrade you don't control upstream for.
Config caching bites in production. php artisan config:cache after a deploy, and remember the map lives in config: if the config isn't published on an environment, behavior diverges between dev and prod. This is one of the most likely "works locally" failures with this package.
Debugging is from source. With no downstream users to learn from, vendor/juststeveking/laravel-key-factory/ is your error message decoder. Keep a tinker session and dump(Model::create([])->getKey()) handy.
Don't reach for this for credentials. Token issuance needs rotation, revocation, scopes, expiry, and hashing — Sanctum/Passport. This package generates strings; that's a different problem with a different risk profile, and the name invites the confusion.
If you do adopt it, pin the version and name an owner. Small blast radius, but key generation is a correctness-critical failure domain and you are effectively the maintainer. A fork or ~100 first-party lines behind an interface is the more sustainable route — and the interface is what keeps the format a one-line change.
How can I help you explore Laravel packages today?