PayPal Integration in PHP: Orders v2 REST API Guide
Step-by-step PayPal integration in PHP and Laravel 11 with the Orders v2 REST API: create orders, capture payments, verify webhooks and avoid common errors.
PayPal integration in PHP is much simpler than most tutorials make it look, because the Orders v2 REST API can be called directly over HTTP without any of the old, deprecated SDKs. The flow is three steps: your server creates an order, the buyer approves it in the PayPal popup, and your server captures it.
I have integrated PayPal into e-commerce stores, booking systems and SaaS billing pages. In this guide I build a complete server-side integration in Laravel 11 and PHP 8.3: authentication, creating and capturing orders, the JavaScript buttons, webhook verification, and the mistakes that cause failed or double payments. The same HTTP calls work in plain PHP with Guzzle or cURL.
Prerequisites
- A PayPal developer account and a sandbox REST app (developer.paypal.com) with a Client ID and Secret
- Laravel 11 with PHP 8.3 (or any PHP 8.1+ project with Guzzle)
- An orders table in your database to store the local order and PayPal order ID
- HTTPS for production webhooks
How the PayPal Orders v2 checkout flow works
- Browser clicks the PayPal button, which calls your server endpoint
- Your server calculates the total from your database and calls POST /v2/checkout/orders
- PayPal returns an order ID, which you send back to the browser
- The buyer logs in and approves the payment in the PayPal popup
- The browser calls your capture endpoint with the order ID
- Your server calls POST /v2/checkout/orders/{id}/capture and marks the local order as paid
- A webhook confirms the capture asynchronously as a safety net
Step 1: Configure credentials
Keep the credentials in .env and switch between sandbox and live with one variable. Never expose the secret to the browser, only the Client ID goes to the front-end.
# .env
PAYPAL_MODE=sandbox
PAYPAL_CLIENT_ID=your-client-id
PAYPAL_SECRET=your-secret
PAYPAL_WEBHOOK_ID=your-webhook-id
// config/paypal.php
return [
'client_id' => env('PAYPAL_CLIENT_ID'),
'secret' => env('PAYPAL_SECRET'),
'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
'base_url' => env('PAYPAL_MODE') === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com',
];Step 2: A small PayPal client class with token caching
Access tokens last around nine hours, so fetching a new one on every request wastes time and can hit rate limits. I cache the token for slightly less than its expiry.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
class PayPal
{
private function token(): string
{
return Cache::remember('paypal.token', now()->addHours(8), function () {
return Http::asForm()
->withBasicAuth(config('paypal.client_id'), config('paypal.secret'))
->post(config('paypal.base_url') . '/v1/oauth2/token', [
'grant_type' => 'client_credentials',
])
->throw()
->json('access_token');
});
}
private function http(): PendingRequest
{
return Http::withToken($this->token())
->baseUrl(config('paypal.base_url'))
->acceptJson()
->timeout(20);
}
public function createOrder(string $reference, string $amount, string $currency = 'USD'): array
{
return $this->http()
->withHeaders(['PayPal-Request-Id' => 'create-' . $reference])
->post('/v2/checkout/orders', [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => $reference,
'custom_id' => $reference,
'amount' => [
'currency_code' => $currency,
'value' => $amount,
],
]],
])
->throw()
->json();
}
public function captureOrder(string $paypalOrderId): array
{
return $this->http()
->withHeaders(['PayPal-Request-Id' => 'capture-' . $paypalOrderId])
->withBody('{}', 'application/json')
->post("/v2/checkout/orders/{$paypalOrderId}/capture")
->throw()
->json();
}
}Step 3: Create the PayPal order on the server
Calculate the amount on the server from your own database. Never trust a price sent from the browser, anyone can edit it in DevTools. Amounts must be strings with exactly two decimals for most currencies (JPY has none).
// routes/web.php
Route::post('/paypal/orders', [PayPalController::class, 'create'])->middleware('auth');
Route::post('/paypal/orders/{paypalOrderId}/capture', [PayPalController::class, 'capture'])->middleware('auth');
// app/Http/Controllers/PayPalController.php
public function create(Request $request, PayPal $paypal)
{
$order = Order::where('user_id', $request->user()->id)
->where('status', 'pending')
->findOrFail($request->integer('order_id'));
$total = number_format($order->total_cents / 100, 2, '.', '');
$paypalOrder = $paypal->createOrder((string) $order->id, $total, 'USD');
$order->update(['paypal_order_id' => $paypalOrder['id']]);
return response()->json(['id' => $paypalOrder['id']]);
}Step 4: Capture the PayPal payment after approval
Capturing is the moment money actually moves. Check the status, compare the captured amount with what you expected, and mark the order paid inside a transaction. Store the capture ID, you need it for refunds.
public function capture(string $paypalOrderId, PayPal $paypal)
{
$order = Order::where('paypal_order_id', $paypalOrderId)->firstOrFail();
if ($order->status === 'paid') {
return response()->json(['status' => 'already_paid']);
}
$result = $paypal->captureOrder($paypalOrderId);
$capture = $result['purchase_units'][0]['payments']['captures'][0] ?? null;
$expected = number_format($order->total_cents / 100, 2, '.', '');
if (($result['status'] ?? null) !== 'COMPLETED'
|| ($capture['amount']['value'] ?? null) !== $expected) {
Log::warning('PayPal capture mismatch', ['order' => $order->id, 'result' => $result]);
return response()->json(['status' => 'failed'], 422);
}
DB::transaction(function () use ($order, $capture) {
$order->update([
'status' => 'paid',
'paypal_capture_id' => $capture['id'],
'paid_at' => now(),
]);
});
return response()->json(['status' => 'paid']);
}Step 5: Add the PayPal JavaScript buttons
Load the JavaScript SDK with your Client ID and wire the createOrder and onApprove callbacks to your two endpoints. The browser never sees the amount logic or the secret.
<div id="paypal-buttons"></div>
<script src="https://www.paypal.com/sdk/js?client-id={{ config('paypal.client_id') }}¤cy=USD"></script>
<script>
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const headers = { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' };
paypal.Buttons({
async createOrder() {
const res = await fetch('/paypal/orders', {
method: 'POST', headers, body: JSON.stringify({ order_id: {{ $order->id }} }),
});
const data = await res.json();
return data.id;
},
async onApprove(data) {
const res = await fetch('/paypal/orders/' + data.orderID + '/capture', { method: 'POST', headers });
const result = await res.json();
if (result.status === 'paid' || result.status === 'already_paid') {
window.location.href = '/orders/{{ $order->id }}/thank-you';
} else {
alert('Payment could not be completed. You have not been charged.');
}
},
onError(err) {
console.error(err);
alert('Something went wrong with PayPal. Please try again.');
},
}).render('#paypal-buttons');
</script>Step 6: Verify PayPal webhooks
If the buyer closes the tab right after approval, your capture call may never run, or a capture might be reversed later. Webhooks cover those cases. Subscribe to PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED and PAYMENT.CAPTURE.REVERSED, and always verify the signature with PayPal before trusting the payload. Exclude this route from CSRF or place it in routes/api.php.
public function webhook(Request $request, PayPal $paypal)
{
$verify = Http::withToken($paypal->publicToken())
->post(config('paypal.base_url') . '/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->json()->all(),
])->json();
if (($verify['verification_status'] ?? '') !== 'SUCCESS') {
abort(400);
}
$event = $request->input('event_type');
$resource = $request->input('resource');
if ($event === 'PAYMENT.CAPTURE.COMPLETED') {
Order::whereKey($resource['custom_id'] ?? null)
->where('status', '!=', 'paid')
->update(['status' => 'paid', 'paypal_capture_id' => $resource['id'], 'paid_at' => now()]);
}
if (in_array($event, ['PAYMENT.CAPTURE.REFUNDED', 'PAYMENT.CAPTURE.REVERSED'])) {
Order::where('paypal_capture_id', $resource['id'])->update(['status' => 'refunded']);
}
return response()->noContent();
}
// In the PayPal class, expose the cached token for this call:
public function publicToken(): string
{
return $this->token();
}Common PayPal API errors and fixes
- 401 invalid_client: sandbox credentials used against the live URL or the other way round, check PAYPAL_MODE
- 422 UNPROCESSABLE_ENTITY with DECIMAL_PRECISION: the amount was sent as a float like 49.9 or with three decimals, format it as a two-decimal string
- 422 ORDER_NOT_APPROVED: capture was called before the buyer approved, only capture inside onApprove
- 422 ORDER_ALREADY_CAPTURED: a double click or retry, handle it as success after checking your database
- INSTRUMENT_DECLINED on capture: the funding source failed, call actions.restart() in onApprove so the buyer can pick another method
- Webhook verification returns FAILURE: the webhook_id belongs to another app or environment, or the body was modified before verifying
Security and production best practices
- Always compute the amount on the server and compare it again after capture
- Send a PayPal-Request-Id header so retried requests are idempotent
- Store the PayPal order ID and capture ID for refunds, disputes and reconciliation
- Treat the webhook as the source of truth for refunds and reversals
- Log every PayPal response with the local order ID, but never log the client secret
- Test the full flow in sandbox with both a PayPal account and a card before going live
Conclusion
A reliable PayPal integration in PHP needs only a few HTTP calls to the Orders v2 API: get a token, create an order with a server-calculated amount, capture after approval, and verify webhooks for everything that happens later. Keep the logic on the server, store the IDs PayPal gives you, and make the endpoints safe to call twice. That is the structure I use in production, and it has kept payment support tickets close to zero.
Frequently asked questions
Do I need the PayPal PHP SDK to integrate PayPal?
No. The old PayPal PHP SDKs are deprecated, and the Orders v2 REST API is easy to call with Guzzle, cURL or Laravel's HTTP client. Direct calls also keep you on the current API.
What is the difference between intent CAPTURE and AUTHORIZE?
CAPTURE takes the money immediately after approval. AUTHORIZE only places a hold that you capture later, which is useful when you ship goods before charging.
How do I switch from PayPal sandbox to live?
Create a live REST app, replace the Client ID and Secret, and change the base URL to https://api-m.paypal.com. Register the webhook again for the live app, since webhook IDs differ.
How do I refund a PayPal payment in PHP?
Call POST /v2/payments/captures/{capture_id}/refund with the stored capture ID. Send an amount for a partial refund or an empty body for a full refund.
Why is my PayPal webhook not received locally?
PayPal cannot reach localhost. Use a tunnel such as ngrok or Expose and register the public HTTPS URL as the webhook in your sandbox app.