vimatech / laravel-document-numbering
Sequential, gap-free, concurrency-safe document numbering for Laravel (invoices, quotes, credit notes).
Package info
github.com/vimatech-io/laravel-document-numbering
pkg:composer/vimatech/laravel-document-numbering
Requires
- php: ^8.3
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- nesbot/carbon: ^2.72 || ^3.0
Requires (Dev)
- larastan/larastan: ^2.9 || ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.5 || ^10.0 || ^11.0
- pestphp/pest: ^3.5 || ^4.0
- phpstan/phpstan: ^1.12 || ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 21:29:11 UTC
README
Sequential, gap-free, concurrency-safe document numbers for Laravel.
Allocate legally-compliant numbers for invoices, quotes and credit notes — under concurrent requests, two callers can never take the same number or leave a hole in the sequence.
Why Laravel Document Numbering?
Gap-free numbering is a legal requirement for invoices in most jurisdictions: the sequence may not skip values. Getting that right under load is harder than it looks. Most Laravel apps eventually need to answer:
- How do I guarantee invoice numbers never skip a value?
- How do two concurrent requests avoid taking the same number?
- Can each company/tenant keep its own independent sequence?
- How do I reset the counter every year or month?
- What happens to the number if the surrounding transaction rolls back?
Laravel Document Numbering provides a small, database-backed layer for exactly that — it leans on database transactions and row locks rather than hoping races never happen.
Feature Matrix
| Feature | Supported |
|---|---|
Configurable patterns (INV-{YYYY}-{seq:5}) |
✅ |
| Per-scope counters (company / tenant / branch) | ✅ |
Period resets (yearly, monthly, never) |
✅ |
| Gap-free mode (transaction-bound) | ✅ |
| "Has this sequence been used?" across all periods | ✅ |
| Fast-sequential mode | ✅ |
| Concurrency-safe (row locks) | ✅ |
| Eloquent trait, event & facade | ✅ |
| Database-portable (MySQL, PostgreSQL, SQLite) | ⚠️ |
| Octane / FrankenPHP safe | ✅ |
| UI | ❌ |
⚠️ The package writes no engine-specific SQL, but the automated suite runs on SQLite only — where
lockForUpdate()compiles to an empty string. Read Database notes before relying on the row lock in production.
Requirements
- PHP 8.3+
- Laravel 11, 12 or 13
Installation
composer require vimatech/laravel-document-numbering
Publish and run the migration:
php artisan vendor:publish --tag=numbering-migrations php artisan migrate
Publish the config (optional):
php artisan vendor:publish --tag=numbering-config
Configuration
config/numbering.php defines a counter strategy per document type:
use Vimatech\DocumentNumbering\Enums\ResetPolicy; return [ 'connection' => env('DOCUMENT_NUMBERING_CONNECTION'), 'table' => 'document_number_sequences', // Times the allocation transaction is retried on a deadlock / locked error. 'lock_attempts' => 5, 'types' => [ 'invoice' => [ 'pattern' => 'INV-{YYYY}-{seq:5}', 'reset' => ResetPolicy::Yearly, 'gap_free' => true, ], 'quote' => [ 'pattern' => 'QUO-{YYYY}-{seq:5}', 'reset' => ResetPolicy::Yearly, 'gap_free' => false, ], 'credit_note' => [ 'pattern' => 'CN-{YY}{MM}-{seq:4}', 'reset' => ResetPolicy::Monthly, 'gap_free' => true, ], ], ];
Pattern tokens
| Token | Meaning | Example |
|---|---|---|
{YYYY} |
4-digit year | 2026 |
{YY} |
2-digit year | 26 |
{MM} |
2-digit month | 06 |
{seq:n} |
sequence value, zero-padded to n |
00042 |
Everything outside a token is literal text. A sequence longer than its padding
is not truncated ({seq:3} with value 12345 → 12345).
Reset policies
The reset policy decides when the counter restarts at 1, by computing a
period key. Two allocations share a counter only when their
(scope, type, period_key) match.
| Policy | Period key (for 2026-06) | Restarts |
|---|---|---|
ResetPolicy::Never |
all |
never |
ResetPolicy::Yearly |
2026 |
each year |
ResetPolicy::Monthly |
2026-06 |
each month |
Tip: include the matching date token in the pattern (
{YYYY}for yearly,{YY}{MM}for monthly) so reused sequence values stay unique across periods.
Scopes
A scope is an arbitrary string that isolates counters — typically a
company, tenant or branch id. acme and globex each get their own
INV-2026-00001.
When you configure a scope column on a model ($documentNumberScopeColumn),
that attribute must be set before saving. A missing value throws a
LogicException rather than silently falling back to the global scope, which
would otherwise mix one tenant's numbers into another's sequence.
Usage
Via the facade
use Vimatech\DocumentNumbering\Facades\Numbering; $number = Numbering::for($companyId, 'invoice')->next(); // "INV-2026-00001" // Preview the next value without consuming it (advisory under concurrency): $preview = Numbering::for($companyId, 'invoice')->peek(); // Has this sequence ever taken a number, in any period? $engaged = Numbering::for($companyId, 'invoice')->hasEverAllocated();
Has the sequence been used yet?
hasEverAllocated() answers "has this sequence ever consumed a number?" across
every period, which is what you need before letting someone change a
numbering setting — the pattern, the reset policy or gap_free of a type,
or the table and connection the counters live in.
peek() cannot answer it. It only looks at the current period, so under a
yearly reset a sequence used all through last year reads as untouched on
1 January:
// 20 November 2026, after a year of invoices Numbering::for($companyId, 'invoice')->peek(); // "INV-2026-00042" Numbering::for($companyId, 'invoice')->hasEverAllocated(); // true // 1 January 2027, same sequence, nothing allocated yet this year Numbering::for($companyId, 'invoice')->peek(); // "INV-2027-00001" Numbering::for($companyId, 'invoice')->hasEverAllocated(); // true
Two details worth knowing:
- Deleting the documents does not reset it. The counter is monotonic and independent of your models: a number stays consumed once taken, whatever later happens to the document that carries it.
- It is a read, not a lock. Like
peek(), it is advisory under concurrency: an allocation running in another uncommitted transaction is not visible to it. If a settings change must be serialised against allocation, take that lock yourself.
An unknown document type throws rather than returning false, so a typo cannot
silently report an unused sequence — see below.
Configuration errors
next(), peek() and hasEverAllocated() all resolve the type's
configuration before doing anything else, so each of the three can throw:
| Exception | Raised when |
|---|---|
UnknownDocumentType |
the type is absent from numbering.types |
InvalidPattern |
the type's pattern uses a token the compiler does not know |
Both report a configuration the package cannot work with, and neither is
recoverable where it is raised: the fix is in config/numbering.php or in the
type name the caller passed, not in the calling code path.
Every exception the package raises extends
Vimatech\DocumentNumbering\Exceptions\NumberingException, and that is the type
to catch if you want one — as a last-resort guard around the whole operation,
not as flow control. Note that it also covers the allocation-time failures
SequenceLocked and SequenceUnreadable, which next() can raise long after
the configuration has been accepted.
Via the Eloquent trait
Add HasDocumentNumber to a model and tell it which type and scope to use. The
number is assigned automatically on creating.
use Illuminate\Database\Eloquent\Model; use Vimatech\DocumentNumbering\Concerns\HasDocumentNumber; class Invoice extends Model { use HasDocumentNumber; protected string $documentNumberType = 'invoice'; protected string $documentNumberColumn = 'number'; // default: 'number' protected string $documentNumberScopeColumn = 'company_id'; // optional } $invoice = Invoice::create(['company_id' => 42]); $invoice->number; // "INV-2026-00001"
You can replace the property hooks with method overrides for full control:
public function documentNumberType(): string { return 'invoice'; } public function documentNumberScope(): string { return (string) $this->team_id; } public function documentNumberColumn(): string { return 'reference'; }
For gap-free types the trait wraps the first save() in a database
transaction, so the number allocation and the row INSERT commit together — if
the insert fails, the number is released back into the sequence.
Complete Example
use Illuminate\Database\Eloquent\Model; use Vimatech\DocumentNumbering\Concerns\HasDocumentNumber; use Vimatech\DocumentNumbering\Enums\ResetPolicy; // 1. Configure the type (config/numbering.php) 'types' => [ 'invoice' => [ 'pattern' => 'INV-{YYYY}-{seq:5}', 'reset' => ResetPolicy::Yearly, 'gap_free' => true, ], ], // 2. Add the trait to your model class Invoice extends Model { use HasDocumentNumber; protected string $documentNumberType = 'invoice'; protected string $documentNumberScopeColumn = 'company_id'; } // 3. Use it $invoice = Invoice::create(['company_id' => 42]); // company 42 $invoice->number; // "INV-2026-00001" $next = Invoice::create(['company_id' => 42]); $next->number; // "INV-2026-00002" $other = Invoice::create(['company_id' => 99]); // different scope $other->number; // "INV-2026-00001"
Concurrency & gap-free guarantees
Each (scope, type, period_key) owns one row in document_number_sequences.
Allocation runs inside a transaction and takes a lockForUpdate() lock on that
row, so concurrent callers serialise on the row rather than racing the
counter — on engines that implement row locks; see
Database notes. The first allocation for a new period inserts
the row at 0; the unique index on (scope, type, period_key) makes a lost
insert race harmless.
gap_free: true (legally safe, default)
The number is allocated inside the caller's transaction. The row lock is held until that transaction commits, so:
- no other allocation for the same sequence can proceed until you commit, and
- if you roll back, the increment is undone and the number is reused.
This is what invoices need. The cost is contention: the lock is held for the lifetime of the surrounding transaction, so keep those transactions short.
When using the facade directly, wrap your write and the allocation together:
DB::transaction(function () use ($companyId, $payload) { $number = Numbering::for($companyId, 'invoice')->next(); Invoice::create([...$payload, 'number' => $number]); });
The HasDocumentNumber trait does this wrapping for you.
gap_free: false (fast sequential)
The number is committed as soon as it is allocated and is not returned on a later rollback, so gaps are possible. Choose this only for sequences where the law does not require gap-freeness (e.g. internal quote drafts) and throughput matters more than a perfectly dense sequence.
FrankenPHP / Laravel Octane (worker mode)
The package is safe under long-lived worker runtimes (FrankenPHP worker mode, Octane with Swoole/RoadRunner), where the application is booted once and reused across many requests. Specifically:
- The
NumberingManageris a stateless singleton. It holds no per-request data: the document-type configuration is read from the config repository on demand, and the database connection is resolved per call through the connection resolver. It never caches aConnection/PDO handle, so a reconnect between requests (which Octane performs) is picked up automatically. - No accumulating static state. Nothing grows in memory across requests.
- The
HasDocumentNumbertrait registers itscreatinghook once per worker, via Eloquent's standard trait-boot mechanism — there is no per-request re-registration or listener leak. - Correctness is unchanged. Worker mode runs several workers concurrently, exactly like PHP-FPM. Gap-free safety still comes from the database row lock, which serialises allocation across all workers and processes.
No special configuration is required. If you register an event listener for
NumberAllocated, follow the usual Octane guidance and avoid capturing
request-scoped state in long-lived listeners.
Database notes
- MySQL / PostgreSQL:
lockForUpdate()issuesSELECT ... FOR UPDATE; row-level locks are held until commit. Recommended for production. - SQLite: write transactions lock the database file, which serialises writers and provides the same guarantees with coarser granularity. Great for tests and small single-writer apps.
What the test suite establishes, and what it does not. Allocation goes
through Laravel's query builder and contains no engine-specific SQL, so the same
code path runs on all three. What differs is the guarantee underneath it, and
the automated suite exercises only one of them: it runs on SQLite, where
lockForUpdate() compiles to an empty string and the serialisation observed —
including the no-gaps, no-duplicates concurrency test — comes from SQLite's
database-wide write lock. The SELECT ... FOR UPDATE path that MySQL and
PostgreSQL take is not covered by any test in this repository. Treat the row
lock on those engines as designed-for and unproven here rather than verified,
and see CONTRIBUTING for why a second connection holding the
row does not close that gap.
If the database aborts a statement while waiting for the lock (lock-wait
timeout or deadlock), a Vimatech\DocumentNumbering\Exceptions\SequenceLocked
exception is thrown and the transaction is rolled back — no number is consumed.
Events
Vimatech\DocumentNumbering\Events\NumberAllocated is dispatched after each
allocation:
final class NumberAllocated { public string $scope; public string $type; public string $periodKey; public int $sequence; public string $number; }
For gap-free types this fires inside the caller's transaction, so a rollback also discards anything a listener did transactionally.
Testing
composer test # Pest composer format # Pint composer analyse # PHPStan (level max)
The suite includes a concurrency test that forks multiple worker processes against a shared SQLite database and asserts that the allocated numbers contain no duplicates and no gaps.
Design Principles
- Correctness first — gap-free safety comes from database row locks, not optimistic hoping.
- Backend-only, UI agnostic — no controllers, no views, no opinions on your frontend.
- No domain assumptions — works for invoices, quotes, credit notes or any document type you define.
- Laravel-native API — a trait, an event, a facade and a config file.
- No engine-specific SQL — allocation goes through the query builder, so the same code path runs on MySQL, PostgreSQL and SQLite. What each engine's lock guarantees underneath it differs; see Database notes.
- Worker-safe — stateless singleton, no accumulating static state, ready for Octane and FrankenPHP.
Possible Future Extensions
- Per-type custom formatters (callables)
- Daily reset policy
- Numbering audit log
- Filament integration
Future extensions may be released as separate packages to keep the core small and focused.
Contributing
Contributions are welcome.
Please ensure:
- Tests pass (
composer test) - PHPStan passes (
composer analyse) - Code style is formatted with Pint (
composer format)
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our Security Policy for reporting vulnerabilities.
License
The MIT License (MIT). Please see License File for more information.
Credits
Built and maintained by Vimatech. Created by Adel Zemzemi.