Concurrency and integrity
Two people saving at the same moment never create duplicate tags, break a type’s max, put a tag on a deleted tag or leave a usage count wrong. Every mutating call runs in a transaction on the tag model’s connection; the outermost one is retried three times on a deadlock, re-reading its rows on every attempt, and events fire after the outermost commit. Inside your own transaction the write joins it and is not retried — Laravel never retries a nested transaction, so your outer one decides.
Lock order
Every write locks in a fixed order and takes its tag-row locks in one ascending sweep of their keys, so two writes never wait on each other in a cycle:
| Write | Locks, in order |
|---|---|
| attach / detach / sync / clear | For names of a free-form tree type, that tree’s lock first. Then the taggable’s own row, so every later read sees the previous writer’s commit and the max and a sync hold. Then one sweep locks every tag row the write touches — the named tags, the carried ones it detaches (every carried one inside your transaction) and every row a new name may change: the slugs’ holders and, in a tree, a deleted holder’s parent and subtree. New free-form tags are created in savepoints with a locking re-read, the tags to attach are re-checked as live, and a detach re-reads which taggings it still carries — only those are removed, counted and announced. |
| … inside your own transaction | The tag rows the model’s pivot rows point at (as your snapshot shows them) and the tags it attaches are locked first; then the pivot rows are read with a locking read, which always sees the latest committed rows, and a tag a newer pivot row revealed is locked last. Tag rows before pivot rows is the order a tag delete or restore takes. |
| Create in a tree (a root and a re-created name included), move, reorder, delete with children, restore | The tree’s tag_locks row (one per type and scope) first; then, in one ascending sweep, every tag row the write decides on or changes — the tag, its old and new parent, its subtree, the siblings a position shifts, a deleted tag a name brings back. Inside your own transaction those rows are then read with locking reads. |
| Rename / update | The tag row and the holders of every slug the change may take, in one sweep in key order. |
tags:repair --paths | One tree at a time, inside that tree’s lock. |
tags:repair --counts | Chunks of 500 tags, each locking its tag rows in key order before counting. |
Because the taggable row comes first, an app that locks a model and then attaches a new name inside its own transaction does not deadlock with the package. And because a tagging write inside your transaction locks the tag rows before the pivot rows, DB::transaction(fn () => $event->syncTags($labels, 'label')) never deadlocks with an admin deleting one of those labels.
What can still deadlock
The engine aborts one side; a write in a transaction of its own is retried, a transaction you opened is yours to retry:
- inside your transaction, a tag attached to the same model by another request after your transaction took its snapshot, and deleted at that very moment;
- inside your transaction, a tree write and a tagging write on two tags of that tree, when another request moved a row into the tree write’s set after your snapshot — the sweep reads the set from that snapshot, and the newer row is locked when the write reaches it;
- a free-form name whose deleted holder still has a leftover tagging on the same model (a plain $tag->delete()), re-created inside your transaction while an admin restores that tag.
What this guarantees
- Two racing attachTags() on a single type never leave a model with two categories.
- Two racing creates of the same name end with one tag and a DuplicateTagException — or, for findOrCreate(), the same tag for both.
- An attach racing a delete of the same tag never attaches a deleted tag (TagNotFoundException, “The tag is deleted.”).
- Usage counts are adjusted in the same transaction as the pivot rows, by exactly the rows written, so they stay exact; a repair recount running beside live traffic is exact too.
- Tree moves, reorders and subtree deletes never interleave within one tree, and inside your own transaction they see the tree as the writer they waited for left it: no parent cycle, no missed new child, no stale path.
- A detach that waited for a tag row a delete held counts only the taggings it actually removed.
MySQL and PostgreSQL
- Inserts are plain inserts, never INSERT IGNORE, which would also turn a foreign-key or truncation error into a warning; a failed insert runs in a savepoint, so PostgreSQL keeps the outer transaction usable.
- Inside a transaction you opened — which may have taken its MySQL REPEATABLE READ snapshot earlier — the current taggings and every tree row a tree write decides on are read with locking reads; an aggregate is read as its top row, because PostgreSQL refuses FOR UPDATE on one.
- A sibling’s last position is the one plain read left: a stale one only ties two siblings, which then order by position, then key.
- Keys handed in from outside are checked against the configured key type before any SQL — a bigint key past the 64-bit range included — because PostgreSQL would answer a mismatched key with an error and MySQL would cast it silently.
Every event implements ShouldDispatchAfterCommit: listeners run only once the change committed, and a rolled-back write announces nothing. The package is tested against PostgreSQL and MySQL with parallel writers.
Writes past the package
A mass delete of taggables, a plain $tag->delete(), a raw SQL import or a hand-edited parent_id bypasses these guarantees. tags:doctor finds what they leave behind and tags:repair fixes it — paths and depths, scope keys, orphan taggings, taggings on deleted tags and usage counts — chunk by chunk, safe under live traffic (see Artisan commands).
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.