leonardganyire / paypal
Paypal integration for Laravel
Requires
- php: ^8.3
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^11.1
- pestphp/pest: ^3.0
README
A frontend-agnostic Laravel package for PayPal Checkout Orders v2. It handles OAuth authentication, access-token caching, order creation, order capture, authorization, refunds, order lookup, approval URL extraction, and webhook signature verification.
Your application still owns the payment model, routes, controllers, validation, fulfillment, and frontend. That keeps this package usable from Blade, Livewire, Inertia, a standalone React app, a mobile app, or any other client that can talk to your Laravel backend.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
- A PayPal REST app from the PayPal Developer Dashboard
Installation
Install the package with Composer:
composer require leonardganyire/paypal
Laravel auto-discovers the service provider and facade. You may publish the config if you want to customize the defaults:
php artisan vendor:publish --tag=paypal-config
Configuration
Add your PayPal REST app credentials to .env:
PAYPAL_MODE=sandbox PAYPAL_CLIENT_ID=your-paypal-rest-client-id PAYPAL_CLIENT_SECRET=your-paypal-rest-client-secret PAYPAL_WEBHOOK_ID=your-paypal-webhook-id # Optional overrides # PAYPAL_BASE_URL=https://api-m.sandbox.paypal.com # PAYPAL_TIMEOUT=12 # PAYPAL_CONNECT_TIMEOUT=4 # PAYPAL_ACCESS_TOKEN_CACHE_KEY=paypal.access_token # PAYPAL_ACCESS_TOKEN_TTL=50 # PAYPAL_ACCESS_TOKEN_LEEWAY=60
Configuration notes:
PAYPAL_MODEshould besandboxwhile testing andlivein production.PAYPAL_CLIENT_IDis safe to expose to a browser only when you need to load PayPal's JavaScript SDK. The backend still needs the same value for OAuth.PAYPAL_CLIENT_SECRETmust stay server-side. Never send it to React, Inertia props, mobile apps, logs, or public config.PAYPAL_WEBHOOK_IDis required only when you callverifyWebhookSignature().PAYPAL_BASE_URLis usually not needed. If it is empty, the package resolveshttps://api-m.sandbox.paypal.comfor sandbox andhttps://api-m.paypal.comfor live.
Access tokens are cached automatically. The cache lifetime follows PayPal's expires_in value minus PAYPAL_ACCESS_TOKEN_LEEWAY, so tokens are refreshed shortly before they expire. PAYPAL_ACCESS_TOKEN_TTL is only used as a fallback when PayPal does not return a usable expiry value. When PayPal returns 401, the package clears the cached token so the next request can authenticate again.
What The Package Provides
Resolve LeonardGanyire\Paypal\PayPalClient from the container or use the PayPal facade:
use LeonardGanyire\Paypal\Facades\PayPal; $paypalOrder = PayPal::createOrder($payload, idempotencyKey: (string) $payment->id); $approvalUrl = PayPal::approvalUrl($paypalOrder); $capture = PayPal::captureOrder($paypalOrder['id']);
Dependency injection is preferred for application code:
use LeonardGanyire\Paypal\PayPalClient; final class PayPalCheckoutController { public function __construct( private readonly PayPalClient $paypal, ) {} }
Available methods:
createOrder(array $payload, ?string $idempotencyKey = null): arraycaptureOrder(string $orderId): arrayauthorizeOrder(string $orderId): arraygetOrder(string $orderId): arrayrefundCapture(string $captureId, ?array $payload = null): arrayverifyWebhookSignature(array $headers, array $payload): boolapprovalUrl(array $orderResponse): ?string
Checkout Architecture
For one-time payments, your application should usually follow this flow:
- The customer starts checkout in your app.
- Your Laravel backend creates a local pending payment record.
- Your Laravel backend calls
createOrder()with server-trusted amount and currency values. - Your app either redirects the customer to PayPal or returns the PayPal order ID to React Smart Buttons.
- After buyer approval, your Laravel backend calls
captureOrder(). - Your app marks the local payment as paid only after PayPal confirms
COMPLETED. - Webhooks act as a recovery path for missed redirects, closed browser tabs, and asynchronous payment events.
Do not trust the amount, currency, user ID, order owner, or product price sent by the browser. Use browser input only to identify the cart/order the authenticated user is trying to pay for, then calculate payable totals on the server.
Example App Structure
The package does not create database tables. A host app normally has a payment or transaction model with columns like these:
Schema::create('payments', function (Blueprint $table): void { $table->id(); $table->foreignId('user_id')->constrained(); $table->foreignId('order_id')->constrained(); $table->string('provider')->default('paypal'); $table->string('provider_reference')->nullable()->index(); $table->string('capture_reference')->nullable()->index(); $table->string('status')->default('pending')->index(); $table->string('currency', 3); $table->decimal('amount', 10, 2); $table->json('provider_payload')->nullable(); $table->timestamps(); });
Your model might look like this:
namespace App\Models; use Illuminate\Database\Eloquent\Model; final class Payment extends Model { protected $fillable = [ 'user_id', 'order_id', 'provider', 'provider_reference', 'capture_reference', 'status', 'currency', 'amount', 'provider_payload', ]; protected function casts(): array { return [ 'amount' => 'decimal:2', 'provider_payload' => 'array', ]; } }
Replace this with your own domain model. For example, a course app might call this an EnrollmentPayment, while an ecommerce app might link it to an Order.
Laravel Routes
For a web or Inertia checkout flow:
use App\Http\Controllers\Payments\PayPalCancelController; use App\Http\Controllers\Payments\PayPalCaptureController; use App\Http\Controllers\Payments\PayPalOrderController; use App\Http\Controllers\Payments\PayPalReturnController; use App\Http\Controllers\Payments\PayPalWebhookController; use Illuminate\Support\Facades\Route; Route::middleware('auth')->prefix('payments/paypal')->name('payments.paypal.')->group(function (): void { Route::post('/orders', [PayPalOrderController::class, 'store'])->name('orders.store'); Route::post('/orders/{payment}/capture', [PayPalCaptureController::class, 'store'])->name('orders.capture'); Route::get('/return/{payment}', PayPalReturnController::class)->name('return'); Route::get('/cancel/{payment}', PayPalCancelController::class)->name('cancel'); }); Route::post('/payments/paypal/webhook', PayPalWebhookController::class) ->name('payments.paypal.webhook');
For a separate frontend, put equivalent endpoints in routes/api.php and protect them with your chosen authentication strategy, commonly Sanctum or bearer tokens:
Route::middleware('auth:sanctum')->prefix('payments/paypal')->name('api.payments.paypal.')->group(function (): void { Route::post('/orders', [PayPalOrderController::class, 'store'])->name('orders.store'); Route::post('/orders/{payment}/capture', [PayPalCaptureController::class, 'store'])->name('orders.capture'); });
Create A PayPal Order
This example creates a local pending payment, creates a PayPal order, stores PayPal's order ID, and returns either JSON or a redirect depending on how the endpoint was called.
namespace App\Http\Controllers\Payments; use App\Models\Order; use App\Models\Payment; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use LeonardGanyire\Paypal\PayPalClient; final class PayPalOrderController { public function __construct( private readonly PayPalClient $paypal, ) {} public function store(Request $request): JsonResponse|RedirectResponse { $validated = $request->validate([ 'order_id' => ['required', 'integer', 'exists:orders,id'], 'mode' => ['sometimes', 'string', 'in:redirect,buttons'], ]); $order = Order::query() ->whereBelongsTo($request->user()) ->whereKey($validated['order_id']) ->firstOrFail(); $payment = Payment::query()->create([ 'user_id' => $request->user()->id, 'order_id' => $order->id, 'provider' => 'paypal', 'status' => 'pending', 'currency' => $order->currency, 'amount' => $order->total, ]); $paypalOrder = $this->paypal->createOrder([ 'intent' => 'CAPTURE', 'purchase_units' => [[ 'reference_id' => (string) $payment->id, 'description' => "Order {$order->id}", 'amount' => [ 'currency_code' => strtoupper($payment->currency), 'value' => number_format((float) $payment->amount, 2, '.', ''), ], ]], 'application_context' => [ 'brand_name' => config('app.name'), 'return_url' => route('payments.paypal.return', $payment), 'cancel_url' => route('payments.paypal.cancel', $payment), 'user_action' => 'PAY_NOW', ], ], idempotencyKey: (string) $payment->id); $payment->update([ 'provider_reference' => $paypalOrder['id'] ?? null, 'provider_payload' => $paypalOrder, ]); $approvalUrl = $this->paypal->approvalUrl($paypalOrder); abort_if($approvalUrl === null, 502, 'PayPal did not return an approval URL.'); if (($validated['mode'] ?? null) === 'redirect') { return redirect()->away($approvalUrl); } return response()->json([ 'payment_id' => $payment->id, 'paypal_order_id' => $paypalOrder['id'], 'approval_url' => $approvalUrl, ]); } }
Important details:
- Pass a local payment ID as the idempotency key so retries do not create duplicate PayPal orders.
- Store PayPal's order ID in
provider_reference. - Return
paypal_order_idto React Smart Buttons. - Return or redirect to
approval_urlfor redirect checkout. - Keep the final amount calculation on the backend.
Redirect Checkout
Redirect checkout is the simplest integration. Your UI submits to payments.paypal.orders.store with mode=redirect; the controller sends the customer to PayPal's approval URL.
When the buyer approves, PayPal redirects to your return_url with a token query parameter. The token is the PayPal order ID. Always compare it with the value stored on your local payment before capture:
namespace App\Http\Controllers\Payments; use App\Models\Payment; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use LeonardGanyire\Paypal\Enums\PayPalOrderStatus; use LeonardGanyire\Paypal\PayPalClient; final class PayPalReturnController { public function __construct( private readonly PayPalClient $paypal, ) {} public function __invoke(Request $request, Payment $payment): RedirectResponse { abort_unless($request->user()->is($payment->user), 403); $paypalOrderId = (string) $request->query('token'); abort_unless( is_string($payment->provider_reference) && $paypalOrderId === $payment->provider_reference, 403, ); $capture = $this->paypal->captureOrder($paypalOrderId); $status = PayPalOrderStatus::fromResponse($capture); if ($status->isCompleted()) { $payment->update([ 'status' => 'paid', 'capture_reference' => PayPalOrderStatus::captureReference($capture), 'provider_payload' => $capture, ]); // Fulfill the order here: grant access, dispatch shipment, send receipt, etc. } return redirect()->route('orders.show', $payment->order); } }
Handle cancellation separately:
namespace App\Http\Controllers\Payments; use App\Models\Payment; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; final class PayPalCancelController { public function __invoke(Request $request, Payment $payment): RedirectResponse { abort_unless($request->user()->is($payment->user), 403); $payment->update(['status' => 'cancelled']); return redirect() ->route('orders.show', $payment->order) ->with('status', 'PayPal checkout was cancelled.'); } }
Capture Endpoint For React
When you use PayPal Smart Buttons, the browser opens the PayPal approval popup. After approval, your frontend must ask Laravel to capture the order.
namespace App\Http\Controllers\Payments; use App\Models\Payment; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use LeonardGanyire\Paypal\Enums\PayPalOrderStatus; use LeonardGanyire\Paypal\PayPalClient; final class PayPalCaptureController { public function __construct( private readonly PayPalClient $paypal, ) {} public function store(Request $request, Payment $payment): JsonResponse { abort_unless($request->user()->is($payment->user), 403); $validated = $request->validate([ 'paypal_order_id' => ['required', 'string'], ]); abort_unless( is_string($payment->provider_reference) && $validated['paypal_order_id'] === $payment->provider_reference, 403, ); $paypalOrderId = $payment->provider_reference; if ($payment->status === 'paid') { return response()->json([ 'status' => 'paid', 'payment_id' => $payment->id, ]); } $capture = $this->paypal->captureOrder($paypalOrderId); $status = PayPalOrderStatus::fromResponse($capture); if ($status->isCompleted()) { $payment->update([ 'status' => 'paid', 'capture_reference' => PayPalOrderStatus::captureReference($capture), 'provider_payload' => $capture, ]); // Fulfill the order here. } $freshPayment = $payment->fresh(); return response()->json([ 'status' => $freshPayment->status, 'payment_id' => $freshPayment->id, 'capture_id' => $freshPayment->capture_reference, ]); } }
Inertia + React Smart Buttons
Install PayPal's React package in your Laravel frontend:
npm install @paypal/react-paypal-js
Expose only the public PayPal client ID to the page. For example:
use Inertia\Inertia; return Inertia::render('checkout/show', [ 'order' => [ 'id' => $order->id, 'total' => $order->total, 'currency' => $order->currency, ], 'paypal' => [ 'clientId' => config('paypal.client_id'), 'currency' => strtoupper($order->currency), ], ]);
Then render PayPal buttons from your Inertia page. In an application that uses Laravel Wayfinder, prefer generated route functions from @/actions or @/routes instead of hard-coded strings.
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js' import { useRef } from 'react' type CheckoutProps = { order: { id: number total: string currency: string } paypal: { clientId: string currency: string } } function csrfToken(): string { return document .querySelector<HTMLMetaElement>('meta[name="csrf-token"]') ?.content ?? '' } export default function CheckoutShow({ order, paypal }: CheckoutProps) { const paymentId = useRef<number | null>(null) return ( <PayPalScriptProvider options={{ clientId: paypal.clientId, currency: paypal.currency, intent: 'capture', components: 'buttons', }} > <PayPalButtons style={{ layout: 'vertical' }} createOrder={async () => { const response = await fetch('/payments/paypal/orders', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ order_id: order.id, mode: 'buttons', }), }) if (! response.ok) { throw new Error('Unable to create PayPal order.') } const payload = await response.json() paymentId.current = payload.payment_id return payload.paypal_order_id }} onApprove={async (data) => { if (! paymentId.current || ! data.orderID) { throw new Error('Missing PayPal approval data.') } const response = await fetch(`/payments/paypal/orders/${paymentId.current}/capture`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ paypal_order_id: data.orderID, }), }) if (! response.ok) { throw new Error('Unable to capture PayPal order.') } window.location.href = `/orders/${order.id}` }} onCancel={() => { window.location.href = `/orders/${order.id}` }} /> </PayPalScriptProvider> ) }
Notes for Inertia apps:
- Keep the frontend amount display informational; the backend should calculate and send the PayPal amount.
- If you use Wayfinder, replace the string URLs with generated imports from
@/actions/...or@/routes/.... - If your PayPal buttons do not render, confirm the script provider includes
components: 'buttons'. - You can use Inertia navigation after capture, but the PayPal callbacks themselves are plain JavaScript callbacks, so
fetch()is often the clearest option.
Standalone React Frontend
A standalone React, Next.js, Vite, or mobile frontend uses the same backend endpoints, but it must handle authentication, CORS, and CSRF according to your Laravel API setup.
Install PayPal's React package:
npm install @paypal/react-paypal-js
Use a public environment variable for the PayPal client ID:
VITE_PAYPAL_CLIENT_ID=your-paypal-rest-client-id VITE_API_BASE_URL=https://api.example.com
Example React component:
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js' import { useRef } from 'react' type PayPalCheckoutButtonProps = { orderId: number currency: string apiToken?: string } const apiBaseUrl = import.meta.env.VITE_API_BASE_URL const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID export function PayPalCheckoutButton({ orderId, currency, apiToken }: PayPalCheckoutButtonProps) { const paymentId = useRef<number | null>(null) return ( <PayPalScriptProvider options={{ clientId: paypalClientId, currency, intent: 'capture', components: 'buttons', }} > <PayPalButtons createOrder={async () => { const response = await fetch(`${apiBaseUrl}/api/payments/paypal/orders`, { method: 'POST', credentials: 'include', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}), }, body: JSON.stringify({ order_id: orderId, mode: 'buttons', }), }) if (! response.ok) { throw new Error('Unable to create PayPal order.') } const payload = await response.json() paymentId.current = payload.payment_id return payload.paypal_order_id }} onApprove={async (data) => { if (! paymentId.current || ! data.orderID) { throw new Error('Missing PayPal approval data.') } const response = await fetch(`${apiBaseUrl}/api/payments/paypal/orders/${paymentId.current}/capture`, { method: 'POST', credentials: 'include', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}), }, body: JSON.stringify({ paypal_order_id: data.orderID, }), }) if (! response.ok) { throw new Error('Unable to capture PayPal order.') } }} /> </PayPalScriptProvider> ) }
Standalone frontend checklist:
- Configure Laravel CORS to allow the frontend origin.
- If using Sanctum cookie auth, call
/sanctum/csrf-cookiebefore the first state-changing request and sendcredentials: 'include'. - If using token auth, send an
Authorization: Bearer ...header. - Keep
PAYPAL_CLIENT_SECRETonly in Laravel. - Do not let the frontend choose the final amount. Send an order/cart ID and let Laravel calculate the PayPal purchase unit.
Refunds
After a successful capture, store the capture ID from PayPalOrderStatus::captureReference($capture). Use it for refunds.
use LeonardGanyire\Paypal\Facades\PayPal; // Full refund $refund = PayPal::refundCapture($payment->capture_reference); // Partial refund $refund = PayPal::refundCapture($payment->capture_reference, [ 'amount' => [ 'value' => '5.00', 'currency_code' => 'USD', ], ]);
Your application should also store refund state locally and prevent refunding more than the captured amount.
Authorize Now, Capture Later
If your business flow needs authorization first and capture later, create the order with intent set to AUTHORIZE and call authorizeOrder() after approval:
$paypalOrder = PayPal::createOrder([ 'intent' => 'AUTHORIZE', 'purchase_units' => [[ 'amount' => [ 'currency_code' => 'USD', 'value' => '100.00', ], ]], ]); $authorization = PayPal::authorizeOrder($paypalOrder['id']);
This package does not currently provide a separate "capture authorization" helper. If you need that flow, add the missing endpoint in your app or extend the package.
Get An Order
Use getOrder() when you need to reconcile state with PayPal:
$paypalOrder = PayPal::getOrder($payment->provider_reference);
This is useful for admin troubleshooting, webhook reconciliation, or checking an order before attempting capture.
Webhooks
Webhooks are important, but they should not be your only completion path. Use them to recover from missed redirects, closed tabs, delayed events, and asynchronous updates.
In the PayPal Developer Dashboard, create a webhook pointing to your Laravel route, for example:
https://example.com/payments/paypal/webhook
Select the events your app needs. Common one-time checkout events include:
CHECKOUT.ORDER.APPROVEDPAYMENT.CAPTURE.COMPLETEDPAYMENT.CAPTURE.DENIEDPAYMENT.CAPTURE.REFUNDEDPAYMENT.CAPTURE.REVERSED
Webhook controller example:
namespace App\Http\Controllers\Payments; use App\Models\Payment; use Illuminate\Http\Request; use Illuminate\Http\Response; use LeonardGanyire\Paypal\Facades\PayPal; final class PayPalWebhookController { public function __invoke(Request $request): Response { $headers = [ 'paypal-auth-algo' => $request->header('PAYPAL-AUTH-ALGO'), 'paypal-cert-url' => $request->header('PAYPAL-CERT-URL'), 'paypal-transmission-id' => $request->header('PAYPAL-TRANSMISSION-ID'), 'paypal-transmission-sig' => $request->header('PAYPAL-TRANSMISSION-SIG'), 'paypal-transmission-time' => $request->header('PAYPAL-TRANSMISSION-TIME'), ]; $payload = $request->all(); if (! PayPal::verifyWebhookSignature($headers, $payload)) { abort(400, 'Invalid PayPal webhook signature.'); } $eventType = $payload['event_type'] ?? null; $resource = $payload['resource'] ?? []; match ($eventType) { 'PAYMENT.CAPTURE.COMPLETED' => $this->markCaptureCompleted($resource), 'PAYMENT.CAPTURE.REFUNDED' => $this->markCaptureRefunded($resource), default => null, }; return response('OK'); } /** * @param array<string, mixed> $resource */ private function markCaptureCompleted(array $resource): void { $captureId = $resource['id'] ?? null; if (! is_string($captureId)) { return; } Payment::query() ->where('capture_reference', $captureId) ->where('status', '!=', 'paid') ->update(['status' => 'paid']); } /** * @param array<string, mixed> $resource */ private function markCaptureRefunded(array $resource): void { $captureId = $resource['supplementary_data']['related_ids']['capture_id'] ?? null; if (! is_string($captureId)) { return; } Payment::query() ->where('capture_reference', $captureId) ->update(['status' => 'refunded']); } }
Exclude the webhook route from CSRF verification in bootstrap/app.php:
use Illuminate\Foundation\Configuration\Middleware; ->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: [ 'payments/paypal/webhook', ]); })
Webhook handling tips:
- Verify the webhook signature before trusting any payload fields.
- Make webhook processing idempotent. PayPal may retry events.
- Store enough local state to ignore duplicate events safely.
- Return a
2xxresponse after successful processing so PayPal does not keep retrying.
Status Mapping
Use PayPalOrderStatus to interpret PayPal responses without coupling your app to raw PayPal status strings:
use LeonardGanyire\Paypal\Enums\PayPalOrderStatus; $status = PayPalOrderStatus::fromResponse($capture); $status->isCompleted(); $status->isCancelled(); $captureId = PayPalOrderStatus::captureReference($capture);
Supported statuses are CREATED, SAVED, APPROVED, PAYER_ACTION_REQUIRED, VOIDED, and COMPLETED.
Error Handling
All PayPal API failures throw LeonardGanyire\Paypal\Exceptions\PayPalException:
use LeonardGanyire\Paypal\Exceptions\PayPalException; use LeonardGanyire\Paypal\Facades\PayPal; try { $paypalOrder = PayPal::createOrder($payload); } catch (PayPalException $exception) { report($exception); return response()->json([ 'message' => $exception->getMessage(), 'code' => $exception->errorCode(), 'details' => $exception->details(), ], $exception->status()); }
The exception exposes:
$exception->getMessage()for a human-readable message.$exception->status()for the HTTP status your app can return, defaulting to502.$exception->errorCode()for a stable application-level error code.$exception->details()for safe PayPal error metadata such asstatus,name,message, anddebug_id.
Configuration errors, such as missing credentials or an invalid base URL, also throw PayPalException before a PayPal request is made.
Security Checklist
- Keep
PAYPAL_CLIENT_SECRETserver-side only. - Calculate payment amounts on the backend from trusted order/cart records.
- Authorize access to local orders and payments before creating or capturing PayPal orders.
- Verify the returned PayPal order ID matches your stored
provider_reference. - Use idempotency keys when creating PayPal orders.
- Treat webhooks as untrusted until
verifyWebhookSignature()returnstrue. - Make capture and webhook handlers idempotent so duplicate requests do not double-fulfill orders.
- Store PayPal
debug_idvalues from exceptions when you need support or reconciliation.
What This Package Does Not Include
The following are intentionally left to your host app:
- Payment, order, transaction, refund, or subscription models and migrations.
- Checkout, return, cancel, capture, refund, or webhook controllers and routes.
- Authorization policies for your orders and payments.
- Order fulfillment logic such as enrollment, shipping, invoicing, or receipt emails.
- React, Inertia, Blade, Livewire, Vue, mobile, or other frontend components.
- PayPal Billing Subscriptions and recurring billing APIs.
Testing
Run the package test suite:
composer test
In your host app, fake PayPal HTTP calls with Laravel's HTTP fake:
use Illuminate\Support\Facades\Http; Http::fake([ 'api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'test-token', 'expires_in' => 3600, ]), 'api-m.sandbox.paypal.com/v2/checkout/orders' => Http::response([ 'id' => 'PAYPAL-ORDER-1', 'links' => [ [ 'rel' => 'approve', 'href' => 'https://paypal.test/approve/PAYPAL-ORDER-1', ], ], ], 201), 'api-m.sandbox.paypal.com/v2/checkout/orders/PAYPAL-ORDER-1/capture' => Http::response([ 'status' => 'COMPLETED', 'purchase_units' => [[ 'payments' => [ 'captures' => [ ['id' => 'CAPTURE-1'], ], ], ]], ], 201), ]);
Example Pest assertion for your host app:
use App\Models\Order; use Illuminate\Support\Facades\Http; it('captures a paypal payment', function (): void { Http::preventStrayRequests(); Http::fake([ 'api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ 'access_token' => 'test-token', 'expires_in' => 3600, ]), 'api-m.sandbox.paypal.com/v2/checkout/orders/PAYPAL-ORDER-1/capture' => Http::response([ 'status' => 'COMPLETED', 'purchase_units' => [[ 'payments' => [ 'captures' => [ ['id' => 'CAPTURE-1'], ], ], ]], ], 201), ]); $order = Order::factory()->create(); $payment = $order->payment()->create([ 'user_id' => $order->user_id, 'provider' => 'paypal', 'provider_reference' => 'PAYPAL-ORDER-1', 'status' => 'pending', 'currency' => 'USD', 'amount' => '25.00', ]); $this->actingAs($order->user) ->postJson(route('payments.paypal.orders.capture', $payment), [ 'paypal_order_id' => 'PAYPAL-ORDER-1', ]) ->assertOk() ->assertJsonPath('status', 'paid'); expect($payment->fresh()->capture_reference)->toBe('CAPTURE-1'); });
License
MIT