PHP Payment Integration (Stripe + PayPal)
September 16, 2025
Looking for a copy-paste PHP payment integration that supports Stripe Checkout and PayPal Checkout with webhook verification and secure server-side code?
This guide gives you a minimal, production-ready baseline you can drop into any PHP project.
- ✅ Works with Stripe (Payment Intents + Checkout Session)
- ✅ Works with PayPal (Orders v2)
- ✅ Includes server-side verification (webhooks)
- ✅ Clean, framework-free PHP (Composer only)
- ✅ SEO-optimized structure (FAQs + JSON-LD)
What We’ll Build
- A single checkout page with two buttons: Pay with Stripe or Pay with PayPal
- Simple product example: “Pro Plan — €19.00”
- Server endpoints to:
- Create a Stripe Checkout Session
- Receive Stripe webhooks (paid, refunded)
- Create PayPal orders server-side
- Capture PayPal orders server-side
- Receive PayPal webhooks (optional)
You’ll replace the test API keys, domain URLs, and product info with your real data.
Prerequisites
- PHP 8.0+
- MySQL (optional)
- Composer
- An HTTPS domain (for live webhooks)
- Accounts:
- Stripe: get
STRIPE_SECRET_KEY
+STRIPE_WEBHOOK_SECRET
- PayPal: get
PAYPAL_CLIENT_ID
+PAYPAL_CLIENT_SECRET
(Sandbox first)
- Stripe: get
1. Project Setup
Folder structure:
/payments-demo
/public
index.html
stripe-create-checkout.php
stripe-webhook.php
paypal-create-order.php
paypal-capture-order.php
paypal-webhook.php
composer.json
.env
Install SDKs:
composer require stripe/stripe-php paypal/paypal-checkout-sdk vlucas/phpdotenv
.env (create in project root):
# Stripe
STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXX
# PayPal (use Sandbox first)
PAYPAL_ENV=sandbox
PAYPAL_CLIENT_ID=AbcYourClientId
PAYPAL_CLIENT_SECRET=YourSecret
# App config
APP_BASE_URL=https://your-domain.com # no trailing slash
CURRENCY=eur
PRICE_UNIT=1900 # €19.00 -> amount in cents
PRODUCT_NAME=Pro Plan
composer.json (minimal example):
{
"require": {
"stripe/stripe-php": "^15.0",
"paypal/paypal-checkout-sdk": "^2.0",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
(No src/ code needed for this tutorial, but PSR-4 is handy if you extend later.)
Bootstrap env in each PHP entry file (top of every PHP file below):
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
?>
2. Frontend — public/index.html
Simple checkout page with two buttons. Stripe uses a server endpoint to create a Checkout Session and redirects; PayPal uses server endpoints for order create/capture, with a tiny JS helper.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout — Stripe & PayPal (PHP)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial,sans-serif;padding:40px;max-width:680px;margin:auto}
.card{border:1px solid #e5e7eb;border-radius:12px;padding:24px;margin:12px 0}
.btn{display:inline-block;padding:12px 16px;border-radius:10px;border:1px solid #111;cursor:pointer;text-decoration:none}
.btn + .btn{margin-left:8px}
.row{display:flex;gap:12px;align-items:center}
.muted{color:#6b7280}
</style>
</head>
<body>
<h1>Checkout</h1>
<p class="muted">Demo: Pro Plan — <strong>€19.00</strong></p>
<div class="card">
<h2>Pay with Stripe</h2>
<p>Uses Stripe Checkout Session with server-side creation & webhook verification.</p>
<button class="btn" id="pay-stripe">Pay €19.00 with Stripe</button>
</div>
<div class="card">
<h2>Pay with PayPal</h2>
<p>Server-created PayPal Order + server capture (no client secrets exposed).</p>
<button class="btn" id="pay-paypal">Pay €19.00 with PayPal</button>
</div>
<div class="card">
<h3>Result</h3>
<pre id="log" class="muted">Awaiting action…</pre>
</div>
<script>
const log = (msg) => document.getElementById("log").textContent = msg;
document.getElementById("pay-stripe").addEventListener("click", async () => {
log("Creating Stripe Checkout Session…");
const res = await fetch("./stripe-create-checkout.php", { method: "POST" });
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else {
log("Stripe error: " + JSON.stringify(data));
}
});
document.getElementById("pay-paypal").addEventListener("click", async () => {
log("Creating PayPal order…");
const res = await fetch("./paypal-create-order.php", { method: "POST" });
const data = await res.json();
if (data.approve_url && data.order_id) {
log("Redirecting to PayPal approval…");
// After buyer approves, PayPal redirects back to your RETURN_URL
window.location.href = data.approve_url;
} else {
log("PayPal error: " + JSON.stringify(data));
}
});
</script>
</body>
</html>
For Stripe: set your success/cancel URLs in stripe-create-checkout.php. For PayPal: set your RETURN_URL to a page that calls paypal-capture-order.php?order_id=....
3. Stripe — public/stripe-create-checkout.php
Creates a Checkout Session server-side and returns the redirect URL.
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
\Stripe\Stripe::setApiKey($_ENV["STRIPE_SECRET_KEY"] ?? "");
$baseUrl = rtrim($_ENV["APP_BASE_URL"] ?? "", "/");
$currency = $_ENV["CURRENCY"] ?? "eur";
$unit = (int)($_ENV["PRICE_UNIT"] ?? 1900);
$name = $_ENV["PRODUCT_NAME"] ?? "Pro Plan";
try {
$session = \Stripe\Checkout\Session::create([
"mode" => "payment",
"line_items" => [[
"price_data" => [
"currency" => $currency,
"unit_amount" => $unit,
"product_data" => ["name" => $name],
],
"quantity" => 1
]],
"success_url" => $baseUrl . "/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url" => $baseUrl . "/cancel",
"payment_method_types" => ["card", "sepa_debit"], // adjust as needed
]);
echo json_encode(["url" => $session->url]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(["error" => $e->getMessage()]);
}
?>
Stripe Webhook — public/stripe-webhook.php
Verifies events (e.g., checkout.session.completed) and marks orders as paid.
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
$payload = @file_get_contents("php://input");
$sig = $_SERVER["HTTP_STRIPE_SIGNATURE"] ?? "";
$endpointSecret = $_ENV["STRIPE_WEBHOOK_SECRET"] ?? "";
try {
$event = \Stripe\Webhook::constructEvent($payload, $sig, $endpointSecret);
} catch (\UnexpectedValueException $e) {
http_response_code(400); echo json_encode(["error"=>"Invalid payload"]); exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400); echo json_encode(["error"=>"Invalid signature"]); exit;
}
switch ($event->type) {
case "checkout.session.completed":
$session = $event->data->object;
// TODO: fulfill the order:
// - lookup by $session->id or client_reference_id
// - mark as paid, send email, unlock features, etc.
break;
case "charge.refunded":
// TODO: mark as refunded
break;
}
echo json_encode(["received" => true]);
?>
Stripe CLI (local testing):
stripe listen --forward-to localhost:8000/stripe-webhook.php
4. PayPal — Create & Capture (server-side)
We’ll create the order on the server, redirect the buyer to PayPal for approval, and then capture on our server after approval.
public/paypal-create-order.php
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
use PayPalCheckoutSdk\Core\PayPalHttpClient;
use PayPalCheckoutSdk\Core\SandboxEnvironment;
use PayPalCheckoutSdk\Core\ProductionEnvironment;
use PayPalCheckoutSdk\Orders\OrdersCreateRequest;
$envName = $_ENV["PAYPAL_ENV"] ?? "sandbox";
$clientId = $_ENV["PAYPAL_CLIENT_ID"] ?? "";
$clientSecret = $_ENV["PAYPAL_CLIENT_SECRET"] ?? "";
$baseUrl = rtrim($_ENV["APP_BASE_URL"] ?? "", "/");
$currency = strtoupper($_ENV["CURRENCY"] ?? "EUR");
$unitCents = (int)($_ENV["PRICE_UNIT"] ?? 1900);
$amount = number_format($unitCents / 100, 2, ".", "");
$environment = ($envName === "production")
? new ProductionEnvironment($clientId, $clientSecret)
: new SandboxEnvironment($clientId, $clientSecret);
$client = new PayPalHttpClient($environment);
$request = new OrdersCreateRequest();
$request->prefer('return=representation');
$request->body = [
"intent" => "CAPTURE",
"purchase_units" => [[
"amount" => [
"currency_code" => $currency,
"value" => $amount
],
"description" => $_ENV["PRODUCT_NAME"] ?? "Pro Plan"
]],
"application_context" => [
"brand_name" => "Your Brand",
"landing_page" => "LOGIN",
"user_action" => "PAY_NOW",
"return_url" => $baseUrl . "/paypal-return", // After approval -> your route/page
"cancel_url" => $baseUrl . "/paypal-cancel"
]
];
try {
$response = $client->execute($request);
$approve = null;
foreach ($response->result->links as $lnk) {
if ($lnk->rel === "approve") { $approve = $lnk->href; break; }
}
echo json_encode([
"order_id" => $response->result->id ?? null,
"approve_url" => $approve
]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(["error" => $e->getMessage()]);
}
?>
public/paypal-capture-order.php
Call this from your return page after approval: paypal-capture-order.php?order_id=...
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
use PayPalCheckoutSdk\Core\PayPalHttpClient;
use PayPalCheckoutSdk\Core\SandboxEnvironment;
use PayPalCheckoutSdk\Core\ProductionEnvironment;
use PayPalCheckoutSdk\Orders\OrdersCaptureRequest;
$envName = $_ENV["PAYPAL_ENV"] ?? "sandbox";
$clientId = $_ENV["PAYPAL_CLIENT_ID"] ?? "";
$clientSecret = $_ENV["PAYPAL_CLIENT_SECRET"] ?? "";
$environment = ($envName === "production")
? new ProductionEnvironment($clientId, $clientSecret)
: new SandboxEnvironment($clientId, $clientSecret);
$client = new PayPalHttpClient($environment);
$orderId = $_GET["order_id"] ?? "";
if (!$orderId) {
http_response_code(400);
echo json_encode(["error" => "Missing order_id"]);
exit;
}
try {
$request = new OrdersCaptureRequest($orderId);
$request->prefer('return=representation');
$response = $client->execute($request);
// Inspect status: COMPLETED = paid
$status = $response->result->status ?? "UNKNOWN";
if ($status === "COMPLETED") {
// TODO: fulfill order, mark as paid, send email, etc.
}
echo json_encode([
"order_id" => $orderId,
"status" => $status,
"raw" => $response->result
]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(["error" => $e->getMessage()]);
}
?>
Optional: PayPal Webhook — public/paypal-webhook.php
Set up in PayPal dashboard to receive async events (e.g., refunds, disputes).
<?php
require __DIR__ . "/../vendor/autoload.php";
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->safeLoad();
header("Content-Type: application/json; charset=utf-8");
/**
* Minimal handler; for production:
* - Validate transmission (cert verification or webhook signature)
* - Check event type + resource to update orders
*/
$body = file_get_contents("php://input");
$event = json_decode($body, true) ?: [];
$type = $event["event_type"] ?? "UNKNOWN";
switch ($type) {
case "CHECKOUT.ORDER.APPROVED":
// Optional: respond or log
break;
case "PAYMENT.CAPTURE.COMPLETED":
// TODO: mark as paid
break;
case "PAYMENT.CAPTURE.REFUNDED":
// TODO: mark as refunded
break;
}
echo json_encode(["ok" => true]);
?>
Webhook security: In production, verify PayPal webhooks using the transmission verification method (cert/signature). Stripe verification is already shown above.
5. Success & Return Pages
- Stripe success URL: handle ?session_id=... (optional: fetch Session to display receipt info)
- PayPal return URL: read token (order ID), call /paypal-capture-order.php?order_id=..., show result
Example (very basic):
<?php // public/paypal-return (pseudo route / page) $orderId = $_GET["token"] ?? ""; if ($orderId) { header("Location: ./paypal-capture-order.php?order_id=" . urlencode($orderId)); exit; } echo "Missing token (order_id)."; ?>
6. Security Checklist (Production)
- Use HTTPS everywhere (no mixed content).
- Do not expose secret keys in the client.
- Verify Stripe signature (shown) and PayPal webhook signatures (add verification).
- Persist a local Order record (status: pending → paid → fulfilled).
- Prevent double fulfillment (idempotency).
- Log all webhook/event payloads (rotate logs).
- Handle refunds/chargebacks gracefully (webhooks).
7. Quick Test Commands
PHP dev server (from /public):
php -S localhost:8000
Stripe webhook (local):
stripe listen --forward-to localhost:8000/stripe-webhook.php
8. Next Steps / Extensions
- Save orders to your DB (orders table: id, provider, amount, currency, status, customer_email, timestamps).
- Add receipt emails after successful payments.
- Add tax/VAT breakdown and invoice generation (PDF).
- Implement PayPal webhook signature verification for production compliance.
Final Notes
- Start in test/sandbox mode.
- When you go live, change .env values and webhook endpoints accordingly.
- Keep libraries updated via composer update.