PHPackages                             leonardganyire/paypal - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. leonardganyire/paypal

ActiveLibrary[Payment Processing](/categories/payments)

leonardganyire/paypal
=====================

Paypal integration for Laravel

1.0.1(1mo ago)12↓50%MITPHPPHP ^8.3

Since Jun 19Pushed 3w agoCompare

[ Source](https://github.com/ganyire/laravel-paypal)[ Packagist](https://packagist.org/packages/leonardganyire/paypal)[ RSS](/packages/leonardganyire-paypal/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (12)Versions (3)Used By (0)

PayPal for Laravel
==================

[](#paypal-for-laravel)

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
------------

[](#requirements)

- PHP 8.3+
- Laravel 11, 12, or 13
- A PayPal REST app from the PayPal Developer Dashboard

Installation
------------

[](#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
-------------

[](#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_MODE` should be `sandbox` while testing and `live` in production.
- `PAYPAL_CLIENT_ID` is 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_SECRET` must stay server-side. Never send it to React, Inertia props, mobile apps, logs, or public config.
- `PAYPAL_WEBHOOK_ID` is required only when you call `verifyWebhookSignature()`.
- `PAYPAL_BASE_URL` is usually not needed. If it is empty, the package resolves `https://api-m.sandbox.paypal.com` for sandbox and `https://api-m.paypal.com` for 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
-------------------------

[](#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): array`
- `captureOrder(string $orderId): array`
- `authorizeOrder(string $orderId): array`
- `getOrder(string $orderId): array`
- `refundCapture(string $captureId, ?array $payload = null): array`
- `verifyWebhookSignature(array $headers, array $payload): bool`
- `approvalUrl(array $orderResponse): ?string`

Checkout Architecture
---------------------

[](#checkout-architecture)

For one-time payments, your application should usually follow this flow:

1. The customer starts checkout in your app.
2. Your Laravel backend creates a local pending payment record.
3. Your Laravel backend calls `createOrder()` with server-trusted amount and currency values.
4. Your app either redirects the customer to PayPal or returns the PayPal order ID to React Smart Buttons.
5. After buyer approval, your Laravel backend calls `captureOrder()`.
6. Your app marks the local payment as paid only after PayPal confirms `COMPLETED`.
7. 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
---------------------

[](#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
--------------

[](#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
---------------------

[](#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_id` to React Smart Buttons.
- Return or redirect to `approval_url` for redirect checkout.
- Keep the final amount calculation on the backend.

Redirect Checkout
-----------------

[](#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
--------------------------

[](#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
-----------------------------

[](#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('meta[name="csrf-token"]')
        ?.content ?? ''
}

export default function CheckoutShow({ order, paypal }: CheckoutProps) {
    const paymentId = useRef(null)

    return (

             {
                    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}`
                }}
            />

    )
}
```

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
-------------------------

[](#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(null)

    return (

             {
                    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.')
                    }
                }}
            />

    )
}
```

Standalone frontend checklist:

- Configure Laravel CORS to allow the frontend origin.
- If using Sanctum cookie auth, call `/sanctum/csrf-cookie` before the first state-changing request and send `credentials: 'include'`.
- If using token auth, send an `Authorization: Bearer ...` header.
- Keep `PAYPAL_CLIENT_SECRET` only 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
-------

[](#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
----------------------------

[](#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
------------

[](#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)

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.APPROVED`
- `PAYMENT.CAPTURE.COMPLETED`
- `PAYMENT.CAPTURE.DENIED`
- `PAYMENT.CAPTURE.REFUNDED`
- `PAYMENT.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  $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  $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 `2xx` response after successful processing so PayPal does not keep retrying.

Status Mapping
--------------

[](#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
--------------

[](#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 to `502`.
- `$exception->errorCode()` for a stable application-level error code.
- `$exception->details()` for safe PayPal error metadata such as `status`, `name`, `message`, and `debug_id`.

Configuration errors, such as missing credentials or an invalid base URL, also throw `PayPalException` before a PayPal request is made.

Security Checklist
------------------

[](#security-checklist)

- Keep `PAYPAL_CLIENT_SECRET` server-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()` returns `true`.
- Make capture and webhook handlers idempotent so duplicate requests do not double-fulfill orders.
- Store PayPal `debug_id` values from exceptions when you need support or reconciliation.

What This Package Does Not Include
----------------------------------

[](#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
-------

[](#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
-------

[](#license)

MIT

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance93

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

2

Last Release

42d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/ca9c1a5ec025668e5cc78632e6437c701421bf63027fffb9fbf772356d8918ae?d=identicon)[ganyirel](/maintainers/ganyirel)

---

Top Contributors

[![ganyire](https://avatars.githubusercontent.com/u/38717515?v=4)](https://github.com/ganyire "ganyire (9 commits)")

---

Tags

phplaravelpaymentspaypalintegration

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/leonardganyire-paypal/health.svg)

```
[![Health](https://phpackages.com/badges/leonardganyire-paypal/health.svg)](https://phpackages.com/packages/leonardganyire-paypal)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3355.4M352](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

78727.1M206](/packages/laravel-mcp)[propaganistas/laravel-disposable-email

Disposable email validator

6023.2M7](/packages/propaganistas-laravel-disposable-email)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k16.3M146](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9872.4M142](/packages/roots-acorn)[illuminate/auth

The Illuminate Auth package.

9328.5M1.3k](/packages/illuminate-auth)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
