›
byrcsc/laravel-hold · 1.x
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.
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.
| State | Produces |
|---|---|
| default | Indefinite 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');
}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
timestampcolumn, 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::ExpiredAll 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.
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.
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$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.
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.