vimatech/laravel-document-numbering
Laravel package for generating sequential document numbers for your models. Configure custom formats, prefixes, suffixes, date placeholders, padding, and reset periods, with database-backed, concurrency-safe numbering for invoices, orders, and other records.
Install and publish. The package is small and low-adoption (0 stars, no dependents), so read the source before trusting it in production — pin an exact version in composer.json rather than a caret range.
composer require vimatech/laravel-document-numbering
php artisan vendor:publish --tag="document-numbering-config"
php artisan vendor:publish --tag="document-numbering-migrations"
php artisan migrate
Look here first, in this order:
config/document-numbering.php — patterns, padding, reset period, and per-document-type definitions. This is the whole mental model of the package.type, scope, period, last_number with a unique index over the first three). Understanding that key is what makes the concurrency behavior predictable.next() implementation — confirm it uses lockForUpdate() inside a transaction rather than increment() on a shared row.DocumentNumber::next() and a HasDocumentNumber trait as the conventional shape; verify exact names before copying.Configure a sequence:
// config/document-numbering.php
return [
'documents' => [
'invoice' => [
'pattern' => 'INV-{year}-{number}',
'padding' => 5,
'reset' => 'yearly', // null | yearly | monthly
],
'credit_note' => [
'pattern' => 'CN-{year}-{number}',
'padding' => 5,
'reset' => 'yearly',
],
],
];
First use case — invoice creation. Allocate the number inside the same transaction that writes the invoice:
use Illuminate\Support\Facades\DB;
use Vimatech\DocumentNumbering\Facades\DocumentNumber;
$invoice = DB::transaction(function () use ($payload) {
return Invoice::create([
...$payload,
'number' => DocumentNumber::next('invoice'),
]);
});
If the package ships a model trait, the equivalent is:
class Invoice extends Model
{
use \Vimatech\DocumentNumbering\Concerns\HasDocumentNumber;
protected string $documentType = 'invoice';
// number assigned on creating, inside the surrounding transaction
}
Whichever entry point you use, the first thing to prove on day one is that two parallel requests produce INV-2026-00001 and INV-2026-00002, never the same value and never a hole.
Allocate inside the document transaction. This is the pattern the package is built around and the only one that preserves gap-free semantics. If next() runs before DB::transaction(), or on a separate connection, a failed insert leaves a burned number and the "gap-free by default" claim no longer holds for you.
// Correct: rollback releases the counter too.
DB::transaction(function () {
$number = DocumentNumber::next('invoice', scope: $tenant->id);
$invoice = Invoice::create(['number' => $number, ...]);
$invoice->lines()->createMany($lines); // still inside the lock
});
// Wrong: number consumed even if the insert fails.
$number = DocumentNumber::next('invoice');
Invoice::create(['number' => $number, ...]);
Use scopes for multi-tenancy, not prefixes. A per-scope counter keyed on tenant_id (or company_id, branch_id) gives each tenant its own 00001 sequence and keeps the lock contention local. Only fall back to scope-embedded patterns like {scope}/INV-{number} when the number has to be human-readable across tenants.
DocumentNumber::next('invoice', scope: $tenant->id);
DocumentNumber::next('invoice', scope: "{$tenant->id}:{$branch->id}");
Keep document types separate. Invoices, quotes, and credit notes should be three sequences, not one sequence with three prefixes. Mixing them means a deleted quote consumes an invoice number, which is exactly the audit finding you are trying to avoid.
| Document | Pattern | Reset | Scope |
|---|---|---|---|
| Invoice | INV-{year}-{number} |
yearly | tenant |
| Quote | QT-{year}-{number} |
yearly | tenant |
| Credit note | CN-{year}-{number} |
yearly | tenant |
| Internal PO | PO-{number} |
none | company |
Period resets belong to the key, not the formatter. A yearly reset should mean the counter row is keyed by (type, scope, 2026). Verify this in the migration. If instead the package resets by comparing a stored timestamp, you get a subtle bug at the year boundary under concurrency — two workers can both decide the period rolled over.
Testing pattern. Freeze time so period-reset assertions are deterministic, and use RefreshDatabase:
public function test_yearly_reset(): void
{
Carbon::setTestNow('2026-12-31 23:00:00');
$this->assertSame('INV-2026-00001', DocumentNumber::next('invoice'));
Carbon::setTestNow('2027-01-01 00:00:01');
$this->assertSame('INV-2027-00001', DocumentNumber::next('invoice'));
}
Backfill before go-live. If you are adopting this on an existing table, seed each counter to the current maximum, or the first generated number will collide with historical data:
DB::table('document_number_sequences')->updateOrInsert(
['type' => 'invoice', 'scope' => $tenantId, 'period' => '2026'],
['last_number' => Invoice::whereYear('created_at', 2026)->max('sequence') ?? 0],
);
Draft and preview numbers are a separate concern. If the UI needs to show "INV-2026-00042 (draft)" before the user saves, do not call next(). Render a preview from the current counter plus one, and only allocate on commit — otherwise every abandoned form burns a number.
Integration point for reporting. Store the rendered string (number) and the decomposed parts (sequence, period, scope) on the document. Sorting and grouping by the string breaks at padding overflow and at resets; sorting by (period, sequence) never does.
The lock only exists if you are in a transaction. lockForUpdate() outside DB::transaction() is a no-op in Laravel — it selects, locks nothing meaningful, and releases immediately. If you call the facade at the top of a controller, you have a race condition that will not show up in tests and will show up in production at month-end. Wrap it, always.
First-call races on an empty sequence row. The counter row for (type, scope, period) does not exist until the first document. Two concurrent first calls can both try to insert it. The right behavior is a unique index plus catch-and-retry; check that the migration has the unique index and that the package handles the duplicate-key exception. If it doesn't, pre-seed sequence rows for the current period during deploy.
Deadlocks from multiple allocations. Allocating an invoice number and a credit-note number in the same transaction takes two row locks. Two requests doing it in opposite orders deadlock. Standardize the order (always invoice first, then credit note) or split into separate transactions.
Long transactions hold the sequence lock. The lock is held from next() until commit. Sending an email, calling a payment API, or generating a PDF inside that window will serialize every invoice creation in the tenant. Do slow work before opening the transaction, or after committing.
Timezone boundaries are the #1 source of "wrong year" bugs. {year} is rendered from now() in app timezone, but the period key may be derived from a DB-side date. Set APP_TIMEZONE explicitly, and make sure config('app.timezone') matches whatever the package uses for period rollover. Test at 23:59 and 00:01 on Dec 31.
Rollback semantics differ by connection. If the sequence table lives on a different connection than your documents (read/write split, separate RDS), transaction rollback will not undo the counter increment, and you lose gap-free guarantees silently. Keep both on the same connection.
Padding overflow breaks lexical sort. With padding: 5, number 100000 sorts before 99999 as a string. Either pad to a width you will never exhaust (7–8 for invoices) or sort on the numeric sequence column. Do not "fix" this later by changing padding — it invalidates every historical number.
Do not cache or batch next(). Calling it in a loop to prefetch ten numbers defeats the lock's purpose and creates gaps when the request fails halfway. If throughput is genuinely the bottleneck, use a documented block-reservation feature if one exists — and accept that blocks reintroduce gaps on crash.
Audit and immutability. Once a document is issued, the number must never change. Guard the column ($model->guard(['number']), no mass assignment in updates) and consider a DB-level unique constraint on (scope, number) as a second line of defense independent of the package.
Extension points. The config-driven pattern string is usually the whole customization surface. If you need something the placeholders don't cover (Roman numerals, fiscal-year labels, check digits), prefer a custom formatter or a wrapper service over forking — you will want composer update to keep working. If the package binds a sequence model or resolver in its service provider, that binding is the clean override; check DocumentNumberingServiceProvider for what's rebindable.
Given the adoption profile, budget for reading the source. Zero stars and zero dependents means no Stack Overflow corpus and no community-tested edge cases. Read next() end to end (it is likely under 100 lines), confirm the transaction and lock behavior yourself, and vendor the migration into your own database/migrations directory so a package update cannot silently alter the sequence table. Write one concurrency test with pcntl_fork or two parallel queue workers before shipping — it is the only test that actually validates the package's central promise.
How can I help you explore Laravel packages today?