Auto-increment IDs work for databases, but not for business-facing numbers like invoices, orders, or tickets. When you need readable formats, yearly resets, or branch-wise counters, naive solutions break fast under concurrency. hatchyu/laravel-sequence (GitHub repository) gives Laravel a transaction-safe way to generate smart, customizable sequence numbers.
Here’s what the package supports:
- concurrency-safe increments
- prefixes and zero padding
- custom format templates
- callback-based formatting
- grouped counters by tenant, branch, year, day, or parent model
- bounded ranges and cycling sequences
- automatic assignment on Eloquent models
- event dispatching when a number is reserved
If your app creates invoices, orders, tickets, admissions, customer codes, receipt numbers, or any other human-readable running number, this package is designed for that problem.
😵 Why Sequence Numbers Break in Real Laravel Apps
A lot of projects start with something like this:
$next = Invoice::max('number') + 1;
or:
$next = Invoice::count() + 1;
That works in development. It even works in production for a while.
Then traffic increases.
Two requests arrive at nearly the same moment.
Both read the same current value.
Both generate the same next number.
Now you have duplicate invoice numbers.
This gets even worse when you need:
- separate numbering per tenant
- yearly resets
- date-based invoice formats
- zero-padded output
- multiple app servers
- queue workers writing records in parallel
What sounds like “just increment a number” quickly becomes a data integrity problem.
🛡️ How laravel-sequence Solves It
By default, this package stores sequence state in a dedicated sequences table and uses:
- a unique key on
(name, group_by) - row-level locking with
SELECT ... FOR UPDATE - database transactions
That means the package does not “guess” the next number. It reserves it safely inside the database transaction.
That is the difference between:
- “probably fine”
- and “safe under concurrency”
📦 Installation
Install the package:
composer require hatchyu/laravel-sequence
Run migrations:
php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=config --provider="Hatchyu\\Sequence\\SequenceServiceProvider"
Optionally publish the migration:
php artisan vendor:publish --tag=sequence-migrations --provider="Hatchyu\\Sequence\\SequenceServiceProvider"
✅ Supported Stack
- PHP
^8.3 - Laravel
^10 || ^11 || ^12 || ^13
⚠️ The Most Important Rule
Always generate sequence numbers inside a DB transaction.
use Illuminate\Support\Facades\DB;
$number = DB::transaction(function () {
return sequence('invoice')->next();
});
If you call it outside a transaction, the package throws an exception on purpose. That guardrail prevents subtle concurrency bugs.
1️⃣ Generate Your First Sequence
use Illuminate\Support\Facades\DB;
$value = DB::transaction(function () {
return sequence('orders')->next();
});
// 1, 2, 3, ...
The sequence('orders') call identifies the counter by name. Every time you call ->next() inside a transaction, the package reserves the next number for that sequence.
2️⃣ Add Prefixes and Zero Padding
$value = DB::transaction(function () {
return sequence('customer_code')
->prefix('CU')
->padLength(4)
->next();
});
// CU0001, CU0002, CU0003...
This is perfect for:
- customer codes
- admission numbers
- product references
- batch IDs
3️⃣ Add Dynamic Output Without Resetting the Counter
Sometimes you want the current year in the visible output:
$value = DB::transaction(function () {
return sequence('batch_code')
->prefix(date('Y'))
->padLength(3)
->next();
});
// 2026001, 2026002, ...
Important detail:
prefix() changes display only.
It does not create a separate counter for each year.
If you want the counter to reset per year, you must group the sequence as well.
4️⃣ Reset the Counter by Year, Month, Day, Tenant, or Branch
This is where the package gets really useful.
Reset every year
$value = DB::transaction(function () {
return sequence('invoice')
->prefix(date('Y'))
->padLength(4)
->groupByYear()
->next();
});
Now you get:
2026000120260002- next year:
20270001
Reset every day
$value = DB::transaction(function () {
return sequence('daily_invoice')
->groupByDay()
->format('INV/' . date('Ymd') . '/?')
->padLength(4)
->next();
});
// INV/20260322/0001, INV/20260322/0002, ...
Separate numbering per tenant or branch
$value = DB::transaction(function () use ($tenantId, $branchId) {
return sequence('receipt_number')
->groupBy($tenantId, $branchId)
->padLength(5)
->next();
});
Each (tenantId, branchId) pair gets its own isolated counter.
Group by models
If the grouping context is already represented by persisted Eloquent models:
$value = DB::transaction(function () use ($tenant, $branch) {
return sequence('branch_invoice')
->belongsTo($tenant, $branch)
->padLength(4)
->next();
});
belongsTo() is just an expressive alias for groupBy() when grouping by related models.
5️⃣ Format the Output with Templates
You can define a format string with a ? placeholder:
$value = DB::transaction(function () {
return sequence('invoice')
->format('INV/' . date('Ymd') . '/?')
->padLength(4)
->next();
});
// INV/20260322/0001
This is a great fit when you want:
INV/20260322/0001PO/2026/0012ORD-ONLINE-000045
Again, formatting controls the final string only. It does not change the underlying counter scope unless you also use groupBy().
6️⃣ Format the Output with a Callback
Template strings are useful, but sometimes you need more control.
This package also supports callback-based formatting:
use Illuminate\Support\Str;
$value = DB::transaction(function () {
return sequence('tickets')
->padLength(4)
->format(fn (string $number): string => "TIC-{$number}-" . Str::random(3))
->next();
});
That makes it possible to build custom outputs like:
TIC-0001-XYZSUP-0042-ABCBOOK-0008-WEB
The callback receives the already padded numeric part, which means you can focus entirely on assembling the final display string.
7️⃣ Use Custom Increment Steps
The default step is 1, but sometimes you want a different increment:
$first = DB::transaction(fn () => sequence('batch')->step(5)->next());
$second = DB::transaction(fn () => sequence('batch')->step(5)->next());
$third = DB::transaction(fn () => sequence('batch')->step(5)->next());
// 1, 6, 11
This can be useful in specialized numbering systems where increments are intentionally spaced.
8️⃣ Configure Ranges, Overflow, and Cycling
The package supports minimum and maximum bounds.
Bounded range
use Hatchyu\Sequence\Support\SequenceConfig;
$value = DB::transaction(function () {
return sequence('coupon')
->config(fn (SequenceConfig $config) => $config->bounded(1000, 1999))
->next();
});
Once the sequence reaches the maximum, it throws a SequenceOverflowException.
Cycle when max is reached
$value = DB::transaction(function () {
return sequence('session_code')
->config(fn (SequenceConfig $config) => $config->cyclingRange(1, 10))
->next();
});
This gives:
1, 2, 3 ... 10, 1, 2 ...
Full range control
You can also use:
range(int $min, ?int $max = null)bounded(int $min, int $max)cyclingRange(int $min, int $max)cycle()throwOnOverflow()
9️⃣ Auto-Assign Sequence Numbers on Eloquent Models
One of my favorite parts of the package is the HasSequence trait.
Instead of manually assigning numbers everywhere, you can define sequence columns directly on the model.
use Illuminate\Database\Eloquent\Model;
use Hatchyu\Sequence\Traits\HasSequence;
use Hatchyu\Sequence\Support\SequenceConfig;
use Hatchyu\Sequence\Support\SequenceColumnCollection;
class CustomerProfile extends Model
{
use HasSequence;
protected function sequenceColumns(): SequenceColumnCollection
{
return SequenceColumnCollection::collection()
->column(
'customer_code',
SequenceConfig::create()
->prefix('CU')
->padLength(4)
->groupBy($this->branch_id)
);
}
}
Now when a record is created inside a transaction, the package assigns the sequence automatically during the creating event.
If the target column already has a non-empty value, the trait leaves it untouched instead of overwriting it.
Model example with formatted invoice numbers
protected function sequenceColumns(): SequenceColumnCollection
{
return SequenceColumnCollection::collection()
->column(
'invoice_number',
SequenceConfig::create()
->groupByDay()
->format('INV/' . date('Ymd') . '/?')
->padLength(4)
);
}
Multiple sequence columns on the same model
protected function sequenceColumns(): SequenceColumnCollection
{
return SequenceColumnCollection::collection()
->column(
'admission_number',
SequenceConfig::create()
->prefix('ADM')
->padLength(3)
)
->column(
'attendance_number',
SequenceConfig::create()
->groupBy($this->tenant_id, $this->academic_year, $this->class_id)
);
}
⚙️ Configuration Options
After publishing the config file, you can customize:
return [
'table' => env('SEQUENCE_TABLE', 'sequences'),
'connection' => env('SEQUENCE_CONNECTION', null),
'model' => Hatchyu\Sequence\Models\Sequence::class,
'strict_mode' => env('SEQUENCE_STRICT_MODE', true),
];
Here is what each option controls:
table
The database table that stores counters.
Default:
'table' => 'sequences'
connection
The database connection used by the sequence model.
Default:
'connection' => null
If you set this, make sure the surrounding transaction uses the same connection.
model
The Eloquent model class used for sequence storage.
Default:
'model' => Hatchyu\Sequence\Models\Sequence::class
If you replace it, your model must:
- extend Eloquent
Model - point to a table with
name,group_by, andlast_number - preserve the unique behavior required for safe concurrency
strict_mode
When enabled, the package validates:
- sequence name length
- group token length
- configured model validity
This helps fail early with clear exceptions instead of vague database errors.
🧩 Complete API Overview
Direct sequence builder methods
sequence(string $name)groupBy(int|string|Model ...$groups)belongsTo(Model ...$models)groupByYear()groupByMonth()groupByDay()prefix(string $prefix)padLength(int $length)step(int $step)format(string|Closure $format)config(Closure $callback)next(): string
SequenceConfig methods
SequenceConfig::create()prefix(string $prefix)format(string|Closure $format)padLength(int $length)step(int $step)groupBy(int|string|Model ...$groups)belongsTo(Model ...$models)groupByYear()groupByMonth()groupByDay()range(int $min, ?int $max = null)bounded(int $min, int $max)cyclingRange(int $min, int $max)cycle()throwOnOverflow()
📣 Event Support
Whenever a sequence number is reserved, the package dispatches:
Hatchyu\Sequence\Events\SequenceAssigned
You can listen for it like this:
use Hatchyu\Sequence\Events\SequenceAssigned;
use Illuminate\Support\Facades\Event;
Event::listen(SequenceAssigned::class, function (SequenceAssigned $event) {
logger()->info('Sequence assigned', [
'name' => $event->name,
'rawNumber' => $event->rawNumber,
'sequenceNumber' => $event->sequenceNumber,
'groupByKey' => $event->groupByKey,
]);
});
This is useful for:
- audit logs
- business activity tracking
- debugging sequence behavior
- emitting domain events or notifications
🤔 Why Not Just Use COUNT() or MAX()?
The real advantage is simple: it solves the edge cases that usually appear later in production, when fixing them is much more painful.
1. It is transaction-safe
This package is designed around database transactions and row-level locking. That is the core feature.
2. It supports grouped counters cleanly
Many apps need:
- yearly invoice resets
- branch-wise receipt numbers
- tenant-specific order sequences
- class-wise attendance numbers
This package handles those use cases without custom ad hoc logic.
3. It separates counter scope from display format
That sounds subtle, but it matters a lot.
You can:
- show the year in the output
- reset by day
- group by tenant
- pad numbers for readability
without mixing display concerns with storage concerns.
4. It supports model-level automation
HasSequence reduces repetitive boilerplate and keeps numbering logic close to the model that owns it.
5. It has flexible formatting
You are not limited to one rigid pattern.
You can use:
- prefix + padding
- format templates
- full callback formatting
6. It handles overflow scenarios
You can choose whether a sequence should:
- throw on overflow
- cycle back to the minimum
That is especially useful for coupon ranges, batch windows, seat ranges, or limited code spaces.
7. It is package-friendly for real business apps
This package fits common business needs:
- invoices
- admissions
- patient IDs
- receipts
- order references
- internal tracking codes
🌟 Why Use This Package Over Simpler Alternatives
Different approaches solve different problems. Some tools focus only on:
- auto-incrementing IDs
- slug generation
- simple prefixes
- per-model counters without real concurrency guarantees
What makes hatchyu/laravel-sequence on GitHub stand out is the combination of:
- transaction-first design
- explicit transaction enforcement
- row-level locking
- grouped counters
- formatting templates
- callback formatting
- sequence ranges and cycling
- Eloquent trait integration
- event dispatching
- clean, focused API
In short, this package is built not just for “increment a number,” but for business-safe numbering in Laravel.
🏷️ Real-World Laravel Use Cases
Invoices
DB::transaction(fn () => sequence('invoice')
->groupByYear()
->format('INV/' . date('Y') . '/?')
->padLength(5)
->next()
);
Support tickets
DB::transaction(fn () => sequence('tickets')
->padLength(5)
->format(fn (string $number): string => "TIC-{$number}")
->next()
);
School admission numbers
DB::transaction(fn () => sequence('admission')
->groupBy(date('Y'))
->prefix('ADM' . date('Y'))
->padLength(4)
->next()
);
Branch-wise receipt numbers
DB::transaction(fn () => sequence('receipts')
->groupBy($branchId)
->prefix('RCPT-')
->padLength(6)
->next()
);
Daily token numbers
DB::transaction(fn () => sequence('daily_tokens')
->groupByDay()
->padLength(3)
->next()
);
🧠 Best Practices
- Always wrap generation inside
DB::transaction(). - Keep transactions short to reduce lock contention.
- Use
groupBy()whenever numbering should reset by context. - Use
prefix()andformat()for display only. - Match the transaction connection with
sequence.connectionif you customize it. - Prefer model-based sequence definitions when the rule belongs to the model lifecycle.
🎯 Final Thoughts
Sequence generation looks like a tiny feature until it breaks in production.
Then it becomes a trust problem:
- duplicate invoice numbers
- broken audit trails
- manual corrections
- support tickets
- accounting headaches
hatchyu/laravel-sequence on GitHub exists to solve that problem properly in Laravel.
If you need sequence numbers that are:
- readable
- grouped
- customizable
- safe under concurrency
- ready for real production workflows
this package gives you a clean and reliable way to do it.
Reliable sequence generation is one of those backend details that looks small until the day it causes duplicate invoices, broken receipts, or manual cleanup work.
Top comments (0)