›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-hold
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

byrcsc/laravel-hold · 1.x

Testing.

Test hold state, time, capacity, and events with the included factory.

There is no fake to swap in and no test mode to switch on. Holds are rows, so you test them the way you test any other Eloquent state: seed a shape, move the clock, assert what changed.

The factory

Hold::factory() is wired to HoldFactory. Point it at both sides, then pick a state.

use ByRcsc\LaravelHold\Models\Hold;

$hold = Hold::factory()
    ->for($seat, 'holdable')
    ->for($user, 'holder')
    ->create();

The relation names matter: holdable and holder are both morphs, so the second argument is not optional.

StateProduces
defaultIndefinite and unreleased, so active
active(?$expiresAt)Expires in 15 minutes, or when you say
indefinite()Null expiry, unreleased
expired(?$expiresAt)Expired 5 minutes ago, unstamped
released(?$releasedAt)Released now, or when you say
Hold::factory()->for($seat, 'holdable')->for($user, 'holder')->active()->create();
Hold::factory()->for($seat, 'holdable')->for($user, 'holder')->expired()->create();
Hold::factory()->for($seat, 'holdable')->for($user, 'holder')->released()->create();

expired() produces exactly what a hold looks like between its expiry passing and any hold:expire run noticing: past expires_at, null expired_at.

A local helper keeps the two for() calls out of every test:

function holdFor(Model $holdable, Model $holder): Factory
{
    return Hold::factory()->for($holdable, 'holdable')->for($holder, 'holder');
}

Controlling the clock

Every status read is a clock read, so freezing time is how you test the boundary rather than racing it.

use Illuminate\Support\Carbon;

Carbon::setTestNow(Carbon::parse('2026-08-01 12:00:00'));

$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15));

$hold->isActive();    // true

Carbon::setTestNow(now()->addMinutes(20));

$hold->isExpired();          // true
$seat->availableSlots();     // 1

Carbon::setTestNow();

Laravel's travel() and freezeTime() helpers work the same way.

Use whole seconds. MySQL truncates sub-second precision on a timestamp column, so a test that offsets by fractions of a second can assert one thing in memory and read another back.

To test the boundary itself, set expires_at to exactly now. The comparison is exclusive, so that hold is expired.

$hold = holdFor($seat, $user)->create(['expires_at' => now()]);

$hold->status;   // HoldStatus::Expired

Asserting on events

All four events are ordinary Laravel events.

use ByRcsc\LaravelHold\Events\HoldAcquired;
use Illuminate\Support\Facades\Event;

Event::fake([HoldAcquired::class]);

$hold = $seat->acquireHold($user);

Event::assertDispatched(
    HoldAcquired::class,
    fn (HoldAcquired $event): bool => $event->hold->is($hold),
);

Assert the negative too, because refusals are silent:

Event::fake([HoldAcquired::class]);

$seat->acquireHold($user);      // takes the only slot
$seat->acquireHold($other);     // refused

Event::assertDispatched(HoldAcquired::class, 1);

Fake the specific events you assert on, not everything. A bare Event::fake() also intercepts your application's own model events and observers, which is rarely what a hold test intends.

Events and transactions in tests

The events are dispatched with afterCommit(). A test wrapped in a transaction that never commits, which is what RefreshDatabase does, still dispatches them, because Laravel treats the test transaction as the outermost boundary.

If you assert on events inside an explicit DB::transaction() in the test body, assert after the closure returns rather than inside it.

Testing your own capacity

Override holdCapacity() on a test-only model rather than mocking anything:

class Venue extends Model
{
    use Holdable;

    protected function casts(): array
    {
        return ['capacity' => 'integer'];
    }

    public function holdCapacity(): int
    {
        return $this->capacity;
    }
}

Then drive the boundary:

$venue = Venue::create(['capacity' => 2]);

$venue->acquireHold($a);            // Hold
$venue->acquireHold($b);            // Hold
$venue->acquireHold($c);            // null
$venue->availableSlots();           // 0
$venue->isFullyHeld();              // true

Testing the commands

$this->artisan('hold:expire')
    ->expectsOutputToContain('Expired 1 hold.')
    ->assertSuccessful();

$this->artisan('hold:prune', ['--days' => 30])->assertSuccessful();
$this->artisan('hold:prune', ['--days' => -1])->assertFailed();

hold:prune accepts the option as an integer or a string, so 30 and '30' behave identically.

Testing under real concurrency

SQLite in memory cannot tell you whether acquisition is atomic: it has no row locks, and one process cannot race itself. If you are testing a change to acquisition, capacity counting, or the expiry comparison, run against MySQL and PostgreSQL.

A meaningful race needs separate processes and a database they can both reach, which rules out :memory:. Point the connection at a file or a server, fork acquirers, and assert that exactly capacity of them won.

What to read next

  • Events and listeners for what each event carries and when it fires.
  • Expiry for the boundary your clock tests are asserting on.
  • Concurrency and databases for what a race test is actually exercising.
PreviousDatabase schemaNextTroubleshooting
View source

On this page

  1. The factory
  2. Controlling the clock
  3. Asserting on events
  4. Events and transactions in tests
  5. Testing your own capacity
  6. Testing the commands
  7. Testing under real concurrency
  8. What to read next