---
title: "Lock recorders — Testing for Laravel | Roundly"
description: "Make lockForUpdate() and its transaction depth observable on SQLite — via a model trait or a recording grammar."
url: https://roundly-consulting.com/open-source/docs/testing-for-laravel/lock-recorders
language: en
---

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

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

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

```php
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():

```php
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:

```php
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 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/testing-for-laravel.md)
