DEV Community

Cover image for 🚀 Stop Guessing Your IDs: Generate Smart, Human-Friendly Sequence Numbers in Laravel
Raju Koyilandy
Raju Koyilandy

Posted on

🚀 Stop Guessing Your IDs: Generate Smart, Human-Friendly Sequence Numbers in Laravel

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;
Enter fullscreen mode Exit fullscreen mode

or:

$next = Invoice::count() + 1;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run migrations:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Optionally publish the config:

php artisan vendor:publish --tag=config --provider="Hatchyu\\Sequence\\SequenceServiceProvider"
Enter fullscreen mode Exit fullscreen mode

Optionally publish the migration:

php artisan vendor:publish --tag=sequence-migrations --provider="Hatchyu\\Sequence\\SequenceServiceProvider"
Enter fullscreen mode Exit fullscreen mode

✅ 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();
});
Enter fullscreen mode Exit fullscreen mode

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, ...
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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, ...
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Now you get:

  • 20260001
  • 20260002
  • 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, ...
Enter fullscreen mode Exit fullscreen mode

Separate numbering per tenant or branch

$value = DB::transaction(function () use ($tenantId, $branchId) {
    return sequence('receipt_number')
        ->groupBy($tenantId, $branchId)
        ->padLength(5)
        ->next();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is a great fit when you want:

  • INV/20260322/0001
  • PO/2026/0012
  • ORD-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();
});
Enter fullscreen mode Exit fullscreen mode

That makes it possible to build custom outputs like:

  • TIC-0001-XYZ
  • SUP-0042-ABC
  • BOOK-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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
}
Enter fullscreen mode Exit fullscreen mode

⚙️ 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),
];
Enter fullscreen mode Exit fullscreen mode

Here is what each option controls:

table

The database table that stores counters.

Default:

'table' => 'sequences'
Enter fullscreen mode Exit fullscreen mode

connection

The database connection used by the sequence model.

Default:

'connection' => null
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you replace it, your model must:

  • extend Eloquent Model
  • point to a table with name, group_by, and last_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
Enter fullscreen mode Exit fullscreen mode

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,
    ]);
});
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

Support tickets

DB::transaction(fn () => sequence('tickets')
    ->padLength(5)
    ->format(fn (string $number): string => "TIC-{$number}")
    ->next()
);
Enter fullscreen mode Exit fullscreen mode

School admission numbers

DB::transaction(fn () => sequence('admission')
    ->groupBy(date('Y'))
    ->prefix('ADM' . date('Y'))
    ->padLength(4)
    ->next()
);
Enter fullscreen mode Exit fullscreen mode

Branch-wise receipt numbers

DB::transaction(fn () => sequence('receipts')
    ->groupBy($branchId)
    ->prefix('RCPT-')
    ->padLength(6)
    ->next()
);
Enter fullscreen mode Exit fullscreen mode

Daily token numbers

DB::transaction(fn () => sequence('daily_tokens')
    ->groupByDay()
    ->padLength(3)
    ->next()
);
Enter fullscreen mode Exit fullscreen mode

🧠 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() and format() for display only.
  • Match the transaction connection with sequence.connection if 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)