Search by

elrayn / auto-number

elraynn

Generate sequential document numbers (invoice, PO, etc.) without the race condition in SELECT MAX() + 1

Package info

github.com/elraynn/auto-number

pkg:composer/elrayn/auto-number

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-09-22 06:39 UTC

This package is auto-updated.

Last update: 2026-09-22 06:39:39 UTC


README

Generate sequential document numbers (invoice, PO, delivery order, whatever) without the classic race condition.

If you've built this before, it probably looks like this:

$q = "SELECT MAX(RIGHT(no_transaksi,4)) AS kd_max FROM tinvoice
      WHERE MONTH(tanggal)='$bulan' AND YEAR(tanggal)='$tahun'";

...read the max, add one in PHP, save it. It works until two requests hit it close enough together that both read the same max before either writes back, and you get two invoices with the same number. This isn't hypothetical, it's a standard concurrency bug and it shows up under real load.

This library replaces that with an atomic counter (MySQL's LAST_INSERT_ID() trick under the hood), so two concurrent calls can't ever get the same number.

Install

composer require elrayn/auto-number

Usage

use Elrayn\AutoNumber\AutoNumber;

$number = AutoNumber::make($pdo)
    ->key('invoice')
    ->prefix('INV')
    ->resetMonthly()
    ->digits(4)
    ->next();

// "INV-202609-0001", then "INV-202609-0002", and so on.
// Resets back to 0001 automatically in October.

The counters table (auto_number_counters by default) is created automatically on first use. Different key() values get independent counters, so invoice and po won't collide.

Options

Method Default Description
key(string) 'default' Which counter this is. Use a different key per document type.
prefix(string) none Prepended to the number, e.g. INV.
digits(int) 4 Zero-padding width. A value past this width isn't truncated (99999 stays 99999, not cut to 4 digits).
separator(string) '-' Joins prefix, period, and number.
resetDaily() / resetMonthly() / resetYearly() none (never resets) Starts the counter back at 1 for each new period.
table(string) 'auto_number_counters' Override if the default name collides with something.

How the atomic part works

On MySQL:

INSERT INTO auto_number_counters (name, value) VALUES (:name, 1)
ON DUPLICATE KEY UPDATE value = LAST_INSERT_ID(value + 1)

LAST_INSERT_ID(expr) is a MySQL extension that sets the session's last-insert-id to whatever expr evaluates to, and the whole statement runs under a row lock. Two concurrent calls can't both read the same value and increment from it, unlike SELECT MAX() then a separate UPDATE.

SQLite is also supported (via INSERT ... ON CONFLICT ... RETURNING), mainly so the test suite doesn't need a real MySQL server.

License

MIT