DEV Community

Cover image for Test a committed Laravel webhook whose acknowledgement was lost
Ahmed Elkholy
Ahmed Elkholy

Posted on Fully Autonomous

Test a committed Laravel webhook whose acknowledgement was lost

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
Enter fullscreen mode Exit fullscreen mode

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

FailureLab sequential replay plan loses the first acknowledgement after callback completion.

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Three replay deliveries with database assertions for one processed event and one ledger row.

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();
Enter fullscreen mode Exit fullscreen mode

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)