laravel-serial-number maintained by boneybone
boneybone/laravel-serial-number
A running/serial number generator for Laravel that is safe under concurrent requests and driven by patterns, e.g. ACME/IPR/2026/00042.
Install
composer require boneybone/laravel-serial-number:^1.0
php artisan vendor:publish --tag=serial-number-migrations
php artisan migrate
Optional: php artisan vendor:publish --tag=serial-number-config.
Releases are tagged (v1.0.0, …), so composer update only brings releases, never work in progress on main.
Apps that installed it from the private GitLab repository can remove the laravel-serial-number entry from repositories in their composer.json; the next composer update gets it from Packagist.
Usage
Facade
use Boneybone\SerialNumber\Facades\SerialNumber;
SerialNumber::next('invoice', ['prefix' => 'INV-', 'length' => 5]); // INV-00001
SerialNumber::next('pr', [
'pattern' => '{COMPANY}/IPR/{YEAR}/{SEQ:5}',
'tokens' => ['company' => 'ACME'],
'scope' => $companyId, // separate counter per company
'reset' => 'yearly', // never | yearly | monthly | daily
]); // ACME/IPR/2026/00001
SerialNumber::preview('invoice', ['prefix' => 'INV-', 'length' => 5]); // INV-00002 (not consumed)
Model trait
use Boneybone\SerialNumber\Concerns\HasSerialNumber;
class PurchaseOrder extends Model
{
use HasSerialNumber;
public function serialNumberColumn(): string
{
return 'po_number';
}
public function serialNumberOptions(): array
{
return [
'pattern' => '{COMPANY}/PO/{YEAR}/{SEQ:5}',
'tokens' => ['company' => $this->company?->name],
'scope' => $this->company_id,
'reset' => 'yearly',
];
}
// Optional: assign automatically on create
public function shouldAutoAssignSerialNumber(): bool
{
return true;
}
}
$po->assignSerialNumber()->save(); // or $po->generateSerialNumber()
The trait checks the model's column (all global scopes ignored) and skips numbers that already exist.
Options
| Option | Description |
|---|---|
pattern |
Tokens: {SEQ}, {SEQ:n}, {YEAR}, {YY}, {MONTH}, {DAY}, plus any custom tokens. |
prefix / suffix |
Alternative to pattern: becomes prefix{SEQ}suffix. |
length |
Default zero-padding for {SEQ} (config serial-number.length, default 6). |
tokens |
Custom token values. Empty values throw, so you never get /IPR/2026/.... |
scope |
Appended to the counter key. Empty values throw. |
reset |
never (default), yearly, monthly or daily. |
date |
The date used for both the reset period and the date tokens (default now()). |
unique |
fn (string $candidate): bool. Rejected candidates are skipped. |
max_attempts |
Maximum number of skips before SerialNumberExhausted is thrown (default 50). |
seed |
Starting last_number (int or callable) when a counter is first created, e.g. the max existing number. |
The counter key is {key}[:{scope}][:{period}], e.g. purchase_order:3:2026.
Guarantees and notes
- A new counter row is created atomically (
insertOrIgnore) and then locked withSELECT ... FOR UPDATE, so concurrent requests never share a number. SQLite ignores row locks. - Gapless numbering: call it inside your own
DB::transaction()that also saves the record. If the save fails, the counter increment rolls back too, and the row lock is held until your transaction commits. - Add a unique index on the business column (e.g.
po_number) as a final safety net at the database level. - The
SerialNumberGeneratedevent is dispatched after the transaction commits.
Developing the package
To try changes in an app before pushing them, point the app at your local checkout:
composer config repositories.laravel-serial-number '{"type": "path", "url": "../laravel-serial-number", "options": {"symlink": true}}'
composer require "boneybone/laravel-serial-number:*@dev"
Remove that repositories entry and switch back to ^1.0 before committing the app.
Testing
composer test
Releasing
- Merge into
main. - Tag it and push the tag:
git tag -a v1.1.0 -m "v1.1.0" && git push origin v1.1.0. - The GitLab push mirror copies
mainand the tag to the public repository Packagist reads from. Apps get it withcomposer update boneybone/laravel-serial-number.