friends-of-ddd/sequence-number-generator
A Laravel/PHP sequence number generator for creating formatted, sequential identifiers such as invoice, order, and document numbers. Configure patterns to produce consistent, human-readable values across your application.
Install and publish the config first.
composer require friends-of-ddd/sequence-number-generator
php artisan vendor:publish --provider="FriendsOfDdd\SequenceNumberGenerator\SequenceNumberGeneratorServiceProvider"
Because the package uses Laravel auto-discovery, the provider and facade should register automatically. If you are on an older Laravel (pre-8) or have discovery disabled, register the provider manually in config/app.php. The published config file is where you will declare each named sequence — the prefix, padding, reset period, and (if applicable) the backing store. Read that file top to bottom before writing any code; it is the real API surface for day-to-day use.
Look at the config and the interface first, not the README. The two things that determine how you will use this package are (a) the SequenceNumberGenerator contract (the port you should be injecting) and (b) the config schema (the sequences you can name). Everything else — the concrete DB implementation, the service provider wiring — is infrastructure you should mostly ignore.
First use case: invoice numbering.
$number = SequenceNumberGenerator::next('invoice');
// "INV-2025-000123" depending on your config
$invoice->update(['number' => $number]);
Start with one non-critical sequence (an internal reference, a support ticket, a document control ID) rather than your regulated invoice numbering. The point of the pilot is to learn how the generator behaves under real concurrency and transaction semantics before you stake a compliance requirement on it.
Minimum smoke test on day one:
next('invoice') and assert 50 distinct values.next() call in a transaction, roll it back, call next() again, and see whether the number is reused or burned.sequence_numbers or similar with a counter column) and whether it uses SELECT ... FOR UPDATE, an atomic UPDATE ... SET counter = counter + 1, or something else.Document those three answers in an ADR or in your team wiki. They are the things that will bite you later and they are not always obvious from reading the source.
Wrap it in your own interface — always. This is the single most important pattern for this package. Even if the package already ships a clean interface, define your own App\Domain\Sequence\SequenceNumberGenerator and an adapter that delegates to the package. This keeps the dependency at the edge of your codebase (hexagonal layering), and it means that if the package (0 stars, 0 dependents) goes unmaintained, you swap one class instead of grepping the whole app.
namespace App\Domain\Invoice;
interface InvoiceNumberGenerator
{
public function next(): string;
}
final class PackageInvoiceNumberGenerator implements InvoiceNumberGenerator
{
public function __construct(private \FriendsOfDdd\...\SequenceNumberGenerator $inner) {}
public function next(): string
{
return $this->inner->next('invoice');
}
}
Bind the adapter in a dedicated service provider and inject the domain interface everywhere else.
One named sequence per business concern. Do not overload a single counter. invoice, order, credit_note, ticket should each have their own entry in the config. This costs nothing and prevents the classic disaster of order numbers leaking the volume of invoices (or vice versa).
Use date-based reset for anything regulatory. Config supports daily/monthly/yearly resets. For fiscal-year invoice numbering (INV/2025/000123), set yearly reset and include the year in the format. For ticket systems, monthly or daily resets keep numbers short and human-friendly. Be aware that resetting means the counter table needs to know which "current period" it is in — the first call after a rollover should reset cleanly, but test it (clock mocking or a manually-inserted stale row) before you depend on it.
Keep generation outside the model, inside the domain service. The transaction that creates an invoice should call the generator, get a string back, and pass it to the aggregate. Avoid calling SequenceNumberGenerator::next() from an Eloquent observer or accessor — you will lose control of when it fires, and you will burn numbers on failures or validation errors.
Sequence the call after validation, before persistence.
DB::transaction(function () use ($request) {
$invoice = Invoice::create([...validated, no number yet...]);
$invoice->number = $this->numberGenerator->next(); // after validation
$invoice->save();
});
Whether this is safe against duplicates depends entirely on the package's concurrency model — verify it. If the counter increment is not coordinated with your transaction, you can end up with a duplicate in the DB even though the generator returned unique values.
For tests, bind an in-memory or stub implementation. Because the port is yours, the test double is trivial:
final class InMemoryInvoiceNumberGenerator implements InvoiceNumberGenerator
{
private int $n = 0;
public function next(): string { return sprintf('INV-%06d', ++$this->n); }
}
Bind it in a test service provider. Never hit the real DB sequence table in unit tests — it makes tests order-dependent and slow.
For seeding/fixtures, generate sequentially with a known prefix. When importing historic data, either allocate a block from the generator (if it supports it — verify) or use a distinguishable prefix (IMP-) so production numbering is not contaminated by imports.
Bulk operations and queues. If a job creates many records, generate one number per record inside the job's own transaction, not one batch call. Retried jobs must not re-allocate numbers; because the generator is not idempotent by itself, store the generated number on the record immediately with a unique index so a retry fails fast and you can detect the collision rather than silently producing a duplicate.
Multi-tenancy. The package gives you per-sequence counters; it does not give you per-tenant sequences out of the box. The pragmatic pattern is to encode the tenant in the sequence name (invoice:t1, invoice:t2) and resolve it from a context object in your adapter. Do not try to shoehorn tenancy into the format string alone — you will end up with duplicate-looking numbers that are impossible to reconcile.
"Gap-free" is the phrase that will get you in trouble. The README describes atomic generation, but true gap-free numbering requires the allocation and the business write to be in the same transaction, with the counter row locked, and a rollback story for partial failures. Test this explicitly: roll back a transaction after calling next(), then call next() again from a fresh process and observe whether the number is reused or burned. In most counter-based implementations the number is burned — acceptable for a receipt, not acceptable for a legally-mandated invoice register. If you have an audit requirement, plan to own that layer yourself rather than assume the package guarantees it.
Concurrency behavior depends entirely on the backing store. A SELECT ... FOR UPDATE on MySQL serializes all callers per sequence and will show up as lock contention at even modest write rates. An atomic UPDATE ... SET counter = counter + 1 is better but can still be a hotspot. Redis INCR is fast but numbers are lost on restart. Confirm which mechanism this package uses (read the source or the migration it ships) and profile it against your peak write rate before relying on it. SQLite local success tells you nothing about MySQL/Postgres under load.
Numbers do not survive migrate:fresh. Obvious once stated, painful in hindsight: the sequence table is a normal table. Make sure your seeders and CI pipelines account for it, and never let a staging reset touch a shared sequence store used by anything resembling production.
Config caching. config('...') works fine with php artisan config:cache, but only if you are not resolving the sequence name from a runtime tenant/date inside the config file itself. Keep runtime variation in your adapter, not in config. After any config change in a deploy, run php artisan config:clear in the local/staging environments — the classic "why is my prefix still the old one" bug.
Rollbacks burn numbers. If the package's counter increments via a separate connection or an auto-committed statement, a rolled-back business transaction leaves a gap. This is fine for human-facing IDs and disastrous for strict sequential accounting. Decide which one you are and write it down.
Test the year/month rollover deliberately. Insert a counter row dated to last year, then generate. The expected behavior is that the counter resets to 1 and the format picks up the new year. If it does not, you will find out on January 1st at 00:01 from your finance team.
The facade is a trap in tests. SequenceNumberGenerator::next() is convenient but hides the dependency. Prefer constructor injection of your own interface. If you must use the facade, SequenceNumberGenerator::shouldReceive(...) in tests, but recognize that you are coupling test setup to a third-party static — one more reason to wrap it.
Pin the version and vendor the code. With 0 stars and 0 dependents there is no field evidence of production use. Pin the exact version in composer.json, and if licensing permits, keep a fork under your own namespace or vendor directory. Add a smoke test that exercises next() so a future composer update cannot silently break numbering without failing CI.
Watch for silent failures on the DB. If the counter table is unavailable (connection pool exhausted, failover), does next() throw or return a duplicate/empty string? Whatever it does, wrap it. Your adapter should translate any internal exception into a domain-meaningful one, and your monitoring should alert on sequence-allocation latency and on unique-constraint violations on the numbered column (a duplicate is the earliest signal of a real problem).
Overlap with existing ID strategies. If you already use UUID/ULID as primary keys, this package is not competing — it is for the human-readable secondary identifier. Make that explicit in your schema: primary key stays a UUID, invoice_number is the sequence-backed unique string with a UNIQUE index. That index is your last line of defence against a generator bug.
Extension points to inspect before you need them. Look for whether the storage backend is swappable (a second implementation of the generator interface) and whether the format is a strategy or a config string. If neither is exposed, treat the package as a fixed black box, wrap it tightly, and be prepared to reimplement the interface behind your adapter in a day — which is exactly why the adapter is worth the twenty lines on day one.
When to walk away. If you need distributed/high-throughput generation (Snowflake-style) or strictly gap-free audit numbering under failure, a counter table is the wrong shape of problem and this package is not worth the fight. Use your own port, implement it with database sequences or an ID service, and move on.
How can I help you explore Laravel packages today?