puntodev/paypal-fake
A lightweight PayPal fake for Laravel testing. Swap the real PayPal client for a fake that returns stubbed API responses, so you can exercise checkout, payment, and webhook flows in your test suite without network calls or sandbox credentials.
puntodev/paypal-fake is a test double for puntodev/paypal. You install it as a dev dependency and swap the real PayPal client for a fake inside your test suite, so order creation, capture, refunds and webhook flows can be exercised without hitting the sandbox API.
Requirements for 2.0.0 (breaking):
puntodev/paypal ^7.0If your app is still on PHP 8.4 or puntodev/paypal 6.x, stay on the 1.x line — 2.0.0 is not a "try it and roll back" upgrade, it hard-requires the new runtime and the new SDK major.
Install:
composer require --dev puntodev/paypal-fake:^2.0
Where to look first:
puntodev/paypal-fake) — the fake API surface is small and the README is the canonical reference for method names and response shapes.puntodev/paypal's own source — the fake mirrors the real client's public methods, so any method you call in production code must have a corresponding fake hook.First use case: a feature test for checkout. Fake the order-creation response, POST to your checkout endpoint, assert the order ID came back and that PayPal was called once with the right amount and currency.
use PuntoDev\PayPal\PayPal;
public function test_checkout_creates_paypal_order(): void
{
PayPal::fake([
'*/v2/checkout/orders' => PayPal::response([
'id' => 'ORDER-123',
'status' => 'CREATED',
], 201),
]);
$this->postJson('/api/checkout', ['sku' => 'ABC', 'qty' => 2])
->assertOk()
->assertJsonPath('paypal_order_id', 'ORDER-123');
PayPal::assertOrderCreated(fn ($request) => $request['purchase_units'][0]['amount']['value'] === '20.00');
}
Swap once per test class, not once per test. Register the fake in setUp()/Pest beforeEach(), and let the package's per-test reset clear recorded calls. If you fake() globally in a base TestCase, make sure the reset runs before each test or assertions will leak between tests.
Stub by endpoint pattern, Laravel-HTTP-style. The idiomatic shape is a map of URL patterns to canned responses. Keep patterns narrow (*/v2/checkout/orders/*/capture) rather than a single catch-all, so an unexpected call fails loudly instead of silently returning a 200.
Model the three states you actually care about in production:
// Happy path
PayPal::fake(['*capture' => PayPal::response(['status' => 'COMPLETED'], 201)]);
// Declined / funding failure — PayPal returns 422 with a structured body
PayPal::fake(['*capture' => PayPal::response([
'name' => 'UNPROCESSABLE_ENTITY',
'details' => [['issue' => 'INSTRUMENT_DECLINED']],
], 422)]);
// Transport failure — exercises your retry/queue logic
PayPal::fake(['*capture' => PayPal::response('', 500)]);
The 422-with-INSTRUMENT_DECLINED body is the single most valuable fake in this package: it is the branch your checkout code gets least real-world coverage on.
Chain the full order lifecycle in one test. Create → capture → refund, with a sequence of stubbed responses, then assert your local orders table transitioned correctly at each step. This is where the fake pays for itself — no sandbox flakiness, no shared test merchant account, no rate limits in CI.
Fake webhooks at the payload level, not the signature level. Build the webhook body with the package's response helper and call your controller directly, bypassing (or separately unit-testing) signature verification. Do not try to forge real PAYPAL-TRANSMISSION-SIG headers — that is what the real SDK's verification is for, and faking it gives you false confidence.
Assert on intent, not on serialized JSON. Use the assertion helpers with a closure over the request body and check amount, currency, custom_id and idempotency headers. Full-body snapshot assertions break on every benign SDK field addition.
Integration tip — bind in the container for non-HTTP code. If you call the PayPal client from a job, action class, or console command, the fake swap works the same way as long as the client is resolved from the container rather than new'd inline. Constructor-inject PayPal and let the fake take over.
Pair with Http::preventStrayRequests(). If the fake misses a call, you want an exception, not a real request to api-mall.paypal.com from CI.
The version lock is a two-sided constraint. paypal-fake:^2.0 requires puntodev/paypal:^7.0. Upgrading the fake before the SDK gives you a Composer conflict; upgrading the SDK without checking the fake's major pin gives you a fake that shadows methods that no longer exist. Bump both in the same PR and read the puntodev/paypal 7.0 upgrade notes — anything the real SDK renamed is renamed here too.
PHP 8.5 is the hard blocker. Because this is a require-dev dependency, the constraint only bites in CI and local dev — but it bites hard: composer install on a PHP 8.4 runner fails outright rather than degrading. Pin puntodev/paypal-fake:^1.0 explicitly in projects that aren't ready, and gate the upgrade on your CI matrix, not just your local machine.
Fakes don't cover raw Guzzle. Anything that talks to PayPal outside the puntodev/paypal client — a hand-rolled Guzzle call, an SDK you instantiated yourself, a JS SDK on the frontend — is invisible to the fake. Grep for paypal.com before assuming full coverage.
Response shapes are your responsibility. The fake returns whatever array you hand it. A typo'd key (purchase_unit vs purchase_units) produces a passing test and a production undefined index. Copy real sandbox payloads into fixtures once, store them under tests/Fixtures/paypal/, and reuse.
Watch the access-token layer. The real client fetches and caches an OAuth token before every call; the fake typically short-circuits that. If your code or config depends on token caching behaviour (expiry handling, cache store), the fake will not exercise it — cover that path separately or with a targeted unit test.
Reset between tests. Recorded calls and queued responses persist on the fake instance. If your base TestCase doesn't reset, a test that asserts "exactly one capture" can pass or fail depending on test ordering — the classic flaky-CI symptom.
Partial fakes are a trap. A catch-all fallback that returns 200 OK for unmatched endpoints will hide missing stubs for months. Prefer strict mode, and add stubs as failures surface.
Debugging: when an assertion against a recorded request fails, dump the full recorded request list rather than the single matched call — mismatches are almost always a URL pattern or a nested key, and seeing the actual body settles it in seconds.
Extension points to know: the response helper for status/header control, the request-matching closure for assertions, and the record/reset hooks for custom base-test wiring. If you need behaviour the fake doesn't expose, prefer a thin wrapper trait in your own test suite over forking the package — the fake's API tracks the SDK major, so local forks rot fast.
How can I help you explore Laravel packages today?