hatchyu/laravel-sequence
A Laravel package for generating customizable, concurrency-safe sequential numbers for invoices, orders, and other records. Database-backed sequences support configurable formats, prefixes, reset schedules, and easy model integration, helping you create human-readable IDs without collisions.
Install the package and its migration scaffolding first:
composer require hatchyu/laravel-sequence
php artisan vendor:publish --tag=sequence-config
php artisan vendor:publish --tag=sequence-migrations
php artisan migrate
Publish the config before migrations — the config file drives the sequences table name and default column layout, and changing it after migration means another php artisan migrate:fresh (or manual ALTER) in dev.
First use case: generate a daily invoice number for a new order. Wrap the number fetch inside the same DB transaction that persists the model, so a failed insert doesn't burn a sequence value:
use Illuminate\Support\Facades\DB;
use Hatchyu\LaravelSequence\Facades\Sequence;
DB::transaction(function () {
$number = Sequence::next('invoice', group: now()->format('Y-m'));
return Invoice::create([
'number' => $number,
'user_id' => auth()->id(),
]);
});
The package stores counters in a sequences table with a unique (name, group) index, and obtains the next value via SELECT ... FOR UPDATE inside the surrounding transaction — this is the part you cannot hand-roll with SELECT MAX(id)+1 and expect to survive concurrent requests. If you already have a working next() call, you're 90% done.
Per-tenant / per-year grouping. The group parameter is the escape hatch for every "reset on the first of the year" requirement you'll ever get. Model it as "{$tenantId}-{$year}" (or a period key) rather than tracking a year column on the sequence row — grouping keeps the unique index clean and lets you archive old groups whole:
$group = sprintf('%d-%s', tenant()->id, now()->format('Y'));
$po = Sequence::next('purchase_order', group: $group);
// → 2026-000142
Formatting without string math in your models. Let the formatter live close to the sequence definition. If the package exposes a format template (padding, prefix, date tokens), define it once per sequence name in config and call Sequence::format('invoice', $raw). Avoid str_pad($n, 6, '0', STR_PAD_left) scattered across controllers — that's the thing you'll regret at 500k rows when 000999 becomes 001000 and half your reports sort lexically.
Model trait integration. For models that "own" a sequence (an Order with an order_no, a Ticket with a ticket_ref), use the provided trait and hook into creating so the number is assigned even when the record is created from a seeder, factory, or queue job:
use Hatchyu\LaravelSequence\Concerns\HasSequence;
class Order extends Model
{
use HasSequence;
protected string $sequenceName = 'order';
protected string $sequenceColumn = 'order_no';
protected function sequenceGroup(): ?string
{
return $this->tenant_id
? "{$this->tenant_id}-" . now()->format('Y')
: null;
}
}
The trait should assign the value inside the model's creating event, which means the outer create() call must be inside a transaction — otherwise the FOR UPDATE lock is released as soon as the select completes and you can still race.
Idempotent queue jobs. When generating numbers in a job, use ShouldBeUnique on the job and still keep the sequence lookup inside DB::transaction. Lock contention on a hot sequence (invoice at year-end) is real; consider one sequence per shard (invoice-{shard}) and concat at display time if you expect bursts above a few hundred/second.
Backfilling / importing. Turn off the trait when importing historical records by setting the sequence column explicitly and using Order::withoutEvents(fn () => ...) or by calling $model->sequenceColumn conditionally in creating. Don't let the trait overwrite imported numbers.
Testing. Sequences are stateful. Use RefreshDatabase (or at minimum truncate sequences in setUp) so a test that asserts INV-000001 doesn't collide with an earlier test that already consumed it. Add a dedicated concurrency test that spawns two short transactions against the same group and asserts distinct values — it's the one test that justifies the package's existence.
Sequence::next() outside DB::transaction is not concurrency-safe — the FOR UPDATE row lock is released at statement end. If you call it in a controller action that subsequently fails, you will have skipped a number. That may be acceptable (invoices are usually fine with gaps) or not (receipts often aren't — store the number only on success, and accept gaps rather than trying to "roll back" a sequence).Order and an Invoice will lock those sequences in insert order. If two code paths lock the same two sequence names in opposite orders you'll get Deadlock found. Consistency trick: always call sequences in a deterministic (alphabetical) order, or reserve all needed numbers up front at the top of the transaction.REPEATABLE READ and gap locks. If your sequences table has secondary indexes used in the SELECT ... FOR UPDATE, InnoDB may gap-lock rows you didn't intend. Keep the lookup index strictly (name, group) and make it UNIQUE.php artisan config:cache will freeze the sequence definitions. If you're adding a new sequence name at runtime (uncommon but happens with tenant onboarding), either bust the config cache or define sequences in the DB, not the config file.UNIQUE on (name, group) depending on version — verify before relying on it. Without the unique index, a lost race between two first-inserts for a brand-new group silently creates duplicate counter rows.paratest runs suites in separate processes against a shared DB — sequences become a shared mutable resource. Give each worker its own DB (DB_DATABASE=testing_{$token}) rather than trying to lock sequences in tests.next($name, $group) contract, so bind your own implementation rather than forking. The HasSequence trait usually calls the facade, so a container rebind is enough to swap backends without touching models.group key) will bite you. Keep the auto-increment id for referential integrity and use the sequence number for display and external communication.How can I help you explore Laravel packages today?