All Articles
Tutorialslaravelphppaypalpaymentsopen-source

Accepting PayPal Payments in Laravel with drewdan/paypal

28 April 2025· 15 min read· Andrew Arscott

drewdan/paypal is an open source Laravel package for accepting PayPal payments. It uses a builder pattern to construct requests, strong types and enums throughout, and follows familiar Laravel conventions to keep the integration feeling native.

Note: This package currently supports up to Laravel 10. Support for Laravel 11 is in progress. It is not yet feature complete — contributions and PRs are very welcome.


Installation

Install the package via Composer:

composer require drewdan/paypal

Publish the config file:

php artisan vendor:publish --tag=drewdan-paypal-config

The config file is where you will register your webhook event handlers later on.

Add the following to your .env file, using the credentials from your PayPal developer account:

PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_MODE=LIVE

Set PAYPAL_MODE to SANDBOX to test against PayPal's sandbox environment without processing real transactions.


Creating an Order

The package uses builders to construct PayPal API requests. Here is a complete example of generating an order and redirecting the user to PayPal to complete payment:

$experienceContext = ExperienceContext::make()
    ->setBrandName(config('app.name'))
    ->setShippingPreference(ShippingPreferenceEnum::NO_SHIPPING)
    ->setUserAction(UserActionEnum::PAY_NOW)
    ->setReturnUrl(route('checkout.success', ['order' => $uuid, 'transaction' => $transactionUuid]))
    ->setCancelUrl(
        route('checkout', [
            'status' => 'cancelled',
            'order' => $uuid,
            'transaction' => $transactionUuid,
        ]),
    );

$order = Order::builder()
    ->setIntent(PaymentIntentEnum::CAPTURE)
    ->setPaymentSource(
        Paypal::make()
            ->setExperienceContext($experienceContext)
            ->setEmailAddress($user->email)
    )
    ->addPurchaseUnit(
        PurchaseUnit::make()
            ->setReferenceId($transactionUuid)
            ->setAmount(10.00, 'GBP')
    )
    ->create();

$paypalRedirectLink = $order->getPaymentRedirectUrl();

This creates an order in PayPal and returns a redirect URL. Send the user there to complete payment.

Breaking It Down

Order::builder() returns an OrderBuilder instance used to construct and create the order. All methods are strongly typed with enums, so your IDE will guide you through the available options.

->setIntent(PaymentIntentEnum::CAPTURE)

The intent determines what happens when the customer approves the payment. Two options are available:

  • CAPTURE — funds are debited immediately on approval. Use this for standard purchases.
  • AUTHORIZE — the payment is reserved but not taken. Useful when you need to perform checks before charging the customer. Deliveroo is a good example: they authorise the payment when you place an order, then capture it only once the restaurant accepts — if the restaurant declines, the authorisation is released and nothing is charged.

->setPaymentSource(...)

The payment source determines how the order is funded. Currently the package supports Paypal and Token as payment sources. The Paypal source loads the hosted PayPal checkout screen, which is the most common flow for online stores.

Most payment sources require an experience context, and some accept additional source-specific options — for example, passing setEmailAddress() pre-fills the customer's email on the PayPal form.

Experience context configures the journey the customer has on the hosted PayPal page:

$experienceContext = ExperienceContext::make()
    ->setBrandName(config('app.name'))
    ->setShippingPreference(ShippingPreferenceEnum::NO_SHIPPING)
    ->setUserAction(UserActionEnum::PAY_NOW)
    ->setReturnUrl(...)
    ->setCancelUrl(...);
  • setBrandName — displayed on the PayPal order page so customers know where they are.
  • setShippingPreference — controls whether PayPal collects a shipping address. In most cases NO_SHIPPING is correct as your own checkout handles this.
  • setUserActionPAY_NOW charges the customer immediately on approval; CONTINUE returns them to a point in your checkout for further steps before capture.
  • setReturnUrl / setCancelUrl — where PayPal redirects the user on success or cancellation.

->addPurchaseUnit(...)

A purchase unit represents what the customer is buying and how much they will be charged. Currently supports a reference ID, amount, and currency. Item-level breakdown is planned for a future release.


Retrieving an Order

Much like Eloquent, the package uses models to represent PayPal resources. Retrieve an existing order by its ID:

use Drewdan\Paypal\Orders\Models\Order;

$order = Order::retrieve('some-order-id');

Available Methods

$order->authorize();     // Authorize the payment
$order->capture();       // Capture the payment

$order->getPurchaseUnits();  // Returns a Collection of purchase units
$order->listCaptures();      // Returns a Collection of captures

$order->toArray();       // Array representation, null values removed

The Order class is not final and its properties are public, so you can extend it and add your own methods.

Order Model Properties

TypePropertyDescription
stringidThe PayPal order ID
stringcreate_timeWhen the order was created
stringupdate_timeWhen the order was last updated
stringprocessing_instructionProcessing instruction string
stringintentThe payment intent
OrderStatusEnumstatusCREATED, SAVED, APPROVED, VOIDED, COMPLETED, PAYER_ACTION_REQUIRED
PurchaseUnitspurchase_unitsThe purchase units on this order
Amountgross_amountAmount and currency
BuildsPaymentSourcepayment_sourceThe payment source (most commonly Paypal)
LinkslinksAll links associated with this resource
arraypayerPayer data

Webhooks

The package registers routes to handle incoming PayPal webhook events. Webhook handlers are configured in the published paypal.php config file.

⚠️ Important: Webhook handling is not yet safe for production use. The package does not currently validate the authenticity of incoming events. Use in development environments only. Automatic signature validation is planned for the next major release.

Registering Handlers

Open config/paypal.php and find the webhook.handlers array. Keys are WebhookEventEnum values; values are either a class implementing HandlesPaypalWebhookEvent or a closure.

'webhook' => [
    'handlers' => [
        WebhookEventEnum::CHECKOUT_ORDER_APPROVED->value => PaypalCheckoutOrderApproved::class,
    ],
],

Using a closure instead of a class will prevent config caching (php artisan config:cache). Classes are recommended.

Register as many handlers as you need. Once configured, run the following Artisan command to create the webhooks in your PayPal account:

php artisan paypal:webhook-setup

Caution: This command deletes all existing webhooks on the account and recreates only those defined in your config. Take care if multiple applications share the same PayPal account.

Implementing a Handler

The HandlesPaypalWebhookEvent interface requires a handle method that receives a WebhookEvent. Call getResource() on the event to get the underlying model:

class PaypalCheckoutOrderApproved implements HandlesPaypalWebhookEvent
{
    public function handle(WebhookEvent $event): void
    {
        $resource = $event->getResource();

        $id = $resource->id;
        $status = $resource->status;

        // Look up your order, update its status, trigger fulfilment, etc.
    }
}

Registering Webhooks Manually

If you need more control — for example, multiple webhook endpoints pointing to different URLs — you can use the builder directly instead of the Artisan command:

$webhook = Webhook::builder()
    ->setUrl('https://example.com/example_webhook')
    ->setEvents([
        WebhookEventEnum::PAYMENT_AUTHORIZATION_CREATED,
        WebhookEventEnum::PAYMENT_CAPTURE_COMPLETED,
    ])
    ->create();

setEvents accepts an array or Collection of WebhookEventEnum values.

Note: if you later run paypal:webhook-setup, it will remove any webhooks registered this way.

Supported Webhook Events

enum WebhookEventEnum: string
{
    // Payments V2
    case PAYMENT_AUTHORIZATION_CREATED = 'PAYMENT.AUTHORIZATION.CREATED';
    case PAYMENT_AUTHORIZATION_VOIDED  = 'PAYMENT.AUTHORIZATION.VOIDED';
    case PAYMENT_CAPTURE_DECLINED      = 'PAYMENT.CAPTURE.DECLINED';
    case PAYMENT_CAPTURE_COMPLETED     = 'PAYMENT.CAPTURE.COMPLETED';
    case PAYMENT_CAPTURE_PENDING       = 'PAYMENT.CAPTURE.PENDING';
    case PAYMENT_CAPTURE_REFUNDED      = 'PAYMENT.CAPTURE.REFUNDED';
    case PAYMENT_CAPTURE_REVERSED      = 'PAYMENT.CAPTURE.REVERSED';

    // Orders V2
    case CHECKOUT_ORDER_COMPLETED             = 'CHECKOUT.ORDER.COMPLETED';
    case CHECKOUT_ORDER_APPROVED              = 'CHECKOUT.ORDER.APPROVED';
    case CHECKOUT_ORDER_SAVED                 = 'CHECKOUT.ORDER.SAVED';
    case CHECKOUT_ORDER_VOIDED                = 'CHECKOUT.ORDER.VOIDED';
    case CHECKOUT_PAYMENT_APPROVAL_REVERSED   = 'CHECKOUT.PAYMENT-APPROVAL.REVERSED';

    // Disputes
    case CUSTOMER_DISPUTE_CREATED   = 'CUSTOMER.DISPUTE.CREATED';
    case CUSTOMER_DISPUTE_RESOLVED  = 'CUSTOMER.DISPUTE.RESOLVED';
    case CUSTOMER_DISPUTE_UPDATED   = 'CUSTOMER.DISPUTE.UPDATED';

    /** @deprecated */
    case RISK_DISPUTE_CREATED = 'RISK.DISPUTE.CREATED';
}

Contributing

The package is open source and not yet feature complete. Planned work includes Laravel 11 support, item-level purchase unit breakdown, additional payment sources (Venmo etc.), and webhook signature validation.

PRs and issues are welcome on GitHub.

Have thoughts on this? Get in touch.