mykholy / laravel-failurelab
Deterministic fault-injection and delivery-replay assertions for Laravel integration tests.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.2.0
- v1.1.0
- v1.0.0
- v0.1.0-beta.1
- dev-docs/finalize-v1-2-public-guides
- dev-feat/fake-provider-idempotency-docs-v1-2
- dev-feat/real-worker-crash-drill-and-docs
- dev-docs/failurelab-webp-hero-20261016
- dev-docs/failurelab-release-automation-20261016
- dev-chore/document-v1-1-and-check-registry
- dev-feature/acknowledgement-loss-replay-v1-1
This package is auto-updated.
Last update: 2026-10-16 17:32:08 UTC
README
FAILURELAB
Break the test. Prove the recovery.
Laravel FailureLab
Deterministic fault injection and replay assertions for Laravel integration tests.
Start in five minutes · Documentation · Recovery patterns · Fake provider fixture · Real SIGKILL drill · Security
Latest verified stable release: v1.2.0. Available on Packagist from immutable source commit
9653af99d3888d956748df2da066e22e53d4ff38. Independent Laravel 12/13 applications installed and exercised the tagged package from the public registry without custom VCS repositories. CI evidence · Release verification.
A payment provider accepts a charge, but your application throws before saving the result. A webhook arrives three times. How do you prove your code won't create the local effect twice?
FailureLab runs the same operation under controlled, named failure points, records which attempts actually ran, and makes the assertions readable. It never intercepts HTTP or background workers by magic; you explicitly opt into each test seam.
your test app behavior the evidence
┌─────────────────┐ ┌───────────────────┐ ┌────────────────┐
│ Scenario │ -----> │ fake provider │ │ Attempt #1 │
│ fail once at │ │ accepts capture │ ---> │ interrupted │
│ provider.accepted│ │ checkpoint.hit() │ ├────────────────┤
│ retry once │ -----> │ retry operation │ ---> │ Attempt #2 │
└─────────────────┘ │ persist locally │ │ completed │
└───────────────────┘ └────────────────┘
Why another testing package?
Laravel's Queue/Bus fakes verify dispatch behavior. Race Lab tests concurrent workers at checkpoints. FailureLab focuses on bounded sequential replay of one business operation after a controlled in-process interruption: a reproducible recovery assertion suitable for an isolated PHPUnit test.
It does not replace Race Lab, Horizon, Pulse, FlowGuard or Testbench. A separate Linux-only CI fixture now tests real Laravel worker SIGKILL/recovery, but the public Scenario and Replay runners remain in-process. It makes no exactly-once delivery guarantee.
Installation
Verified on Packagist: clean Laravel 12 and 13 applications installed v1.0.0, v1.1.0 and v1.2.0
without any custom Composer VCS/path repository:
composer require --dev mykholy/laravel-failurelab:^1.2
The Packagist-only stable consumer matrix and pinned v1.2.0 public release checks verify installation and public API execution independently. Version 1.1's acknowledgement-loss API is available in the published v1.1.0 GitHub release; it is not part of v1.0.0.
Requirements: PHP 8.3+, Laravel 12 or 13, PHPUnit/Pest in the host app. Keep this in require-dev. Set APP_ENV=testing in phpunit.xml and use a disposable test database. No migrations, middleware, queues, services, or network access are installed by FailureLab.
Example: a provider succeeded, but local state was interrupted
use Mykholy\FailureLab\FailureLab; $report = FailureLab::scenario('capture accepted, local commit interrupted') ->interruptOnceAt('provider.accepted') ->attempts(2) // total executions, including the first ->run(function ($checkpoint) use ($checkout): string { $reference = $checkout->captureUsingFakeGateway('order-42'); $checkpoint->hit('provider.accepted'); // controlled test seam $checkout->markPaid('order-42', $reference); return $reference; }); $report ->assertAttempts(2) ->assertInterruptedAt('provider.accepted') ->assertFaultPlanSatisfied() // confirm every configured fault actually fired ->assertCheckpointHit('provider.accepted', 2) ->assertCompleted() ->assertInvariant('provider accepted once', fn (): bool => $fakeGateway->acceptedCaptures('order-42') === 1); // Also assert the BUSINESS INVARIANT using the app's DB and fake provider: $this->assertSame(1, $fakeGateway->acceptedCaptures('order-42')); $this->assertSame('paid', Order::findOrFail(42)->status);
Avoid false-green tests: a scenario can complete successfully without ever
reaching a configured fault. Always pair assertFaultPlanSatisfied() with
assertCompleted() when your test is intended to prove recovery. The first
assertion verifies every configured checkpoint/attempt was actually interrupted;
the second verifies the operation eventually completed. Neither proves your
payment or database invariant by itself.
The fake gateway must implement provider-side idempotency for this assertion to be meaningful. FailureLab does not make an unsafe checkout safe; it exposes whether your integration handles the replay. Real external acceptance and unknown outcomes require reconciliation logic in your app.
Example: the same webhook delivered three times
FailureLab::replay('verified event evt-123') ->times(3) ->run(fn () => $webhookHandler->handle($sameVerifiedEvent)) ->assertDeliveries(3) ->assertAllCompleted(); $this->assertDatabaseCount('ledger_entries', 1);
This is sequential replay, not a concurrent race test. Use your normal HTTP tests to check signature verification; use Race Lab or a process-isolated integration test to prove concurrency behavior.
Example: failed delivery, then a successful retry
By default, the replay runner stops after an unexpected exception. To model a transport sending the same message again after a failed handler, opt in:
$report = FailureLab::replay('webhook failed then recovered') ->times(3) ->continueAfterFailure() ->run(function (int $delivery) use ($handler, $verifiedEvent): void { $handler->handle($verifiedEvent); // your application, using a disposable test DB }); $report->assertDeliveries(3) ->assertFailedDeliveries(1) ->assertRecoveredAfterFailure() // a later handler invocation succeeded ->assertInvariant('one committed ledger entry', fn (): bool => DB::table('ledger_entries')->count() === 1);
Configure your test handler to fail on a chosen delivery, e.g. throw inside a
database transaction the first time. FailureLab never injects an exception
into the replay callback automatically. A recovered handler call is not
proof of exactly-once delivery or correct financial state: use your own
assertInvariant() plus provider-fake assertions. Use the default stop-on-error
behavior when replaying potentially unsafe side effects.
After run(), assertInvariant(name, predicate) evaluates a project-specific recovery rule; failure messages include the rule's name only, not the values tested. summary() exposes attempt/checkpoint counters without leaking provider responses or arbitrary exception messages.
The canonical Composer package source now lives in this standalone repository. Its incubation history is retained in the Mavvix Package Factory.
Guarantees and limits
| It does | It does not |
|---|---|
| Simulate a specific exception on a named checkpoint/attempt | Kill a worker through the public Scenario API; a separate Linux CI drill exercises SIGKILL |
| Bound re-execution to at most 20 attempts | Retry arbitrary application exceptions |
| Record attempts, exact interrupted points and checked checkpoints | Record every side effect or guarantee idempotency |
| Sequentially replay a callback up to 20 times | Simulate true parallel transport or race conditions |
Fail closed when APP_ENV is not testing |
Make calls to a real external payment provider safe |
| Work with regular PHPUnit/Pest assertions | Depend on a daemon, production middleware or migration |
For hands-on examples, see Five-minute quick start, Recovery patterns, Fake provider, real Laravel worker crash drill, Limitations & threat model, and Runnable examples.
Release and distribution
The canonical source and CI live in this repository. v1.2.0 is published on GitHub Releases and Packagist, with exact-source verification and clean registry-based consumer installation. The v1.2.0 public release workflow passed after clean Laravel 12/13 installs. For future versions, see the maintainer release procedure.
New: the handler committed, but the acknowledgement was lost
Introduced in v1.1.0 (not in v1.0.0). A webhook handler can finish and commit successfully, while a caller still retries because its acknowledgement was lost. Model this explicitly, without throwing inside a successful handler:
$report = FailureLab::replay('ack lost after webhook committed') ->times(3) // explicit simulated redelivery count; never automatic network retry ->loseAcknowledgementOn(1) ->run(fn () => $webhookHandler->handle($sameVerifiedEvent)); $report->assertDeliveries(3) ->assertAllCompleted() // each handler invocation returned successfully ->assertAcknowledgementPlanSatisfied() ->assertAcknowledgementLostOn(1) ->assertAcknowledgedAfterLoss() ->assertInvariant('one ledger entry', fn (): bool => DB::table('ledger_entries')->count() === 1);
Unlike continueAfterFailure(), this records successful application execution
with simulated lost acknowledgement. No network packet is dropped, no queue is
started, and no new delivery is scheduled automatically. The caller explicitly
sets times(). The report distinguishes attempted loss from actual loss,
so a callback that throws first cannot silently satisfy the loss plan.
The handler is responsible for idempotency and real database integrity.
Developer workflow
composer install
composer test
composer analyse
vendor/bin/pint --test
Standalone GitHub Actions validates PHP 8.3–8.5, Laravel 12/13, PHPStan, Pint, real MySQL/PostgreSQL integration, and independent clean Laravel consumer installations. Passing CI is necessary but not sufficient for publishing a stable, verified Composer tag.
Public API at a glance
| Method | Purpose |
|---|---|
FailureLab::scenario(string $name) |
Start an isolated failure scenario |
->interruptOnceAt(string $point, int $onAttempt = 1) |
Inject one InjectedFailure exception at one precise attempt |
->attempts(int $count) |
Set maximum total attempts (1–20) |
->run(callable $operation) |
Invoke operation, retrying only the package's own interruption |
->assertAttempts(int) |
Assert the number of actual invocations |
->assertInterruptedAt(string, int $times = 1) |
Assert exact fault activation count |
->assertCheckpointHit(string, int) |
Assert exact checkpoint count |
->assertCompleted() / ->value() |
Check success and inspect returned value |
FailureLab::replay(...)->times(int)->run(callable) |
Run bounded sequential duplicate deliveries |
->loseAcknowledgementOn(int) |
Model acknowledgement lost after successful handler (1.1.0+) |
->assertAcknowledgementPlanSatisfied() |
Verify configured loss occurred after a completed callback (1.1.0+) |
->assertAcknowledgedAfterLoss() |
Require a later successful acknowledged delivery (1.1.0+) |
new FakeCaptureGateway |
Test-only provider with stable idempotency keys and unknown responses (v1.2.0+) |
A FailureLabManager is available from Laravel's service container for dependency-injected tests; the static entry point needs no bootstrapping.
Project status
v1.2.0 — latest tagged stable on GitHub and Packagist, with matching immutable source and verified independent Composer installs on Laravel 12 and 13. FailureLab remains a test-only library: it does not simulate real process termination, concurrent workers, or distributed exactly-once delivery. Release verification covers the exact evidence and limits.
MIT licensed. Contributions welcome; see CONTRIBUTING.md. Maintained by @mykholy.
