blendbyte/paypal
A Laravel package for PayPal integration that simplifies accepting payments and handling Instant Payment Notifications. Verify IPN messages, process payment status updates, and connect PayPal to your app with a clean, familiar API.
Install and wire up
composer require blendbyte/paypal
php artisan vendor:publish --tag=paypal-config # or --provider="Blendbyte\PayPal\PayPalServiceProvider"
Laravel auto-discovery registers the service provider and the PayPal facade (Blendbyte\PayPal\Facades\PayPal). For standalone PHP, instantiate the client directly with your credentials instead of resolving from the container — everything the facade does is a thin proxy over the same client.
Configure .env first, not the config file
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_MODE=sandbox # sandbox | live
PAYPAL_WEBHOOK_ID= # required only for signature verification
PAYPAL_LOG_ENABLED=true
PAYPAL_LOG_LEVEL=debug
Where to look first
config/paypal.php — the key names are the contract; everything else reads from here.Orders, Payments, Webhooks, etc.) live under src/.src/ itself. This is a thin, ~1k-star package: the source is the documentation for edge cases like header handling and token caching. Read the client class before debugging a 4xx.First use case: Orders v2 checkout
// POST /paypal/order
$order = PayPal::orders()->create([
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => ['currency_code' => 'USD', 'value' => '49.00'],
]],
'application_context' => [
'return_url' => route('paypal.return'),
'cancel_url' => route('paypal.cancel'),
],
]);
$approve = collect($order['links'])->firstWhere('rel', 'approve')['href'];
return redirect()->away($approve);
// GET /paypal/return?token={orderId}&PayerID=...
public function return(Request $request)
{
$result = PayPal::orders()->capture($request->query('token'));
if (($result['status'] ?? null) === 'COMPLETED') {
// mark order paid, using $result['purchase_units'][0]['payments']['captures'][0]['id']
}
return redirect()->route('checkout.success');
}
That is the entire happy path: create server-side → redirect to the approve link → capture on return. Everything below is about making that reliable.
Never trust a client-supplied amount. Build purchase_units from your own database rows (cart, invoice, subscription plan), not from the request payload. The order ID you get back is opaque; persist it against your local record before redirecting so the return handler can look it up rather than trusting token.
Return URL is a hint, not proof of payment. PayPal redirects the buyer back whether or not the capture succeeded. Treat PAYMENT.CAPTURE.COMPLETED from the webhook as the authoritative signal, and let the return handler be a UX redirect that also attempts capture (the capture call is what actually moves money).
Webhooks as the durable path
Route::post('/webhooks/paypal', function (Request $request) {
$event = $request->json()->all();
if (! PayPal::webhooks()->verify($request->headers->all(), $request->getContent())) {
abort(400);
}
match ($event['event_type']) {
'PAYMENT.CAPTURE.COMPLETED' => MarkOrderPaid::dispatch($event['resource']),
'PAYMENT.CAPTURE.DENIED' => FlagPaymentFailed::dispatch($event['resource']),
'PAYMENT.CAPTURE.REFUNDED' => RecordRefund::dispatch($event['resource']),
default => null,
};
return response()->noContent(); // 2xx fast; do the work in a job
});
Return 200/204 within a couple of seconds; PayPal retries failed deliveries on a schedule and duplicate events are normal. Key your idempotency on $event['id'] (or resource.id for capture events) in a unique column.
Wrap the facade in your own gateway class. Injecting PayPal directly into controllers couples your domain to PayPal's array shapes and makes tests painful. A PayPalGateway interface bound in a service provider gives you one swap point, one place to normalize the {"status": "...", "purchase_units": [...]} envelope, and a clean seam for a fake in tests.
Authorize-then-capture when you need to hold funds before shipping: intent => 'AUTHORIZE', then PayPal::payments()->authorize($authorizationId, ['amount' => ...]) later. Authorizations expire (roughly 29 days) — schedule the capture, don't wait for an admin to click.
Refunds and partial refunds are capture-scoped, not order-scoped:
PayPal::payments()->refund($captureId, [
'amount' => ['currency_code' => 'USD', 'value' => '12.50'],
]);
Omit amount for a full refund. The response can come back PENDING — poll or wait for PAYMENT.CAPTURE.REFUNDED before telling the customer it's done.
Store money as integers in your DB, convert to PayPal's "49.00" string only at the boundary. One Money value object used everywhere beats scattering number_format() around.
Local development: PayPal cannot reach localhost. Use ngrok/expose for the webhook URL, register the webhook in the sandbox app dashboard, and put that webhook ID in .env. Sandbox and live webhooks are separate objects with separate IDs.
10, 10.0, and 10.00 (float) are all rejected or silently misinterpreted. number_format($cents / 100, 2, '.', '') — quote it.PayPal-Request-Id on create and capture. Both endpoints are non-idempotent by default; a network timeout + naive retry is a double charge or a duplicate order. Most wrappers let you pass headers per call; if yours doesn't, that's your first patch.UNPROCESSABLE_ENTITY is a bag of issues, not one error. Read details[].issue: INSTRUMENT_DECLINED means "let the buyer pick another funding source" (re-render the buttons), ORDER_ALREADY_CAPTURED means "your retry already worked — fetch the order", PAYER_ACTION_REQUIRED means redirect them again.$request->getContent() — not $request->all() re-encoded, which changes key order and whitespace and fails the signature. It also needs all five paypal-transmission-* headers intact; don't strip them in a proxy or middleware. Verification fails confusingly if PAYPAL_WEBHOOK_ID is from the other environment.401 invalid_client is almost always environmental, in this order: stale config:cache after an .env change, sandbox credentials against the live URL (or vice versa), or a trailing newline/quote in the secret. php artisan config:clear before you debug anything else.queue:work without --max-time): a worker can hold an expired token past the ~9h lifetime and every call starts returning 401. Either run workers with --max-time=3600 or catch 401 once and retry after forcing a token refresh.Http::fake() does not intercept this package — it talks to Guzzle directly, so Laravel's HTTP client fakes are blind to it. Fake the facade (PayPal::shouldReceive('orders->capture')...) or, better, inject your own gateway fake. This is the single biggest testing annoyance and the strongest argument for the gateway wrapper.boot() or a service provider. Credentials may be missing during config:cache, artisan commands, and CI, and you'll get hard failures in unrelated commands.$result['purchase_units'][0]['payments']['captures'][0]['status'] is real code you'll write. Wrap access in helpers ($gateway->captureStatus($result)) so a PayPal schema addition doesn't ripple through controllers.payment(), sale(), refund() resources); the current REST client exposes order/capture/payment resources instead. Read the changelog before a major bump and diff the resource classes you actually call.client_secret and anything under payer.GETs and writes carrying a PayPal-Request-Id. Blindly retrying a capture on a 500 is how duplicate captures happen.How can I help you explore Laravel packages today?