---
title: "Real-engine races — Testing for Laravel | Roundly"
description: "Racer forks a second database session, so a test sees a lock actually hold on Postgres or MySQL — not just get requested."
url: https://roundly-consulting.com/open-source/docs/testing-for-laravel/real-engine-races
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)

# Real-engine races

A lock recorder proves a lock was asked for; only a second session proves it holds. RoundlyConsulting\\Testing\\Concurrency\\Racer forks the test process and runs one operation in the child, on its own connection, while the parent holds a lock:

```php
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\Testing\Concurrency\Racer;

it('serialises two redemptions of the last coupon', function (): void {
    DB::beginTransaction();
    Coupon::query()->lockForUpdate()->find($coupon->id);

    $racer = (new Racer)
        ->run(fn (): string => Coupons::redeem($coupon->code) ? 'redeemed' : 'refused')
        ->waitUntilBlockedOrDone();

    Coupons::redeem($coupon->code);
    DB::commit();

    expect($racer->outcome())->toBe('refused');
})->skip(fn (): bool => ! Racer::available(), 'needs a real engine and pcntl + posix');
```

waitUntilBlockedOrDone() returns once the racer is queued behind the parent’s lock, so the parent can finish its side of the race before it commits. The same mechanism without a facade in the way:

```php
it('waits until the racer blocks on a row the parent holds, then lets it through', function (): void {
    DB::table('race_rows')->insert(['id' => 1, 'value' => 1]);

    DB::beginTransaction();
    DB::table('race_rows')->where('id', 1)->lockForUpdate()->first();

    $racer = (new Racer)->run(function (): string {
        DB::table('race_rows')->where('id', 1)->update(['value' => 2]);

        return 'updated';
    })->waitUntilBlockedOrDone();

    // Blocked, not finished: the row is still the parent's.
    expect(DB::table('race_rows')->value('value'))->toBe(1);

    DB::commit();

    expect($racer->outcome())->toBe('updated')
        ->and(DB::table('race_rows')->value('value'))->toBe(2);
})->skip(fn (): bool => ! Racer::available(), 'needs a real engine (pgsql/mysql) and pcntl + posix');
```

## API

| Member | Behaviour |
| --- | --- |
| `Racer::available()` | true on a pgsql or mysql leg (DriverMatrix::driver()) with pcntl\_fork() and posix\_kill() present. Use it in ->skip(), so a leg that cannot race says so. |
| `run(Closure $operation)` | Forks and returns at once in the parent. The operation returns the outcome string. A racer runs once: a second run() throws LogicException. Without pcntl or posix it throws RuntimeException naming the extensions. |
| `waitUntilBlockedOrDone(float $timeoutSeconds = 30.0)` | Returns once the racer waits on a lock in this test’s database (Postgres pg\_stat\_activity, MySQL performance\_schema.data\_lock\_waits), or has ended because nothing blocked it. A racer that does neither in time is SIGKILLed and the call throws RuntimeException — no orphan process. |
| `outcome()` | Waits for the racer to end and returns what it reported. |
| `Racer::CONNECTION` | racer — the child’s default connection. |

outcome() and waitUntilBlockedOrDone() called before run() throw LogicException.

## Outcomes

```php
(new Racer)->run(fn (): string => 'won')->outcome();
// 'won'

(new Racer)->run(fn (): string => throw new RuntimeException('lost the race'))->outcome();
// 'error: RuntimeException: lost the race'

(new Racer)->run(function (): string { exit(0); })->outcome();
// 'error: the racer exited without reporting an outcome'

(new Racer)->run(fn (): string => '')->outcome();
// '' — an empty outcome stays apart from a racer that never reported

$racer = (new Racer)->run(function (): string {
    sleep(30);

    return 'too late';
});
$racer->waitUntilBlockedOrDone(timeoutSeconds: 0.3);
// throws RuntimeException: The racer neither blocked nor finished within 0.3 s.
// $racer->outcome() then starts with 'error: the racer was killed'
```

## What the child does, and why

- Its own connection. It registers racer from DriverMatrix::connectionConfig() — the worker’s database under --parallel — and makes it the default. It never touches the parent’s connection: it shares the parent’s server sessions, and closing one ends the parent’s session mid-test.
- It ends with posix\_kill(SIGKILL), never exit() or a return: no shutdown handlers, no destructors, so nothing closes the shared sessions — the kept schema’s included. An operation that calls exit() itself is still SIGKILLed, from a shutdown function.
- Outcomes are strings. A throwable becomes error: <class>: <message>; an operation that exits reports that it exited without an outcome; a child killed or crashed before it wrote anything reports that it died without one. An empty string stays an empty string.
- The lock-wait check is scoped to this test’s database, so another parallel worker’s wait never releases this one early.

## Extensions and engines

- ext-pcntl and ext-posix are suggested, not required: without them the package installs as usual, Racer::available() returns false and racing tests skip visibly. Both are Unix-only.
- run() works on SQLite, but an in-memory child starts with an empty database of its own, so a race there proves nothing — available() is false. MariaDB is not a racing engine here either: its lock-wait tables differ from MySQL’s.
- Racer needs no Testbench: any Laravel app whose test connection is pgsql or mysql through the driver matrix can use it.

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