laravel-pact maintained by elkady
laravel-pact
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.