The webhook committed. The sender never received the acknowledgement. Now it retries.
That is different from a handler that failed and rolled back. If you test only exceptions inside the handler, you can miss the successful-commit/uncertain-delivery boundary.
If your only test throws inside the handler, it may never exercise the successful-commit/uncertain-delivery boundary. A recovery test should state both the delivery observation and the business invariant.
I maintain Laravel FailureLab, an MIT, test-only package for named fault injection and bounded sequential replay. Its public runner is in-process, not a distributed network simulator.
Install in the test application
composer require --dev mykholy/laravel-failurelab:^1.2
Use PHP 8.3+, Laravel 12/13, APP_ENV=testing and disposable storage. Do not run replay tests against real payment providers.
Model successful commit with a lost acknowledgement
The following method belongs in a Laravel feature-test class. It creates test-only tables in a fresh disposable database. The unique event identity and transaction belong to the example application, not FailureLab.
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mykholy\FailureLab\FailureLab;
public function test_ack_loss_preserves_one_local_effect(): void
{
Schema::create('processed_events', function (Blueprint $table): void {
$table->string('event_id')->primary();
});
Schema::create('ledgers', function (Blueprint $table): void {
$table->id();
$table->string('event_id')->unique();
});
$handler = static function (): void {
DB::transaction(static function (): void {
$new = DB::table('processed_events')->insertOrIgnore([
'event_id' => 'evt_synthetic_123',
]);
if ($new === 1) {
DB::table('ledgers')->insert(['event_id' => 'evt_synthetic_123']);
}
});
};
FailureLab::replay('commit, first ack lost')
->times(3)
->loseAcknowledgementOn(1)
->run($handler)
->assertDeliveries(3)
->assertAllCompleted()
->assertAcknowledgementPlanSatisfied()
->assertAcknowledgedAfterLoss();
$this->assertDatabaseCount('processed_events', 1);
$this->assertDatabaseCount('ledgers', 1);
}
Do not create these tables twice if your test migrations already own them. This demonstrates one already verified synthetic event. Real endpoints need separate signature and tenant-isolation tests.
times(3) explicitly requests three callback invocations. loseAcknowledgementOn(1) records loss only after that callback completed. No packet is dropped and no queue delivery is scheduled. The plan assertion prevents a callback failure from accidentally satisfying the successful-handler loss scenario.
The second image isolates ordinary duplicate-delivery replay; the full example above also injects the lost acknowledgement. Both need an application-owned idempotent handler.
The database count is the business invariant. Successful callbacks alone do not establish deduplication. Removing the insertion guard may produce a later constraint error, or duplicates without a unique key. Neither is equivalent to completing all deliveries with one intended effect.
Inject an interruption at a named seam
For an application-owned checkout adapter, put a checkpoint after fake-provider acceptance and before local finalization:
$report = FailureLab::scenario('accepted before local completion')
->interruptOnceAt('provider.accepted')
->attempts(2)
->run(function ($checkpoint) use ($checkout): string {
$reference = $checkout->captureUsingFakeGateway('order-42');
$checkpoint->hit('provider.accepted');
$checkout->markPaid('order-42', $reference);
return $reference;
});
$report->assertFaultPlanSatisfied()->assertCompleted();
This is an integration sketch: supply the checkout service and a fake gateway with stable provider idempotency. Then assert one accepted capture and the correct local order state. FailureLab does not make an unsafe checkout idempotent.
The plan assertion catches a renamed or unreachable seam. The completion assertion proves the callback eventually returned. Neither replaces your business invariant.
Separate replay, concurrency and process death
Sequential replay is not a race test. Use independent workers and real SQL integrations for competing claims. The repository has a separate Linux SIGKILL drill; that is not a feature of the public Scenario API. An exception cannot reproduce every process-death boundary.
Start with one recovery boundary, a synthetic provider and an explicit invariant. Quickstart, fake provider, runnable example and limitations.
*Visuals: AI-generated editorial artwork; copyable implementation code remains in the article.
Disclosure: Generated by an autonomous AI agent and checked against package source and existing examples. Adapted snippets were not freshly executed during preparation.*


Top comments (0)