elkady / laravel-pact
Laravel-native consumer-driven contract testing. A framework ergonomics layer on top of pact-foundation/pact-php.
Requires
- php: ^8.2
- ext-json: *
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- pact-foundation/pact-php: ^10.2
- symfony/finder: ^7.0|^8.0
- symfony/process: ^7.0|^8.0
Requires (Dev)
- fakerphp/faker: ^1.23
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- fakerphp/faker: Required for realistic sample-argument inference in pact:generate-tests (^1.23).
This package is not auto-updated.
Last update: 2026-08-04 02:39:56 UTC
README
Consumer-driven contract testing that feels like Laravel.
Pact::consumer('checkout-service') ->provider('billing-service') ->given('invoice 42 exists') ->uponReceiving('a request for invoice 42') ->withRequest('GET', '/api/invoices/42') ->willRespondWith(200, [ 'id' => Pact::match()->integer(42), 'total' => Pact::match()->decimal(19.99), ]); $invoice = app(BillingClient::class)->fetchInvoice(42); // ordinary Http:: call Pact::verifyInteractions();
Why this exists
This is a wrapper around pact-foundation/pact-php, not a replacement for it.
pact-php is excellent, actively maintained and spec-compliant. It is a general PHP library, though, and four things about using it in a Laravel app are genuinely painful. This package fixes those four things and delegates everything else — the protocol, the mock server, the matching engine, the pact file format — straight to pact-php.
pact-foundation/pact-php |
laravel-pact |
|
|---|---|---|
| Pact protocol, mock server, matching engine | ✅ owns it | ↳ delegates to pact-php |
| Pact file format | ✅ owns it | ↳ delegates to pact-php |
Provider states under RefreshDatabase |
states run over HTTP in a separate process — state leaks between interactions | each interaction in its own transaction, rolled back immediately |
| Consumer DSL | generic PHP builder objects | fluent, Http::fake()-shaped, integrated with the Http facade |
| Queue / event contracts | HTTP + raw message pacts | serialises real Laravel events and jobs via the framework's own path |
| Test generation from code | — | #[PactConsumes] / #[PactProvides] → real, committed test files |
| Broker workflow | verifier-level only | pact:publish, pact:can-i-deploy, pact:verify as Artisan commands |
| Failure output | nested JSON | formatted diffs naming the interaction and its provider state |
The problem it really solves
Pact's verifier is a Rust binary reached over FFI. It blocks the calling process and drives your provider over real HTTP — so run the ordinary way, your provider is a different process from PHPUnit. That means:
RefreshDatabasewraps the test process in a transaction the provider cannot see;- provider states run through an HTTP callback outside any transaction, so rows created for interaction #1 are still there for interaction #7 — verification quietly depends on interaction order;
- fakes, spies and frozen clocks bound in your test's container have no effect on the code being verified.
laravel-pact turns the arrangement inside out. The verifier is pushed into a child process, and the provider becomes the PHPUnit process itself, served by a small in-process listener. Interactions are dispatched through your app's own HTTP kernel — same container, same connection, same open transaction.
The verifier brackets every interaction with a setup and teardown callback,
which become the transaction boundary:
setup → BEGIN (savepoint) → run #[ProviderState] handlers
request → dispatched through your kernel, inside that transaction
teardown → ROLLBACK
Because it nests, it composes with RefreshDatabase rather than fighting it.
tests/Feature/TransactionalIsolationTest.php
proves both halves: with the fix each interaction sees exactly its own rows
([1, 1]), and with the fix disabled the second interaction sees the first
one's leaked data ([1, 2]).
Installation
composer require --dev elkady/laravel-pact php artisan vendor:publish --tag=pact-config
Requires PHP 8.2+, Laravel 11/12/13, and ext-ffi (pact-php drives the Pact
core through it).
PACT_PACTICIPANT="checkout-service" PACT_BROKER_BASE_URL="https://your-org.pactflow.io" PACT_BROKER_TOKEN="..." PACT_CONSUMER_VERSION="${GITHUB_SHA}"
Quickstart
1. Consumer test
use LaravelPact\Facades\Pact; use LaravelPact\Testing\InteractsWithPact; class FetchInvoiceTest extends TestCase { use InteractsWithPact; public function test_it_fetches_an_invoice(): void { Pact::consumer('checkout-service') ->provider('billing-service') ->given('invoice 42 exists') ->uponReceiving('a request for invoice 42') ->withRequest('GET', '/api/invoices/42') ->willRespondWith(200, [ 'id' => Pact::match()->integer(42), 'email' => Pact::match()->email(), 'total' => Pact::match()->decimal(19.99), ]); $invoice = app(BillingClient::class)->fetchInvoice(42); $this->assertSame(42, $invoice['id']); Pact::verifyInteractions(); } }
BillingClient makes an ordinary Http::get('https://billing.internal/...')
call. InteractsWithPact registers one global request middleware on Laravel's
HTTP client factory — the same machinery Http::fake() uses — so the request is
transparently redirected to the mock server. Your application code needs no
knowledge of Pact.
Pact::verifyInteractions() asserts every declared interaction was actually
exercised, then writes .pact/checkout-service-billing-service.json. An
interaction nobody called fails the test, so dead contracts cannot accumulate.
2. Provider test
class BillingProviderTest extends TestCase { use RefreshDatabase; public function test_it_honours_its_consumers(): void { Pact::provider('billing-service') ->withStatesFrom(InvoiceStates::class) ->fromBroker() ->verify(); // throws with a readable report on failure } }
class InvoiceStates { #[ProviderState('invoice 42 exists')] public function invoiceExists(array $params): void { Invoice::factory()->create(['id' => $params['id'] ?? 42]); } }
No cleanup: the handler runs inside that interaction's own transaction, which is rolled back the moment the interaction finishes.
3. Publish and gate the deploy
php artisan pact:publish --branch=main --build-url="$CI_URL" php artisan pact:verify --provider=billing-service --publish php artisan pact:can-i-deploy --to-environment=production # exits 1 if unsafe
pact:can-i-deploy fails closed. The broker answers true, false, or
null, and only true is treated as safe — an unverified contract blocks the
deploy exactly as a failing one does, because from the deployer's point of view
they are the same thing.
Attribute-driven test generation
Put the contract next to the code it describes:
class BillingClient { #[PactConsumes( provider: 'billing-service', method: 'GET', path: '/api/invoices/42', state: 'invoice 42 exists', response: [ 'id' => PactFieldType::Integer, 'email' => PactFieldType::Email, 'total' => PactFieldType::Decimal, ], )] public function fetchInvoice(int $invoiceId): array { /* ... */ } }
php artisan pact:generate-tests
writes a real file to tests/Pact/Generated/BillingClientFetchInvoicePactTest.php
— reviewable in a pull request, greppable, git blame-able, and named
individually in CI output. Field types become matcher calls, and sample
arguments are inferred from the signature and the contract path, so
fetchInvoice(42) matches /api/invoices/42 and the generated test actually
passes.
#[PactProvides] on a controller action generates provider-state handlers,
inferring factory calls from route-model-binding type hints and emitting an
explicit // TODO: define state setup where nothing can be inferred safely — a
wrong guess would produce a test that passes for the wrong reason.
Drift detection
php artisan pact:generate-tests --check # exits 1 if any file is stale
Run this first in CI. It regenerates into memory, diffs against what is committed, and fails with a line-level diff — so a merged pull request can never contain an attribute that disagrees with the test claiming to enforce it.
Message and queue contracts
A lot of Laravel service-to-service traffic never touches HTTP.
Pact::messageConsumer('notification-service') ->provider('order-service') ->given('order 42 has shipped') ->expectsToReceive('an order shipped event') ->withMetadata(['event' => 'order.shipped']) ->withContent([ 'order_id' => Pact::match()->integer(42), 'tracking_number' => Pact::match()->string('TRK-99'), ]) ->verifiedBy(fn (ReceivedMessage $message) => (new SendShipmentNotification) ->handle(OrderShipped::fromPayload($message->toArray())));
On the provider side you register the code that produces the real thing:
Pact::provider('order-service') ->producing('an order shipped event', fn (array $params) => new OrderShipped(...)) ->fromBroker() ->verify();
The event or job is serialised through the framework's own path —
broadcastWith(), Arrayable::toArray(), or a job's public properties (queue
plumbing excluded, and reported as metadata instead). The output is a
spec-compliant V3 message pact, so a Node or Go service on the other end of
the queue can verify against it too.
Commands
| Command | What it does |
|---|---|
pact:generate-tests |
Write test files from #[PactConsumes] / #[PactProvides] |
pact:generate-tests --check |
Fail if any generated file is stale (run first in CI) |
pact:publish |
Publish pact files to the broker |
pact:verify |
Verify this provider against broker or local pacts |
pact:can-i-deploy |
Deploy gate; exits non-zero unless everything is verified |
A copy-pasteable pipeline wiring these in the right order lives in
docs/ci/github-actions.yml:
php artisan vendor:publish --tag=pact-ci
Matchers
Pact::match() exposes integer(), string(), email(), uuid(),
decimal(), boolean(), iso8601() and regex($example, $pattern), plus
like(), eachLike(), equal(), nullValue() and notEmpty(). Each maps to
a real Pact matching rule, never an exact-value assertion. Anything not
surfaced here falls through to pact-php's own matcher.
A trap worth knowing about.
decimal()against a whole number fails, because PHP encodes25.0as JSON25and Pact then sees an integer. Use a value with a fractional part in examples, ornumber()if the field really can be either.
Notes
- Parallel testing. No global mutable state: mock servers, sessions and
registries are bound per container, and the mock server binds port
0so Paratest workers cannot collide. - Failures are formatted. Both sides route through a formatter that names the interaction, the provider state that was active, the expected/actual diff, and a bounded excerpt of the real response body — an HTML error page is reduced to its title rather than dumping kilobytes of inlined CSS.
- Spec compliance is tested, not asserted.
tests/Feature/SpecComplianceTest.phpverifies a pact this package wrote using stock pact-php against a plain PHP provider, with nothing from laravel-pact involved.
Documentation
- Getting started — the full walkthrough
- CI pipeline — copy-pasteable GitHub Actions
Testing this package
composer install vendor/bin/phpunit
Database-backed tests use SQLite in memory by default. To run them against a
real server — which also exercises real SAVEPOINT nesting:
PACT_TEST_DB=pgsql PACT_TEST_DB_PORT=5432 \ PACT_TEST_DB_DATABASE=pact_test PACT_TEST_DB_USERNAME=pact \ vendor/bin/phpunit
Credits
Built on pact-foundation/pact-php,
which does all the hard protocol work.
License
MIT. See LICENSE.md.