Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Paypal Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. config/paypal.php — the key names are the contract; everything else reads from here.
  2. The README's usage block — it is short, and the resource classes it proxies to (Orders, Payments, Webhooks, etc.) live under src/.
  3. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Amounts must be strings with two decimals. 10, 10.0, and 10.00 (float) are all rejected or silently misinterpreted. number_format($cents / 100, 2, '.', '') — quote it.
  • Add 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.
  • Webhook verification needs the raw body. $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.
  • Access tokens are cached in-process. Fine for web requests, a trap in long-running queue workers (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.
  • Don't fire API calls in 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.
  • API shapes are plain arrays. There are no DTOs, so $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.
  • Version drift is the real risk here. Releases are frequent (this is actively maintained), but the ecosystem is thin — 0 dependents means few blog posts and fewer Stack Overflow answers, so pin the major version. Older 1.x/2.x-era examples you'll find online use the deprecated PayPal SDK style (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.
  • Log carefully. Enable package logging in sandbox, disable or downgrade it in production: request bodies contain payer email, address, and card metadata. If you add Guzzle middleware for logging, redact client_secret and anything under payer.
  • Retries: only retry GETs and writes carrying a PayPal-Request-Id. Blindly retrying a capture on a 500 is how duplicate captures happen.
  • Extension points are limited but sufficient: bind a configured client instance into the container, add Guzzle middleware at construction for logging/metrics/retries, and keep the package behind your own interface. Treat it as a transport, not a domain layer — that's the difference between a one-day integration and a migration when the API version moves.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package