Code1180
PayPal Standard Checkout Integration in Laravel (React + TypeScript)
PayPal Standard Checkout Integration in Laravel 12 (React + TypeScript)
PayPal Standard is the classic redirect-based checkout flow. It is simple, reliable, and still widely used for one-time payments. In this tutorial, you will build a complete PayPal Standard integration using the Laravel 12 React starter kit (TypeScript), Inertia, and PayPal Webhooks for payment confirmation.
This guide is written for junior developers, but it covers the full scope end-to-end. You will create sandbox accounts, build the checkout flow, verify webhook signatures, and store payments in your database.
Table of Contents
- Overview
- Prerequisites
- What We Are Building
- Step 1: Create PayPal Sandbox Accounts
- Step 2: Add PayPal Configuration
- Step 3: Create the Order Model and Migration
- Step 4: Create the Payment Status Enum
- Step 5: Build the PayPal Standard Service
- Step 6: Create Form Requests
- Step 7: Add Controllers
- Step 8: Define Routes
- Step 9: Build Inertia Pages (TypeScript)
- Step 10: Configure Webhooks in PayPal
- Step 11: Test the Complete Flow
- Troubleshooting
- Conclusion
Overview
Tech Stack:
- Laravel 12
- Inertia.js v2
- React 19 + TypeScript (.tsx)
- PayPal Standard (sandbox)
- Laravel HTTP client for webhook verification
What you will implement:
- Checkout form built with Inertia React
- Order creation and persistence
- Redirect to PayPal Standard checkout
- Webhook signature verification and payment status updates
- Success and cancel pages
Prerequisites
- Laravel 12 app using the React starter kit (TypeScript)
- Basic Laravel knowledge (routes, controllers, migrations)
- A PayPal developer account
What We Are Building
The user flow will be:
- Customer fills out a checkout form in your app.
- Your server creates an order and returns an Inertia page that auto-submits a PayPal Standard form.
- Customer completes payment on PayPal.
- PayPal redirects the user back to your app.
- PayPal sends a webhook event to your server.
- Your app verifies the webhook signature and updates the order status.
Step 1: Create PayPal Sandbox Accounts
-
Go to https://developer.paypal.com.
-
Log in with your PayPal account (or create one).
-
Open Dashboard -> Sandbox -> Accounts.
-
Create two accounts:
- Business account (merchant)
- Personal account (buyer)
-
For the business account, open the account details and copy:
- Email (used as the receiver in PayPal Standard)
- Password (for logging into sandbox checkout)
-
For the personal account, copy the email and password. You will use this to test purchases.
Step 2: Add PayPal Configuration
Create a dedicated config file to keep PayPal settings in one place.
File:
config/paypal.php
<?php
return [
'endpoint' => env('PAYPAL_ENDPOINT', 'https://www.sandbox.paypal.com/cgi-bin/webscr'),
'business_email' => env('PAYPAL_BUSINESS_EMAIL'),
'currency' => env('PAYPAL_CURRENCY', 'USD'),
'api_base' => env('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com'),
'client_id' => env('PAYPAL_CLIENT_ID'),
'client_secret' => env('PAYPAL_CLIENT_SECRET'),
'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
];
Add these to your .env file:
PAYPAL_ENDPOINT=https://www.sandbox.paypal.com/cgi-bin/webscr
PAYPAL_BUSINESS_EMAIL=merchant-facilitator@example.com
PAYPAL_CURRENCY=USD
PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
PAYPAL_CLIENT_ID=your-sandbox-client-id
PAYPAL_CLIENT_SECRET=your-sandbox-client-secret
PAYPAL_WEBHOOK_ID=your-sandbox-webhook-id
Step 3: Create the Order Model and Migration
Create a model and migration for PayPal orders.
php artisan make:model PayPalOrder -m --no-interaction
File:
database/migrations/xxxx_xx_xx_create_paypal_orders_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('paypal_orders', function (Blueprint $table) {
$table->id();
$table->uuid('order_number')->unique();
$table->string('status');
$table->decimal('amount', 10, 2);
$table->string('currency', 3);
$table->string('description');
$table->string('payer_email')->nullable();
$table->string('payer_id')->nullable();
$table->string('paypal_txn_id')->nullable();
$table->string('paypal_payment_status')->nullable();
$table->string('paypal_event_id')->nullable();
$table->string('paypal_event_type')->nullable();
$table->json('paypal_webhook_payload')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('paypal_orders');
}
};
Step 4: Create the Payment Status Enum
Use an enum for order status values.
File:
app/Enums/PayPalOrderStatus.php
<?php
namespace App\Enums;
enum PayPalOrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Failed = 'failed';
case Refunded = 'refunded';
}
Step 5: Build the PayPal Standard Service
This service prepares the PayPal form fields and verifies webhook signatures.
File:
app/Services/Payments/PayPalStandard.php
<?php
namespace App\Services\Payments;
use App\Models\PayPalOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class PayPalStandard
{
/**
* @return array<string, string>
*/
public function buildCheckoutFields(PayPalOrder $order, array $customer): array
{
return [
'cmd' => '_xclick',
'business' => config('paypal.business_email'),
'item_name' => $order->description,
'amount' => number_format((float) $order->amount, 2, '.', ''),
'currency_code' => $order->currency,
'invoice' => $order->order_number,
'custom' => $order->order_number,
'return' => route('paypal.return', ['order' => $order->order_number]),
'cancel_return' => route('paypal.cancel', ['order' => $order->order_number]),
'email' => $customer['email'],
];
}
public function verifyWebhook(Request $request): bool
{
$token = $this->getAccessToken();
if (! $token) {
return false;
}
$response = Http::asJson()
->withToken($token)
->post(config('paypal.api_base').'/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('paypal.webhook_id'),
'webhook_event' => $request->all(),
]);
return $response->ok()
&& data_get($response->json(), 'verification_status') === 'SUCCESS';
}
protected function getAccessToken(): ?string
{
$response = Http::asForm()
->withBasicAuth(config('paypal.client_id'), config('paypal.client_secret'))
->post(config('paypal.api_base').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (! $response->ok()) {
return null;
}
return $response->json('access_token');
}
}
Step 6: Create Form Requests
Use form requests for validation.
php artisan make:request PayPalCheckoutRequest --no-interaction
File:
app/Http/Requests/PayPalCheckoutRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PayPalCheckoutRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email'],
'amount' => ['required', 'numeric', 'min:1', 'max:10000'],
'description' => ['required', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'amount.min' => 'The minimum charge is 1.00.',
'amount.max' => 'The maximum charge is 10,000.00.',
];
}
}
Step 7: Add Controllers
Create a checkout controller and a webhook controller.
php artisan make:controller PayPalCheckoutController --no-interaction
php artisan make:controller PayPalWebhookController --no-interaction
File:
app/Http/Controllers/PayPalCheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Enums\PayPalOrderStatus;
use App\Http\Requests\PayPalCheckoutRequest;
use App\Models\PayPalOrder;
use App\Services\Payments\PayPalStandard;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class PayPalCheckoutController extends Controller
{
public function show(): Response
{
return Inertia::render('checkout/paypal-checkout', [
'currency' => config('paypal.currency'),
'submitUrl' => route('paypal.start'),
]);
}
public function start(PayPalCheckoutRequest $request, PayPalStandard $paypal): Response
{
$data = $request->validated();
$order = PayPalOrder::query()->create([
'order_number' => (string) Str::uuid(),
'status' => PayPalOrderStatus::Pending,
'amount' => $data['amount'],
'currency' => config('paypal.currency'),
'description' => $data['description'],
'payer_email' => $data['email'],
]);
return Inertia::render('checkout/paypal-redirect', [
'endpoint' => config('paypal.endpoint'),
'fields' => $paypal->buildCheckoutFields($order, $data),
]);
}
public function success(Request $request): Response
{
return Inertia::render('checkout/paypal-result', [
'status' => 'success',
'order' => $request->route('order'),
]);
}
public function cancel(Request $request): Response
{
return Inertia::render('checkout/paypal-result', [
'status' => 'cancel',
'order' => $request->route('order'),
]);
}
}
File:
app/Http/Controllers/PayPalWebhookController.php
<?php
namespace App\Http\Controllers;
use App\Enums\PayPalOrderStatus;
use App\Models\PayPalOrder;
use App\Services\Payments\PayPalStandard;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PayPalWebhookController extends Controller
{
public function store(Request $request, PayPalStandard $paypal): Response
{
if (! $paypal->verifyWebhook($request)) {
return response('Invalid webhook', 400);
}
$payload = $request->all();
$resource = $payload['resource'] ?? [];
$orderNumber = $resource['invoice_number'] ?? $resource['custom'] ?? $payload['invoice'] ?? null;
if (! $orderNumber) {
return response('Missing order number', 400);
}
$order = PayPalOrder::query()->where('order_number', $orderNumber)->first();
if (! $order) {
return response('Order not found', 404);
}
$eventType = $payload['event_type'] ?? '';
$status = match ($eventType) {
'PAYMENT.SALE.COMPLETED' => PayPalOrderStatus::Paid,
'PAYMENT.SALE.REFUNDED' => PayPalOrderStatus::Refunded,
'PAYMENT.SALE.DENIED' => PayPalOrderStatus::Failed,
default => $order->status,
};
$order->update([
'status' => $status,
'payer_email' => $resource['payer_email'] ?? $order->payer_email,
'payer_id' => $resource['payer_id'] ?? $order->payer_id,
'paypal_txn_id' => $resource['id'] ?? $order->paypal_txn_id,
'paypal_payment_status' => $resource['state'] ?? $order->paypal_payment_status,
'paypal_event_id' => $payload['id'] ?? null,
'paypal_event_type' => $eventType,
'paypal_webhook_payload' => $payload,
]);
return response('OK', 200);
}
}
Step 8: Define Routes
Add routes for checkout, return, cancel, and webhooks. Use named routes so the PayPal service can build URLs.
File:
routes/web.php
<?php
use App\Http\Controllers\PayPalCheckoutController;
use App\Http\Controllers\PayPalWebhookController;
use Illuminate\Support\Facades\Route;
Route::get('/checkout/paypal', [PayPalCheckoutController::class, 'show'])->name('paypal.checkout');
Route::post('/checkout/paypal', [PayPalCheckoutController::class, 'start'])->name('paypal.start');
Route::get('/checkout/paypal/success/{order}', [PayPalCheckoutController::class, 'success'])->name('paypal.return');
Route::get('/checkout/paypal/cancel/{order}', [PayPalCheckoutController::class, 'cancel'])->name('paypal.cancel');
Route::post('/webhooks/paypal', [PayPalWebhookController::class, 'store'])->name('paypal.webhook');
Exempt the webhook route from CSRF protection
PayPal webhooks post directly to your server, so they cannot include a CSRF token. In Laravel 12, add a CSRF exception in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/paypal',
]);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
If you are using Wayfinder and the Vite plugin is not active, regenerate routes after adding routes:
php artisan wayfinder:generate --no-interaction
Step 9: Build Inertia Pages (TypeScript)
Create three pages for checkout, redirect, and result.
Checkout Page
File:
resources/js/pages/checkout/paypal-checkout.tsx
import { Head, Form } from '@inertiajs/react';
interface CheckoutProps {
currency: string;
submitUrl: string;
}
export default function PayPalCheckout({ currency, submitUrl }: CheckoutProps) {
return (
<div className="mx-auto max-w-xl p-6">
<Head title="PayPal Checkout" />
<h1 className="text-3xl font-semibold">PayPal Checkout</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter your details to continue to PayPal.
</p>
<Form action={submitUrl} method="post" className="mt-6 flex flex-col gap-4">
{({ errors, processing }) => (
<>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium">Email</span>
<input
name="email"
type="email"
className="rounded-md border px-3 py-2"
placeholder="buyer@example.com"
required
/>
{errors.email && <span className="text-sm text-red-600">{errors.email}</span>}
</label>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium">Amount ({currency})</span>
<input
name="amount"
type="number"
step="0.01"
min="1"
className="rounded-md border px-3 py-2"
placeholder="49.00"
required
/>
{errors.amount && <span className="text-sm text-red-600">{errors.amount}</span>}
</label>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium">Description</span>
<input
name="description"
type="text"
className="rounded-md border px-3 py-2"
placeholder="Laravel course purchase"
required
/>
{errors.description && (
<span className="text-sm text-red-600">{errors.description}</span>
)}
</label>
<button
type="submit"
className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
disabled={processing}
>
{processing ? 'Processing...' : 'Continue to PayPal'}
</button>
</>
)}
</Form>
</div>
);
}
Auto-Redirect Page
File:
resources/js/pages/checkout/paypal-redirect.tsx
import { Head } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
interface RedirectProps {
endpoint: string;
fields: Record<string, string>;
}
export default function PayPalRedirect({ endpoint, fields }: RedirectProps) {
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
formRef.current?.submit();
}, []);
return (
<div className="mx-auto max-w-xl p-6">
<Head title="Redirecting to PayPal" />
<h1 className="text-2xl font-semibold">Redirecting to PayPal...</h1>
<p className="mt-2 text-sm text-muted-foreground">
If you are not redirected, click the button below.
</p>
<form ref={formRef} action={endpoint} method="post" className="mt-6">
{Object.entries(fields).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
<button type="submit" className="rounded-md bg-black px-4 py-2 text-white">
Continue to PayPal
</button>
</form>
</div>
);
}
Success or Cancel Page
File:
resources/js/pages/checkout/paypal-result.tsx
import { Head, Link } from '@inertiajs/react';
interface ResultProps {
status: 'success' | 'cancel';
order: string;
}
export default function PayPalResult({ status, order }: ResultProps) {
const isSuccess = status === 'success';
return (
<div className="mx-auto max-w-xl p-6">
<Head title={isSuccess ? 'Payment Complete' : 'Payment Cancelled'} />
<h1 className="text-3xl font-semibold">
{isSuccess ? 'Payment Complete' : 'Payment Cancelled'}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Order: {order}
</p>
<p className="mt-4">
{isSuccess
? 'Thanks for your purchase. We will email you a receipt after webhook confirmation.'
: 'No payment was processed. You can try again if you want.'}
</p>
<Link href="/checkout/paypal" className="mt-6 inline-flex text-blue-600 hover:underline">
Back to checkout
</Link>
</div>
);
}
Step 10: Configure Webhooks in PayPal
For PayPal Standard, webhooks are the recommended way to confirm payments.
- Log in to the PayPal sandbox developer dashboard.
- Go to Apps & Credentials and create a REST app.
- Copy the Client ID and Secret for sandbox.
- Go to the app and add a Webhook URL, for example:
https://your-domain.test/webhooks/paypal
-
Save the webhook and copy the Webhook ID into your
.envfile. -
Select these events:
PAYMENT.SALE.COMPLETEDPAYMENT.SALE.DENIEDPAYMENT.SALE.REFUNDED
Step 11: Test the Complete Flow
- Run your migrations:
php artisan migrate --no-interaction
- Open
/checkout/paypalin the browser. - Enter your sandbox personal account email in the checkout form.
- Submit the form. You should be redirected to PayPal sandbox.
- Log in using the sandbox personal account and complete payment.
- PayPal redirects to your success page.
- PayPal sends a webhook event to your endpoint and marks the order as paid.
You can confirm the order in the database:
select * from paypal_orders order by id desc limit 5;
Troubleshooting
Webhook not firing
- Confirm the webhook URL is correct.
- Make sure your local environment is accessible from PayPal (use a tunneling tool).
- Verify the webhook route is excluded from CSRF.
Payment status not updating
- Ensure the API base URL matches sandbox or live.
- Check that your webhook events include
PAYMENT.SALE.COMPLETED. - Log the webhook payload and confirm
resource.stateiscompleted.
Invalid webhook responses
- Ensure the webhook ID matches the one from the PayPal dashboard.
- Check that signature headers are forwarded by your proxy.
- Verify your REST app credentials are correct.
Conclusion
You now have a full PayPal Standard integration for Laravel 12 with React and TypeScript:
- Checkout form with Inertia
- Server-side order creation
- PayPal Standard redirect flow
- Webhook verification and order status updates
From here, you can add features like coupon codes, taxes, and receipt emails. The core flow stays the same.