elkady/laravel-pact

Laravel-native consumer-driven contract testing. A framework ergonomics layer on top of pact-foundation/pact-php.

Maintainers

Package info

github.com/KarimEl-Kady/laravel-pact

pkg:composer/elkady/laravel-pact

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-03 07:14 UTC

This package is not auto-updated.

Last update: 2026-08-04 02:39:56 UTC


README

Consumer-driven contract testing that feels like Laravel.

Tests License

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:

  • RefreshDatabase wraps 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 encodes 25.0 as JSON 25 and Pact then sees an integer. Use a value with a fractional part in examples, or number() 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 0 so 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.php verifies a pact this package wrote using stock pact-php against a plain PHP provider, with nothing from laravel-pact involved.

Documentation

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.