Lock recorders
SQLite compiles lockForUpdate() to an empty string, so a test cannot tell a locked read from an unlocked one — and emitting a real FOR UPDATE is a SQLite syntax error. Two observable variants record each lock, and the transaction depth it ran at, into LockRecorder. They catch races that stay invisible on SQLite otherwise: an alert that never opened, a tier paged twice, an oversold stock level.
Variant A — the model is subclassable
Add the RecordsLocks trait to a test subclass. It returns a LockRecordingBuilder that records each lock(), lockForUpdate() and sharedLock() when its query runs — at the transaction depth it runs at — while the statement runs unchanged:
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\Testing\Fixtures\Concerns\RecordsLocks;
use RoundlyConsulting\Testing\Fixtures\LockRecorder;
// Variant A — the model is subclassable:
final class RecordingCoupon extends Coupon { use RecordsLocks; }
it('locks the coupon inside the transaction', function (): void {
LockRecorder::flush();
DB::transaction(fn () => RecordingCoupon::query()->lockForUpdate()->get());
expect(LockRecorder::recorded())->toHaveCount(1)
->and(LockRecorder::recorded()[0]['marker'])->toBe('lock-for-update')
->and(LockRecorder::recorded()[0]['transactionDepth'])->toBe(1);
});Variant B — the lock is buried in code you can’t swap
Install LockRecordingGrammar on the SQLite connection. It compiles the lock to a trailing SQL comment that SQLite runs without complaint, and listenForMarkers() records each marked query through DB::listen():
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\Testing\Fixtures\LockRecorder;
use RoundlyConsulting\Testing\Fixtures\LockRecordingGrammar;
// Variant B — the model is not subclassable, or the lock is buried in an action:
it('locks the shop row', function (): void {
$connection = DB::connection();
$connection->setQueryGrammar(new LockRecordingGrammar($connection));
LockRecorder::flush();
LockRecorder::listenForMarkers();
DB::transaction(fn () => Shop::query()->lockForUpdate()->get());
expect(LockRecorder::recorded()[0]['sql'])->toContain('/* lock-for-update */')
->and(LockRecorder::recorded()[0]['transactionDepth'])->toBe(1);
});LockRecordingGrammar is SQLite only: its constructor throws InvalidArgumentException for any other connection, because on Postgres or MySQL it would replace the engine’s grammar and silently drop the real FOR UPDATE. On a real-engine leg, gate the grammar-based test to SQLite — ->skip(fn () => DriverMatrix::driver() !== 'sqlite') — since the engine takes the real lock there.
Why transaction depth matters
A lock that lands in a savepoint released before the ledger write is a real bug class — it is what condemned a would-be locking helper. Depth makes it observable; a nested transaction records depth 2:
DB::transaction(function (): void {
DB::transaction(function (): void {
RecordingCoupon::query()->lockForUpdate()->get();
});
});
// The nested transaction is a savepoint — depth 2, not the outer transaction.
expect(LockRecorder::recorded()[0]['transactionDepth'])->toBe(2);LockRecorder API
| Method | Purpose |
|---|---|
LockRecorder::flush() | Clear the registry — call in setUp() and before driving a race. |
LockRecorder::recorded() | The locks since the last flush, in order: a list of marker, sql and transactionDepth entries. |
LockRecorder::listenForMarkers() | Variant B wiring — a DB::listen() that records every marked query. Call once, after installing the grammar. |
LockRecorder::record($marker, $transactionDepth, $sql = '') | Record one lock by hand. |
Markers are lock-for-update, lock-shared and lock-custom (a string lock clause). Variant A records the lock when its query runs, with an empty sql: a builder made outside a transaction and run inside one records the inner depth, a locking builder that never runs records nothing, and several lock calls on one query record once, with the lock the query ran with. toSql() counts as running, and a builder executed again records again only after another lock call. Variant B records the full executed statement.
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.