A minimal plain PHP app demonstrating a complete PayPal Checkout flow
using the official
paypal/paypal-server-sdk
on the server and the
PayPal JavaScript SDK v6 web components
on a plain HTML front-end. No framework required.
It shows the full server-side pattern: create an order, let the buyer approve it in the PayPal UI, then capture the payment — with prices held on the server so they can't be tampered with from the browser.
-
Create a sandbox app at developer.paypal.com to get a Client ID and Secret.
-
Copy the env template and fill in your credentials:
cp .env.example .env
PAYPAL_CLIENT_ID=your_sandbox_client_id PAYPAL_CLIENT_SECRET=your_sandbox_client_secret PAYPAL_ENV=sandbox # or "live"
-
Install and run:
composer install composer start
-
Open http://localhost:3000 and pay with a sandbox test account or a generated test card.
Opening this repo in a Codespace uses the .devcontainer config to start a
PHP 8.3 container, run composer install, and copy .env.example to .env
for you automatically. You still need to open .env and swap in your own
Sandbox PAYPAL_CLIENT_ID/PAYPAL_CLIENT_SECRET before running
composer start — the placeholder values won't authenticate.
The checkout is a three-legged handshake between the browser, this server, and PayPal. The browser never talks to PayPal's REST API directly — it only ever calls this server, which holds the secret and the prices.
Browser This server PayPal
│ GET /api/config │ │
│ ───────────────────────────▶│ clientId, env, sdkUrl │
│ ◀───────────────────────────│ │
│ (loads PayPal SDK, renders <paypal-button>) │
│ │ │
│ click → POST /api/orders │ createOrder(item) │
│ ───────────────────────────▶│ ────────────────────────────▶│
│ ◀───────────────────────────│ ◀────────────────────────────│ order id
│ (buyer approves in PayPal UI) │
│ │ │
│ POST /api/orders/:id/capture captureOrder(order) │
│ ───────────────────────────▶│ ────────────────────────────▶│
│ ◀───────────────────────────│ ◀────────────────────────────│ COMPLETED
| File | Responsibility |
|---|---|
router.php |
The whole server: PayPal client, routes, and the in-memory item catalogue. Also the front controller PHP's built-in server checks before serving a static file. |
public/index.html |
Loads the PayPal SDK v6, renders the <paypal-button> web component, and drives the checkout. |
Everything lives in router.php:
- An
$itemsarray — the stub catalogue. Prices are held server-side so the browser can't change them. - A PayPal client from
paypal/paypal-server-sdk, driven through itsOrdersController. - Hand-rolled routing on
$_SERVER['REQUEST_METHOD']and the request path — there's no framework here, and anything that doesn't match falls through to the built-in server's static file handling inpublic/.
Because PHP's built-in server (and PHP-FPM) starts a fresh process per
request, there's no in-memory store for created orders the way a long-running
Node process could keep one. The order id round-trips through the browser
instead: /api/orders returns it, and the client sends it straight back to
/api/orders/:orderId/capture.
GET /api/config— hands the front-end the publicclientId,env, and the correctsdkUrl. Loading the SDK URL from the server lets you switch between sandbox and live without touching client code.POST /api/orders— looks up the item byitemId, asks PayPal to create an order, and returns the order id.POST /api/orders/:orderId/capture— captures the previously approved order.
The paypal/paypal-server-sdk
handles the REST calls and OAuth for you. A single client is built with
PaypalServerSdkClientBuilder, configured with client-credentials auth and the
environment, then $client->getOrdersController() exposes:
createOrder(...)— creates an order withCheckoutPaymentIntent::CAPTUREand the item's amount pulled from the server-side$itemsarray.captureOrder(['id' => ...])— captures the previously approved order.
PAYPAL_ENV drives both the SDK's Environment (Sandbox vs Production) and the
front-end sdkUrl returned by /api/config, so switching between sandbox and
live is a single env change.
This is a demo. Before using anything like it in production you'd want to:
- Track order and capture status in a real database instead of trusting the round trip through the browser.
- Compute order amounts from a real cart rather than a single hardcoded
demo-product(currently$9.99 USD). - Add input validation, authentication, idempotency, and verification of PayPal webhooks for reliable payment confirmation.
- Swap the hand-rolled routing in
router.phpfor a framework (Laravel, Slim, Symfony) if the app grows beyond a couple of routes.
php -S is for local development only. In production, run this behind
PHP-FPM with Nginx or Apache: serve static files from public/ directly, and
route everything else to router.php the same way -t public router.php
does locally.
| Script | Description |
|---|---|
composer start |
Run with PHP's built-in server on http://localhost:3000. |