NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

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:

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:

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

MemberBehaviour
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::CONNECTIONracer — the child’s default connection.

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

Outcomes

(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

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