justinholtweb / craft-abacus
Sequential order numbers for Craft Commerce — real counters with prefixes, padding, date tokens and periodic resets, allocated under a lock so two checkouts can never share a number.
Package info
github.com/justinholtweb/craft-abacus
Type:craft-plugin
pkg:composer/justinholtweb/craft-abacus
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
README
Sequential order numbers for Craft Commerce 5. Real counters, allocated under a lock, with a ledger that can tell you where every number went.
Out of the box Commerce hands each completed order a reference derived from its cart hash —
4b55768. It is unique, and it is useless to a customer reading it down the phone or an
accountant reconciling a spreadsheet. Abacus replaces it with a number you designed:
INV-2026-01042, SO-000317, 2026-08-0044.
It writes to Commerce's own order.reference, so the new number shows up everywhere the old one
did — the order index, emails, PDFs, {{ order.reference }} — without a single template change.
Requirements
- Craft CMS 5.3+
- Craft Commerce 5.0+
- PHP 8.2+
Installation
composer require justinholtweb/craft-abacus php craft plugin/install abacus
Abacus installs with one disabled scheme per store. Nothing changes until you open Abacus → Schemes, set a format and switch it on — installing a plugin should never silently renumber a live store.
Editions
Lite is free and covers one sequence per store. Pro adds everything that gets interesting once you have more than one kind of order.
| Lite | Pro | |
|---|---|---|
| Price | Free | $39, $19/year renewal |
| One sequence per store | ✅ | ✅ |
| Format tokens — prefix, suffix, date, store, site | ✅ | ✅ |
| Zero-padding, custom start, custom increment | ✅ | ✅ |
| Counter resets — daily, monthly, yearly | ✅ | ✅ |
| Live preview of the next three numbers | ✅ | ✅ |
| Panel on Commerce's order screen | ✅ | ✅ |
craft.abacus.* Twig API |
✅ | ✅ |
| Console: status, preview, set | ✅ | ✅ |
| Several schemes per store, matched by order condition | — | ✅ |
| Skip lists — reserve numbers and ranges | — | ✅ |
| Reclaim numbers from orders that never completed | — | ✅ |
| Backfill existing orders, with dry run and undo | — | ✅ |
| Allocation ledger in the CP, with CSV export | — | ✅ |
The format
One field, with {number} standing in for the counter value:
| Token | Becomes |
|---|---|
{number} |
The counter value, zero-padded to the width you set |
{yyyy} {yy} |
Year — 2026, 26 |
{mm} {m} |
Month — 08, 8 |
{dd} {d} |
Day — 04, 4 |
{store} |
The store's handle |
{site} |
The order's site handle |
The dates are the order's dates, not today's — which is what makes a backfill of a two-year-old order land in the right year.
A format without {number} is refused, and so is an unknown token: a typo like {yyy} would
otherwise be written through literally onto every invoice you print.
Counters and resets
A scheme's counter is not a single number. It is one counter per reset period, so "reset every month" actually means something:
| Reset | Counter buckets |
|---|---|
| Never | one, forever |
| Every year | 2026, 2027, … |
| Every month | 2026-08, 2026-09, … |
| Every day | 2026-08-18, 2026-08-19, … |
If you reset, put a date token in the format. {number} alone with a monthly reset produces 1
in January and 1 again in February — Abacus will step past the collision rather than issue a
duplicate, but the sequence will not be what you meant.
How a number is allocated
Every number — from checkout, from a backfill, from the console — comes out of one method, holding a mutex named after the scheme:
- Take the counter for this scheme and this reset period.
- Step over anything on the skip list.
- Render the reference and check it is free, against both the orders table and the ledger.
- Write the new counter value and the ledger row in one transaction.
Commerce fires EVENT_BEFORE_COMPLETE_ORDER after it has generated its own reference and before
it saves the order, so Abacus replaces $order->reference in place. One save, no second write,
and if Abacus declines the order keeps the reference Commerce already gave it.
That fallback is the default and it is deliberate. A locked counter should not be able to fail a checkout. If you would rather the order failed than carried the wrong number, set If a number cannot be allocated to Fail the order completion.
Know what you are choosing there. Commerce wraps markAsComplete() in a mutex named after the
order but has no try/finally around the event Abacus listens on, so an exception thrown from it
escapes with that lock still held. The customer sees a failed checkout and that same order cannot
be retried until the lock lapses with the request. Use throw only where a wrong-format number
is genuinely worse than a stuck cart — reconciliation against an external accounting system, say.
Gaps
Abacus allocates before the order element is saved. If that save then fails, the number is spent and the sequence has a hole in it. Abacus is honest about this rather than pretending:
- Every allocation is recorded, so a hole is visible in Abacus → Numbers.
- Pro's Reuse abandoned numbers hands that number to the next order instead, once the allocation has sat unused past the reclaim window (an hour by default, so a customer still finishing payment cannot have their number taken).
php craft abacus/numbers/reclaimsweeps them by hand.
The reclaim window matters. Set it to a minute and you will eventually take a number from an order that was merely slow.
Several schemes (Pro)
Schemes are tried in order and the first whose condition matches numbers the order. A scheme with no condition matches everything, so keep the catch-all last:
| Order | Scheme | Condition |
|---|---|---|
| 1 | Wholesale | Customer is in the Wholesale group |
| 2 | Big orders | Total ≥ 1000 |
| 3 | Orders | — |
The condition builder is Commerce's own, evaluated against the order as it completes — so unlike a cart-time condition it can ask about order status, payment and completion date.
A condition that throws is logged and skipped rather than allowed to take a checkout down.
Backfill (Pro)
Abacus → Backfill renumbers orders that completed before Abacus arrived. It always rehearses first: pick a window, press Preview, and read the table of now → would become before anything is written.
php craft abacus/backfill/preview --limit=500 --dateFrom=2026-01-01 php craft abacus/backfill/run --limit=500 --dateFrom=2026-01-01 php craft abacus/backfill/revert --batch=<id>
Orders are renumbered in completion order, so the sequence means what a sequence should mean. Each run is grouped under a batch ID, and each allocation records the reference the order carried before — which is what makes Put back possible.
Two things worth knowing before you run it:
- A run recomputes rather than replaying the preview. An order that completes between the rehearsal and the run shifts the sequence honestly instead of colliding with it.
- Reverting does not rewind the counter. The numbers the batch spent stay spent. Rewinding would mean reissuing a number that may already be printed on somebody's invoice, and a duplicate is a worse problem than a gap.
A backfill re-saves every order it touches so the search index follows the new reference. Anything else listening for order saves will hear about all of them, so run large backfills when that is acceptable.
Twig
{{ order.reference }} {# Abacus wrote it here — this is the number #}
{{ craft.abacus.numberForOrder(order) }} {# null if Abacus never numbered this order #}
{{ craft.abacus.allocationForOrder(order).previousReference }}
{{ craft.abacus.next('orderNumbers') }} {# what the next order would most likely get #}
{{ craft.abacus.preview('orderNumbers', 5) }}
Nothing in the Twig API spends a number.
Console
php craft abacus/numbers/status # every counter, and the next reference php craft abacus/numbers/preview --scheme=orderNumbers --count=10 php craft abacus/numbers/set --scheme=orderNumbers --number=5000 php craft abacus/numbers/reclaim # Pro php craft abacus/backfill/preview --limit=200 # writes nothing php craft abacus/backfill/run --limit=200 # Pro php craft abacus/backfill/batches php craft abacus/backfill/revert --batch=<id> # Pro
abacus/numbers/set refuses to move a counter backwards, for the same reason a revert does not.
Permissions
- Manage numbering schemes — the Schemes section
- View allocated numbers — the ledger and the order-screen panel
- Renumber existing orders — Backfill
Settings
| Setting | Default | Why you would change it |
|---|---|---|
| Number orders as they complete | on | Off leaves live checkouts alone; Abacus stays available for backfills and console work |
| If a number cannot be allocated | keep Commerce's reference | Set to fail only if a wrong-format number is worse than a stuck cart — see above |
| Lock timeout | 5s | Raise it on a store with heavy simultaneous checkouts |
| Reclaim after | 60 min | Must be longer than your slowest payment flow |
| Backfill batch size | 100 | The default the Backfill form opens with, and the bound for a console run with no --limit |
| Log allocations | off | On while setting up; noisy afterwards |
What Abacus does not touch
order.number — the 32-character cart hash Commerce uses to identify a cart in URLs and cookies.
It is a security token, not a customer-facing number, and rewriting it would break carts. Abacus
only ever writes order.reference.