---
title: "Concurrency and integrity — Tags for Laravel | Roundly"
description: "How tagging, tree and catalog writes stay correct under parallel writers — a fixed lock order, one key-ordered sweep, savepoints and deadlock retries."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/concurrency
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tags for Laravel](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)

# 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)
