Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel PayPal Integration for One-Time Payments

By Kokil Thapa | Last reviewed: September 2026

Your checkout works in staging, but production PayPal payments fail silently because the PayPal account credentials, sandbox mode, or capture logic do not match what the REST API expects. For cross-border sales from Nepal or any Laravel storefront, Laravel PayPal integration for one-time payments must use Orders API v2 with server-side verification—not legacy NVP/SOAP wrappers or client-side success callbacks. This guide covers account setup, order capture, refunds, webhooks, and the Nepal-specific settlement gotchas I hit on real client projects.

If you are comparing gateways, my guide to Laravel payment integrations covers eSewa, Khalti, and ConnectIPS for domestic NPR checkout. PayPal still wins for international buyers who expect USD or EUR settlement. The pattern below treats PayPal as a direct HTTP integration inside Laravel 13.x on PHP 8.3 or higher, which keeps debugging straightforward when a payment fails at midnight.

How do I set up a PayPal account for Laravel development?

Before you write a line of PHP, you need two PayPal account types in the Developer Dashboard: a Business (merchant) account and a Personal (buyer) sandbox account. The Business account supplies your REST API client ID and secret. The Personal account simulates checkout during testing.

Create REST app credentials

Log into the PayPal Developer Dashboard, open Apps & Credentials, and create a Sandbox app. Copy the Client ID and Secret into your Laravel .env. Repeat the process under the Live tab when you are ready for production. Never commit these values to Git.

PAYPAL_CLIENT_ID=your_sandbox_client_id
PAYPAL_SECRET=your_sandbox_secret
PAYPAL_WEBHOOK_ID=your_webhook_id
PAYPAL_MODE=sandbox

Business vs personal account rules

Commercial Laravel sites must run on a Business PayPal account. Personal accounts face limits and can be frozen if transaction volume looks like commerce. For Nepal-based sellers, link a Payoneer or Wise business account for withdrawals. Direct NPR settlement through PayPal is not available. Price in USD or EUR at the API layer and show an NPR equivalent in the UI using live rates from a tool like the Nepal forex rates converter.

PayPal Account Setup for LaravelDeveloperDashboardSandbox AppClient ID + SecretLive AppProduction onlyLaravel .env MappingPAYPAL_MODE=sandboxPAYPAL_MODE=liveNever mix sandbox keys on production servers
Separate PayPal account credentials for sandbox testing and live Laravel PayPal integration

How do I configure Laravel for PayPal REST API v2?

The most common mistake in 2026 is installing abandoned Composer wrappers that target deprecated APIs. PayPal's Orders API v2 documentation is clear enough to call directly with Laravel's HTTP client. That removes a dependency layer and makes production debugging far easier.

Environment variables and configuration

PayPal uses OAuth 2.0 client credentials for server-to-server calls. Add this to config/services.php:

<?php
// config/services.php

'paypal' => [
    'client_id'  => env('PAYPAL_CLIENT_ID'),
    'secret'     => env('PAYPAL_SECRET'),
    'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
    'mode'       => env('PAYPAL_MODE', 'sandbox'),
    'base_uri'   => env('PAYPAL_MODE') === 'live'
        ? 'https://api-m.paypal.com'
        : 'https://api-m.sandbox.paypal.com',
],

On production Laravel apps I maintain, the mode toggle lives only in environment config. Staging must never accidentally charge live cards. For e-commerce builds, see our e-commerce development service if you need checkout architecture beyond a single gateway.

Authentication service with token caching

Access tokens expire after roughly nine hours. Cache them for eight hours to avoid hitting the auth endpoint on every checkout click.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class PayPalService
{
    public function getAccessToken(): string
    {
        return Cache::remember('paypal_access_token', now()->addHours(8), function () {
            $response = Http::asForm()
                ->withBasicAuth(
                    config('services.paypal.client_id'),
                    config('services.paypal.secret')
                )
                ->post(config('services.paypal.base_uri') . '/v1/oauth2/token', [
                    'grant_type' => 'client_credentials',
                ]);

            if (!$response->successful()) {
                throw new \RuntimeException('PayPal auth failed: ' . $response->body());
            }

            return $response->json('access_token');
        });
    }
}

Store tokens in Redis when you run multiple app servers. File cache works on a single VPS. Queue workers that process refunds need the same cached token logic.

How do I create and capture PayPal orders securely in Laravel?

Security means never trusting the browser. A manipulated JavaScript callback is not proof of payment. Your Laravel backend must call PayPal's capture endpoint and verify the returned amount before marking an order paid or delivering digital goods.

Step 1: Create the order server-side

The frontend requests an order ID from Laravel. Use intent: CAPTURE for immediate one-time payments.

<?php

public function createOrder(Request $request): JsonResponse
{
    $validated = $request->validate([
        'amount'    => 'required|numeric|min:0.50',
        'currency'  => 'required|in:USD,EUR,GBP',
        'item_desc' => 'required|string|max:127',
    ]);

    $token = app(PayPalService::class)->getAccessToken();

    $response = Http::withToken($token)
        ->post(config('services.paypal.base_uri') . '/v2/checkout/orders', [
            'intent' => 'CAPTURE',
            'purchase_units' => [[
                'reference_id' => 'order-' . Str::uuid(),
                'description'  => $validated['item_desc'],
                'amount' => [
                    'currency_code' => $validated['currency'],
                    'value' => number_format($validated['amount'], 2, '.', ''),
                ],
            ]],
            'application_context' => [
                'return_url'  => route('paypal.success'),
                'cancel_url'  => route('paypal.cancel'),
                'brand_name'  => config('app.name'),
                'user_action' => 'PAY_NOW',
            ],
        ]);

    if (!$response->successful()) {
        Log::error('PayPal order failed', $response->json());
        return response()->json(['error' => 'Payment initiation failed'], 500);
    }

    Order::create([
        'paypal_order_id' => $response->json('id'),
        'amount'          => $validated['amount'],
        'currency'        => $validated['currency'],
        'status'          => 'pending',
    ]);

    return response()->json(['id' => $response->json('id')]);
}

Step 2: Capture and verify after approval

After the buyer approves in the PayPal modal, your JavaScript calls a Laravel capture route. Compare the captured amount against your database record. Mismatches indicate tampering.

<?php

public function captureOrder(string $paypalOrderId): JsonResponse
{
    $token = app(PayPalService::class)->getAccessToken();

    $response = Http::withToken($token)
        ->post(config('services.paypal.base_uri') . "/v2/checkout/orders/{$paypalOrderId}/capture");

    if (!$response->successful() || $response->json('status') !== 'COMPLETED') {
        return response()->json(['error' => 'Capture failed'], 400);
    }

    $capture = $response->json('purchase_units.0.payments.captures.0');
    $localOrder = Order::where('paypal_order_id', $paypalOrderId)->firstOrFail();

    if ($capture['amount']['value'] !== number_format($localOrder->amount, 2, '.', '')) {
        Log::warning('PayPal amount mismatch', [
            'expected' => $localOrder->amount,
            'received' => $capture['amount']['value'],
        ]);
        return response()->json(['error' => 'Verification failed'], 409);
    }

    $localOrder->update([
        'status'            => 'paid',
        'paypal_capture_id' => $capture['id'],
        'paid_at'           => now(),
    ]);

    return response()->json(['status' => 'success']);
}
One-Time Payment Capture FlowBrowserLaravelPayPal APIMySQLApproveCaptureLoad orderVerify amountMark status = paidJSON OK
Server-side capture and amount verification for Laravel PayPal integration for one-time payments

On platforms like Nepal Gift Card-style digital delivery, I dispatch fulfilment only after the database row shows paid. Never trigger emails from the frontend success handler alone. For queue-based fulfilment patterns, read about Laravel queues with Redis in production.

How do I process PayPal refunds from a Laravel application?

Refund queries are common once a store goes live. Buyers expect partial or full refunds through your admin panel—not manual PayPal dashboard clicks. PayPal refunds run against the capture ID, not the order ID. Store paypal_capture_id at payment time.

Full and partial refund API call

Use the Captures refund endpoint. Omit the amount field for a full refund. Include it for partial refunds.

<?php

public function refundCapture(Order $order, ?float $partialAmount = null): array
{
    if ($order->status !== 'paid' || empty($order->paypal_capture_id)) {
        throw new \InvalidArgumentException('Order is not refundable.');
    }

    $token = app(PayPalService::class)->getAccessToken();
    $payload = [];

    if ($partialAmount !== null) {
        $payload['amount'] = [
            'value'         => number_format($partialAmount, 2, '.', ''),
            'currency_code' => $order->currency,
        ];
    }

    $response = Http::withToken($token)
        ->post(
            config('services.paypal.base_uri') . "/v2/payments/captures/{$order->paypal_capture_id}/refund",
            $payload
        );

    if (!$response->successful()) {
        Log::error('PayPal refund failed', $response->json());
        throw new \RuntimeException('Refund request rejected by PayPal.');
    }

    $refund = $response->json();

    $order->update([
        'status'           => $partialAmount ? 'partially_refunded' : 'refunded',
        'paypal_refund_id' => $refund['id'],
        'refunded_at'      => now(),
    ]);

    return $refund;
}

Refund status and idempotency

PayPal refund status can be COMPLETED or PENDING depending on the funding source. Listen for PAYMENT.CAPTURE.REFUNDED webhooks to confirm settlement. Use idempotency: check whether paypal_refund_id already exists before calling the API again. Duplicate refund attempts create support headaches and accounting mismatches.

Document your refund policy on product pages. Good templates appear in our post on e-commerce refund policy templates. For PCI-aware handling of payment data, review PCI DSS essentials for developers.

PayPal Refund Flow in LaravelAdmin ActionFull or partialRefund ServicePOST /captures/refundPayPal APIReturns refund IDDatabase Updatespaypal_refund_idstatus = refundedWebhookConfirm PAYMENT.CAPTURE.REFUNDED before closing ticket
Processing PayPal refunds through Laravel using the Captures API and webhook confirmation

Why are webhooks essential for reliable PayPal payment confirmation?

Synchronous capture works most of the time. Networks fail. Users close tabs. PayPal occasionally delays settlement. Without webhooks, those edge cases become orphaned orders and angry support emails. Webhooks deliver asynchronous truth when your capture endpoint times out but PayPal already moved the money.

Verifying webhook signatures

Never process webhook JSON without signature verification. Attackers can POST fake payloads to your endpoint. PayPal signs every notification. Validate through the verify endpoint documented in the PayPal Webhooks API.

<?php

public function handleWebhook(Request $request): Response
{
    $verified = Http::withToken(app(PayPalService::class)->getAccessToken())
        ->post(config('services.paypal.base_uri') . '/v1/notifications/verify-webhook-signature', [
            'auth_algo'         => $request->header('PAYPAL-AUTH-ALGO'),
            'cert_url'          => $request->header('PAYPAL-CERT-URL'),
            'transmission_id'   => $request->header('PAYPAL-TRANSMISSION-ID'),
            'transmission_sig'  => $request->header('PAYPAL-TRANSMISSION-SIG'),
            'transmission_time' => $request->header('PAYPAL-TRANSMISSION-TIME'),
            'webhook_id'        => config('services.paypal.webhook_id'),
            'webhook_event'     => $request->all(),
        ]);

    if ($verified->json('verification_status') !== 'SUCCESS') {
        return response('Invalid signature', 400);
    }

    match ($request->input('event_type')) {
        'PAYMENT.CAPTURE.COMPLETED' => $this->markPaid($request),
        'PAYMENT.CAPTURE.REFUNDED'  => $this->markRefunded($request),
        default => null,
    };

    return response('OK', 200);
}

Exclude the webhook route from CSRF middleware. Protect it with signature validation instead. Register webhook URLs manually in the PayPal dashboard for both sandbox and live. For broader event-driven patterns, see my guide on building real-time features in Laravel using WebSockets and Redis and Laravel webhooks done reliably.

What are the common pitfalls for Nepal-based developers integrating PayPal?

Generic tutorials ignore Nepal-specific constraints. These issues recur on every cross-border Laravel store I ship or maintain.

ChallengeImpactPractical Solution
Currency restrictionsNPR cannot be received directly into a PayPal balance.Charge USD or EUR via API. Show NPR equivalent in the UI for trust.
Sandbox verificationNepali phone numbers sometimes fail SMS checks in sandbox.Use virtual numbers for sandbox only. Live Business accounts verify with NP numbers.
Withdrawal channelsNo direct PayPal-to-Nepal bank transfer.Route through Payoneer or Wise. Budget 2–3% conversion fees (~Rs 300 per USD 10).
Regulatory complianceNRB rules limit certain outbound digital flows.Use a registered Business PayPal account. Keep PAN/VAT records aligned with IRD filings.
Refund timingPayPal refund can take 5–10 business days to reach the buyer's card.Set customer expectations in your refund policy. Track refund status via webhooks.

For domestic checkout, pair PayPal with local gateways. My guide to Laravel Khalti and eSewa integration covers NPR settlement. A broader comparison lives in e-commerce payment gateway options for Nepal. International withdrawal paths are covered in Nepal international payments via PayPal, Wise, and Payoneer.

Nepal Payment Gateway ChoiceBuyer location?Nepal domesticInternationaleSewa / KhaltiNPR settlementPayPal REST v2USD → PayoneerHybrid checkoutBoth rails in one cartLocal gateway guides
Choosing PayPal versus domestic gateways for Laravel e-commerce in Nepal

How should I test Laravel PayPal integration before going live?

Payment testing differs from CRUD testing. You need end-to-end sandbox runs with real PayPal sandbox accounts—not mocked HTTP responses alone.

  • Separate sandbox accounts: Create one Personal buyer and one Business seller per project in the Developer Dashboard.
  • Simulate failures: Use sandbox mock headers to trigger INSTRUMENT_DECLINED and confirm your UI handles errors gracefully.
  • Test webhooks locally: Expose your dev server with ngrok or Cloudflare Tunnel. Register the tunnel URL as a sandbox webhook endpoint.
  • Format amounts correctly: PayPal rejects 10. Send 10.00 via number_format($amount, 2, '.', '').
  • Test refund flows: Run a full payment, then issue a partial and full refund. Confirm database status and webhook events match.
  • Mobile checkout: Test on real devices. The PayPal app handoff behaves differently from desktop modals.

Automate sandbox smoke tests in CI when you manage multiple client stores. Read about CI/CD pipeline setup for Nepal teams to catch breaking API changes before deploy. A reference implementation pattern appears in our Quick And Easy Nepalese Grocery portfolio case, which combines local delivery logic with international payment options.

Key Takeaways

  • Create separate Sandbox and Live REST apps in your PayPal account; never mix credentials across environments.
  • Use Orders API v2 with server-side capture and amount verification—never trust frontend success callbacks alone.
  • Store paypal_capture_id for refunds; call /v2/payments/captures/{id}/refund with idempotency checks.
  • Verify every webhook signature before updating order or refund status in your database.
  • For Nepal sellers, charge USD/EUR via API, display NPR for UX, and withdraw through Payoneer or Wise.
  • Send customer confirmations only after your database shows paid or refunded, not from JavaScript handlers.

People Also Ask

Do I need a Business PayPal account for Laravel integration?

Yes. REST API credentials for production checkout come from a Business account app in the Developer Dashboard. Personal accounts lack the API access and volume limits that commercial Laravel stores require.

Can I accept NPR directly through PayPal in Laravel?

You can display NPR prices for user experience, but the PayPal Orders API must charge a supported currency like USD or EUR. NPR settlement into a Nepali bank account is not available through PayPal alone.

How long does a PayPal refund take to process?

The API response is immediate, but funds typically return to the buyer's funding source within five to ten business days. Track PAYMENT.CAPTURE.REFUNDED webhooks and update your order status when PayPal confirms completion.

Should I use a Composer package or call PayPal directly?

Direct HTTP calls via Laravel's HTTP client are easier to maintain in 2026. Many wrapper packages lag behind Orders v2 changes. Fewer dependencies mean fewer surprises during PHP or Laravel upgrades.

Ship PayPal Checkout With Confidence

Production-grade Laravel PayPal integration for one-time payments comes down to three habits: verify every capture on the server, confirm async events through signed webhooks, and handle refunds against capture IDs with idempotent database updates. Get the PayPal account setup, refund path, and webhook layer right before you chase conversion tweaks—payments are the trust foundation for every digital store. For hybrid checkout architecture or security review before launch, contact us or reach out directly to discuss your stack. You can also explore API development services and building fast Laravel e-commerce platforms for the wider checkout picture.

Frequently Asked Questions

Use srmklive/laravel-paypal version 3.x. It supports PayPal Checkout v2, works with Laravel 12 and PHP 8.4, and handles one-time payment flows without unnecessary subscription complexity.

PayPal charges no setup or monthly fees for standard checkout. Transaction fees are typically 3.49% plus Rs 65 (~USD 0.49) per sale in Nepal. Development costs depend on scope; a basic integration usually takes 8–16 hours.

Yes. Personal accounts cannot accept payments via API. Create a free PayPal Business account and generate REST API credentials from the developer dashboard before starting integration.

Add PAYPAL_SANDBOX_CLIENT_ID and PAYPAL_SANDBOX_CLIENT_SECRET to your .env file, never commit them to Git. In config/paypal.php, reference these via env() helpers. Use separate sandbox and live credential sets, switching only through environment variables during deployment. On production servers I manage with Deployer, these values stay in the shared .env file outside the release directory, preventing accidental exposure during zero-downtime deploys.

Checkout v2 uses RESTful JSON endpoints, server-side order creation, and client-side SDK approval. Older Express Checkout relied on NVP/SOAP XML and browser redirects. For new Laravel projects in 2026, always use v2. The srmklive package abstracts this correctly. I have migrated legacy legal-tech portals from Express Checkout to v2, and the reduction in redirect-related failures was immediate and measurable.

Register a POST route at /api/paypal/webhook and verify the signature using PayPal’s webhook-signature header against your stored webhook ID. Never trust unverified payloads. Log raw events before processing. In my experience building eCommerce systems like Nepal Gift Card, skipping signature verification led to fraudulent order confirmations during testing. Always validate first, then dispatch a queued job to update order status idempotently.

This error occurs when the sandbox buyer account lacks a valid funding source. Log into the PayPal sandbox buyer account, add a test credit card or bank, and ensure sufficient balance. Also confirm your sandbox app has "Accept Payments" enabled. I have seen this repeatedly during client demos where developers assumed sandbox accounts were pre-funded. Resetting the buyer account or generating a new one via the developer dashboard resolves it quickly.

Yes, and you should. Store the PayPal order ID, capture ID, payer email, and amount in your orders table. These serve as audit trails and reconciliation keys. Never rely solely on PayPal’s dashboard for financial records. On legal service portals I have built, storing these fields enabled automated invoice generation and dispute resolution without manual lookup. Index the capture_id column for fast refund or status checks.

Make your webhook handler idempotent by checking if the capture_id already exists before updating any order. Use database unique constraints on capture_id as a safety net. Queue the processing job so retries do not re-execute business logic. In production Laravel applications I maintain, duplicate webhook deliveries caused double inventory deductions until we added this guard. Idempotency is non-negotiable for payment integrations.

No. PayPal converts currencies at its own rate if you send an amount in a currency different from the buyer’s. To avoid unexpected margins, always send amounts in your settlement currency (e.g., USD). If pricing in NPR, convert server-side using a fixed or fetched rate before creating the PayPal order. On WooCommerce stores like Petals Nepal serving international customers, we explicitly set USD at checkout to prevent PayPal’s less favorable conversion.

Use ngrok or Cloudflare Tunnel to expose your local Laravel app to PayPal webhooks. Update your sandbox app’s webhook URL to the tunnel endpoint. Alternatively, use PayPal’s simulator for basic event testing, but real end-to-end testing requires a reachable URL. I routinely use ngrok during development of legal-tech portals to validate webhook flows before deploying to staging. Remember to update the tunnel URL each time it regenerates.

The order remains in CREATED or APPROVED state until captured. Implement a scheduled command that queries uncaptured orders older than 30 minutes and either captures or voids them based on business rules. Relying on client-side completion is unsafe. On booking systems like Adventure Third Pole Trek, we run a cleanup job every five minutes to expire abandoned PayPal sessions, preventing inventory locks and false reservations.

Use the PayPal Refund Capture API via your package’s refund method, passing the original capture_id. Store the refund ID and status in your database. Only allow refunds from authenticated admin routes with CSRF protection. Partial refunds require specifying amount and currency. In my work on eCommerce platforms, automated refund triggers reduced support tickets significantly, but always log the full response for audit compliance and IRD record-keeping.

No, they are separate gateways with distinct APIs. PayPal handles international cards; eSewa and Khalti serve domestic Nepali users. For local businesses, integrate all three. Use a unified payment interface pattern in Laravel to abstract gateway differences. On projects like Quick And Easy Nepalese Grocery, we route Australian customers to PayPal and Nepali users to eSewa based on billing country, maximizing conversion without complicating the checkout codebase.

Enforce HTTPS everywhere, validate all webhook signatures, store credentials only in .env, use parameterized queries, and never log sensitive payer data. Enable PayPal’s IPN or webhook notifications instead of trusting return URLs. Apply rate limiting to payment endpoints. On legal portals handling sensitive client payments, I also restrict admin refund access by role using Spatie Permission and enforce 2FA. Security is architectural, not additive.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: