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 every lock(), lockForUpdate() and sharedLock() call, then forwards it so 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);
});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 the builder method is called, with an empty sql; 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.