Skip to main content
lunarphp/paypal integrates Lunar with PayPal’s Orders v2 and Payments v2 REST APIs. It creates and captures PayPal orders, records the money against Lunar’s transaction ledger, and handles refunds, webhooks, and payment checks. The package is server-side only — it does not ship a storefront integration, because the client-side half is a handful of calls to PayPal’s JS SDK and every storefront wants it wired differently (Blade, Inertia, Livewire, a headless SPA, a native app).

Installation

Require the Composer package

The service provider registers automatically.

Publish the configuration

This publishes config/lunar/paypal.php.

Run migrations

The addon adds a paypal_orders table.

Enable the driver

Register paypal as a driver for a payment type in config/lunar/payments.php.

Add PayPal credentials

Set the following in .env:
REST API credentials can be created in the PayPal Developer Dashboard.

Configuration

The following options are available in config/lunar/paypal.php.
Credentials fall back to the equivalent services.paypal.* keys if lunar.paypal.* is unset. That fallback is deprecated and will be removed in a future release.

The checkout flow

Four steps. The storefront owns steps 2 and 4.

1. Create the PayPal order

The package registers one storefront route:
Lunar\Paypal\Http\Controllers\GetPaypalOrderController builds a PayPal order for the current session cart (Lunar\Core\Contracts\CartSession::current()) and returns only what the client needs:
It returns 422 when there is no cart to pay for, and 502 when PayPal declines to create the order. The route is throttled per order_rate_limit, since each call costs a PayPal API request. The amount is taken from the calculated cart total, in the cart’s currency. For a different payload — multiple purchase units, a custom reference_id, a line-item breakdown — bind a custom implementation of Lunar\Paypal\Contracts\PaypalInterface, or call Paypal::buildInitialOrder() from a dedicated controller instead of using this route.

2. Render the PayPal buttons

Load the SDK with the client ID and currency, and point createOrder at the route above:
/checkout/paypal is a storefront route — the package does not provide it, since what happens after payment (which page to land on, what to email, how to handle failure) is storefront-specific.

3. Customer approves

Handled entirely by PayPal. The customer may never return to the storefront; step 4 covers that case through webhooks.

4. Authorize

In a checkout controller, hand the approved PayPal order ID to the driver.
authorize() verifies the amount, captures the money, creates the order, and places it, in that order. It returns a Lunar\Core\DataObjects\PaymentAuthorize carrying success, message, and orderId.
The message on a failure is a diagnostic, not shopper-facing copy — the storefront decides what wording to show for each failure case.

Amount verification

Before capturing anything, authorize() checks the PayPal order against the expected total — the order’s total when authorizing against a placed/draft order, or the calculated cart’s total otherwise. Over-payment is deliberately allowed through: the money has already left the customer’s account, and refusing would strand a captured payment with no order attached to it. To change the policy, override the protected assertOrderMatchesTotal() method on a subclass of Lunar\Paypal\PaypalPaymentType. The comparison is done at PayPal’s precision for the currency, not Lunar’s minor unit — Lunar\Paypal\Managers\PaypalManager rescales both sides through Currency::decimal_places before comparing, using integer string arithmetic rather than a float multiply. PayPal treats most currencies as two-decimal and a handful (HUF, JPY, TWD) as zero-decimal; comparing at Lunar’s raw minor-unit total would reject a total that legitimately rounds down at PayPal’s precision.

Capture policies

automatic (default) captures at authorize time. One capture transaction is created, the order places, and the payment status is paid. manual authorizes only. The driver requests an AUTHORIZE intent from PayPal, and the held funds are recorded as an intent transaction — the order still places, with payment status authorized. Capture later, in full or in part:
PayPal authorizations typically expire after 29 days — capture within that window or the hold is lost.

Capture and refund

$amount is optional and in the order currency’s minor unit; when omitted (or 0), the transaction’s full amount is captured. This calls /v2/payments/authorizations/{id}/capture, carrying a PayPal-Request-Id idempotency header derived from the transaction reference and amount so a retried capture cannot charge twice.
$amount is required and in the order currency’s minor unit. This calls /v2/payments/captures/{id}/refund, also with an idempotency header, and populates Lunar\Core\DataObjects\PaymentRefund::$transaction with the refund row it created. Both are available as verbs on the transaction and the order:
For a refund that also allocates against specific order lines — rather than a flat amount against a transaction — use Lunar\Core\Models\Order::refund() with a Lunar\Core\DataObjects\RefundRequest. It resolves the capture transaction, computes the amount from the requested lines, shipping, and adjustment, and dispatches to the driver’s refund() under the hood.
Refunds raised directly from the PayPal dashboard are not driven through Lunar; they arrive by webhook instead (see below) and are recorded automatically, skipping any row the driver already wrote for the same PayPal refund ID.

Payment checks

PayPal returns AVS and CVV results on each capture, read from processor_response. The driver stores them and exposes them through the standard seam.

Webhooks

Without webhooks, the driver only learns what happens while the customer is on the storefront. Anything asynchronous — a capture that settles later, a customer who approves and closes the tab, a refund issued from the PayPal dashboard, a dispute — never reaches Lunar otherwise, and the order’s payment status drifts from reality. Create a webhook in the PayPal dashboard pointing at:
The path is configurable via webhook_path. Subscribe it to:
  • CHECKOUT.ORDER.APPROVED
  • PAYMENT.CAPTURE.COMPLETED
  • PAYMENT.CAPTURE.DENIED
  • PAYMENT.CAPTURE.PENDING
  • PAYMENT.CAPTURE.REFUNDED
  • CUSTOMER.DISPUTE.CREATED
Then set the ID it returns as PAYPAL_WEBHOOK_ID. Every inbound request is verified against PayPal’s /v1/notifications/verify-webhook-signature endpoint using the configured webhook ID — without a webhook ID, notifications are rejected rather than trusted. Event types outside the list above are acknowledged with a 200 and otherwise ignored. CHECKOUT.ORDER.APPROVED and PAYMENT.CAPTURE.COMPLETED carry an approved or captured PayPal order through to a placed Lunar order, covering a customer who never returns to the storefront to trigger authorize() themselves. PAYMENT.CAPTURE.REFUNDED records a refund transaction for refunds issued outside Lunar. To act on events directly, listen for Lunar\Paypal\Events\PaypalWebhookReceived — it fires for every verified webhook the driver accepts, including ones it does not otherwise act on:

Facade methods

Lunar\Paypal\Facades\Paypal provides direct access to the PayPal client, bound to Lunar\Paypal\Contracts\PaypalInterface.

Get a PayPal order

Capture an approved order

Authorize an approved order

Capture a previously authorized payment

Refund a capture

$amount and $currencyCode are PayPal-precision strings — use PaypalManager::toPaypalAmount() to build $amount from a Lunar minor-unit value.

Build an order from a cart

Calculates the cart if it has not been calculated yet, and reads intent (CAPTURE or AUTHORIZE) from the configured policy.

Get an access token

Fetched from PayPal and cached for its stated lifetime minus a 60-second safety margin, keyed to the configured environment so a sandbox token can never be reused against live.

Extending

The client is bound to Lunar\Paypal\Contracts\PaypalInterface as a scoped binding. Swap it in a service provider:
The driver itself is a Lunar\Core\Contracts\PaymentType. Subclass Lunar\Paypal\PaypalPaymentType and re-register it to change authorization behavior:

Database

The addon adds a paypal_orders table (Lunar\Paypal\Models\PaypalOrder), giving the driver a double-processing guard and giving webhooks a way to resolve an inbound PayPal order ID to a cart or order.

What this package does not do

  • Render anything. No Blade components, no Livewire, no JS — see the checkout flow above.
  • Own checkout routes. /api/paypal/order is the only storefront route it adds.
  • Decide what the shopper sees. Failure messages on PaymentAuthorize are diagnostics; the copy is the storefront’s responsibility.
  • Support PayPal subscriptions or payouts. Orders and Payments only.