pxlrbt/faker-better-unique
A better unique() modifier for FakerPHP. Keeps generated values unique across formatters and resets, so tests and seeders avoid duplicate collisions without extra bookkeeping. A drop-in replacement for Faker's built-in unique() with more predictable behavior.
This is a dev-only Composer utility that changes how Faker's unique() modifier tracks state. It is not a Laravel package with migrations, config, or a production request path. Treat adoption as "swap in a smarter unique()" and the whole evaluation takes an afternoon.
1. Reproduce the failure you're trying to solve. Before installing anything, get the current behaviour on record. The most common trigger:
// database/seeders/DemoSeeder.php
User::factory()->count(20000)->create(); // eventually: OverflowException / "Maximum retries of 10000 reached"
Or a narrower one, which is the case this package's tagline ("allows keys for unique faker values") is actually aimed at:
$faker = fake();
$faker->unique()->numberBetween(1, 50); // fine
// ...later, in a different factory, a different field
$faker->unique()->numberBetween(1, 50); // now competing for the same pool
If neither of these describes your suite, stop. You do not have the problem.
2. Install dev-only.
composer require pxlrbt/faker-better-unique --dev
Pin the exact version ("pxlrbt/faker-better-unique": "1.0.0") rather than ^1.0. A single release with two stars has no track record of honouring semver, and its entire value is subtle behaviour inside someone else's generator.
3. Read src/ before you read anything else. There is no documentation beyond a one-line release note, so the integration path is a source-reading exercise. Answer three questions in ten minutes:
Faker\Generator, or decorate Faker\UniqueGenerator?Faker\Generator in the container (which is what makes fake() and factory $this->faker pick it up)?unique(key: 'x'), unique('x'), or a separate method?Do not assume fake() returns your new generator until you've proven it:
// tests/Feature/FakerSmokeTest.php
public function test_keyed_uniqueness_is_active(): void
{
$faker = fake();
$a = $faker->unique('smoke')->numberBetween(1, 3);
$b = $faker->unique('smoke')->numberBetween(1, 3);
$c = $faker->unique('smoke')->numberBetween(1, 3);
$this->assertCount(3, array_unique([$a, $b, $c]));
}
4. Then prove key separation — the headline feature. Two keys must track independently, which means a tiny pool under one key can be exhausted while the other still works:
foreach (range(1, 3) as $i) {
$faker->unique('pool-a')->numberBetween(1, 3);
}
// This must still succeed if the pools are genuinely separate:
$faker->unique('pool-b')->numberBetween(1, 3);
5. First real use case is almost always a factory with two unique columns that currently cannibalise a shared pool:
class UserFactory extends Factory
{
public function definition(): array
{
return [
'email' => $this->faker->unique('users.email')->safeEmail(),
'username' => $this->faker->unique('users.username')->userName(),
];
}
}
That's the day-to-day leverage: scoping, not more values.
Namespaced keys per column, per table. The single highest-value habit is a stable key convention so unrelated fields never contend:
$this->faker->unique('users.email')->safeEmail();
$this->faker->unique('users.username')->userName();
$this->faker->unique('orders.reference')->bothify('ORD-####-????');
$this->faker->unique('tenants.slug')->slug();
Namespace by table.column. Two factories that both call unique()->userName() for different models currently fight; with keys, they don't. This is the pattern you want applied on day one.
Chunked seeders with stable keys. Keyed pools mean a seeder that runs in chunks still behaves correctly, because the key is the identity, not the loop iteration:
foreach (range(1, 20) as $chunk) {
User::factory()->count(1000)->create();
}
If the tracker is instance-scoped, though, chunking is fine but re-instantiating the generator mid-run is not. Confirm before you rely on it, and see the reset notes below.
Key-per-run for tests, rather than global resets. If the package exposes a reset, prefer key scoping over calling it. A per-test prefix keeps tests order-independent without depending on a reset hook existing:
protected function setUp(): void
{
parent::setUp();
$this->fakerKey = 'test.'.Str::random(8);
}
// $this->faker->unique($this->fakerKey.'.email')->safeEmail()
If a reset entry point does exist and is documented in source (UniqueTracker::reset(), a container rebind, or unique(true)), wiring it into TestCase::setUp() is the cleaner option — but only after you've verified what it clears.
Parallel test runners. Under Paratest or Pest's parallel mode, each process has its own memory, so uniqueness is per-worker. That's usually what you want, but if you generate fixtures that must be globally unique across workers, fold the worker token into the key:
$key = sprintf('worker.%s.users.email', getenv('TEST_TOKEN') ?: '0');
Wrapper helper — non-negotiable for a 2-star v1.0.0. Put one call site in front of the package so removal is a single file:
// tests/Support/Unique.php
final class Unique
{
public static function value(string $key, callable $generator): mixed
{
return fake()->unique($key)->{$generator}();
}
}
Or a thin enum of key constants so keys can't drift into typos — a typo'd key silently creates a second pool and defeats the feature.
Determinism and uniqueness are different concerns. Keep $faker->seed(1234) in any test that asserts on generated values. This package only addresses uniqueness; it does not make output reproducible.
Custom providers. If you register providers ($faker->addProvider(new AppFakerProvider($faker))), exercise the unique path against at least one custom formatter during the spike. That's the code path most likely to behave differently from stock, because custom formatters often have small, finite output spaces.
Seeder-level integration tip. Factories are the natural home for keyed uniqueness, not seeders. Seeders usually call factories; if you find yourself calling fake() directly in a seeder loop, move that into a factory first — it makes the uniqueness keys reusable and the seeder readable.
It cannot invent values. Smarter tracking does not widen a finite pool. unique('tiny')->numberBetween(1, 10) called eleven times must still fail. If you're hitting exhaustion on a genuinely small space, the fix is a wider pattern or a deterministic sequence — not this package.
Verify it fails loudly. Deliberately exhaust a three-value pool and check what surfaces. A version of this package that swallows the exhaustion and returns a duplicate is strictly worse than stock Faker, because you'd get duplicate rows in a seeder with no signal. Confirm OverflowException (or equivalent) is still thrown.
Signature collision with stock Faker. Faker\Generator::unique($reset = false, $maxRetries = 10000) already exists with a specific argument shape. If this package changes argument order or reinterprets the first positional argument as a key, existing $faker->unique(true) calls — which reset the pool — will change meaning silently. Grep before installing:
grep -rn "unique(" app/ database/ tests/ --include="*.php" | grep -v "unique(" # ...you get the idea
State growth in long processes. In a single long-running seeder or a suite with process isolation off, the tracker grows monotonically. Generating hundreds of thousands of rows may show up as memory growth. Log the tracker size if retention looks unbounded, and prefer key scoping over one giant pool.
State leaking between tests. This is the hazard that mirrors the feature. If uniqueness state survives across test boundaries, your tests stop being independent and start depending on execution order — which is a worse failure than the flakiness you were fixing. Either reset explicitly in setUp() or use a per-test key prefix. If neither is possible, confine the package to seeders and leave the test suite on stock Faker.
"Drop-in" is a claim, not a guarantee. Until you've checked the source, don't assume container rebinding happens. If the package ships no service provider, fake() and factory $this->faker will still return stock Faker\Generator, and your keyed calls will explode with a signature error — which at least fails loudly. The dangerous case is a package that installs a trait or helper you must opt into; then half your suite uses one strategy and half the other.
Confirm the key parameter name before writing patterns. unique(key: 'x') versus unique('x') versus unique()->withKey('x') are three different codebases. Get this from src/ on day one, not from a Stack Overflow answer three weeks later.
Pin, lock, and plan the exit. Exact version in composer.json, lockfile committed, no auto-merge for dependency bots on this package. Because the value is subtle correctness inside someone else's generator, a behavioural regression will not announce itself — you'll just see flaky tests or duplicate rows. With the wrapper helper in place, rollback is one Composer revert and one file edit.
Keep it out of any load-bearing path until 1.0.x has shipped a patch. Zero dependents and a single January 2024 release means nobody has stress-tested this against your Laravel major. Trial it in one seeder or one factory first; widen after it has survived a month of CI.
How can I help you explore Laravel packages today?