Stripe Payment Integration in PHP: SDK Setup and First Payment
What is Stripe integration? The term covers the code, API keys, and UI components that let your application accept payments through Stripe’s infrastructure – card data never touches your server, and the API handles the regulatory side (3D Secure, SCA, fraud screening) for you. In PHP specifically, Stripe integration means wiring the official stripe/stripe-php SDK into your backend, embedding Stripe.js in the checkout page, and setting up a webhook endpoint for asynchronous payment events.
In one sentence of code flow: your server uses the SDK to create a PaymentIntent, the customer’s browser confirms it through Stripe.js, and a webhook tells your system when the money actually settled. Four moving parts work together there: an API key on the server, a PaymentIntent object describing one payment attempt, a JavaScript widget that collects the card data so PCI scope doesn’t cover your server, and a webhook receiver that records the final status.
How to integrate Stripe in PHP, step by step, is what this article covers end-to-end for the first three of those parts, along with the details that tend to trip people up: 3D Secure, currency units, mode mismatches. The code samples target the current major version of the SDK; run composer show stripe/stripe-php to confirm the version in your project.
Two repositories worth bookmarking before you start:
- github.com/stripe/stripe-php on GitHub – source code, issue tracker, and release notes for the Stripe PHP library itself. Clone the repo or download a release ZIP straight from the Releases page.
- github.com/stripe-samples on GitHub – runnable reference integrations maintained by Stripe. The
accept-a-paymentsample ships a PHP server plus the matching HTML frontend and works as a full downloadable source code for Stripe payment gateway integration in PHP.
Installing the Stripe PHP SDK
The SDK is a regular Composer package. From your project root:
composer require stripe/stripe-php
That pulls in the library plus its PHP extensions (curl, json, mbstring) which are enabled by default on any sane PHP build. The SDK’s composer.json sets PHP 7.2 as the floor, but anything below PHP 8.1 is effectively end-of-life for security patches, so treat 8.1 as your floor in production.
Then include the Composer autoloader once, wherever your application bootstraps:
require __DIR__ . '/vendor/autoload.php';
Installing without Composer
It comes up often enough to address directly: if your hosting environment has no shell access or you inherited a codebase without composer.json, you can download the SDK as a ZIP from the GitHub releases page and include its bundled autoloader:
require __DIR__ . '/stripe-php/init.php';
This works, but you lose automatic dependency management. When a security patch ships, you have to manually re-download and replace the folder. Composer is worth the 5 minutes of setup even on cheap shared hosting – most providers expose it through cPanel or SSH nowadays.
Verifying the install
Before writing any payment logic, confirm the SDK loaded correctly:
require __DIR__ . '/vendor/autoload.php';
echo 'SDK version: ' . \Stripe\Stripe::VERSION . PHP_EOL;
echo 'cURL: ' . (extension_loaded('curl') ? 'yes' : 'no') . PHP_EOL;
echo 'PHP: ' . PHP_VERSION . PHP_EOL;
If this prints without a fatal error, you’re ready for API keys.
API keys: test and live mode
Every Stripe account has two separate sets of keys. Test keys start with sk_test_ and pk_test_, live keys with sk_live_ and pk_live_. They’re not interchangeable: an object created with test keys only exists in test mode, and using a publishable key from one mode with a secret key from another produces a resource_missing error that’s easy to misdiagnose as a bug in your code.
Get the keys from the Stripe Dashboard under Developers > API keys. The publishable key is safe to expose in frontend code; the secret key must stay on the server. Never commit secret keys to git, even in example files, even in .env.example – search the hash history of any key you’ve accidentally pushed and rotate it immediately.
Load them from environment variables:
$secret = getenv('STRIPE_SECRET_KEY');
if (false === $secret || '' === $secret) {
throw new RuntimeException('STRIPE_SECRET_KEY is not set in the environment');
}
\Stripe\Stripe::setApiKey($secret);
The explicit check saves you from Stripe’s “Invalid API Key” error when the variable is missing – that message doesn’t point at the actual cause. A common gotcha is having the key in .env but not actually exporting it to the PHP process (for example, your framework loads .env only for its own config, not for getenv()).
For a project with more than one Stripe account (common in multi-tenant setups), use a per-request client instead of the global static:
$stripe = new \Stripe\StripeClient([
'api_key' => getenv('STRIPE_SECRET_KEY'),
// Match whatever your Stripe Dashboard shows as the default version.
'stripe_version' => '2025-03-31.basil',
]);
Pinning the API version explicitly prevents surprises when Stripe rolls out changes. Your account’s default pinned version is visible under Developers > API version in the Dashboard.
Restricted keys for production
The secret key has full account access: creating charges, issuing refunds, exporting customer data, modifying webhooks. For a payment flow that only needs to create PaymentIntents, use a restricted key scoped to exactly those permissions. Developers > API keys > Create restricted key, then grant only PaymentIntents: write. If the key leaks, the blast radius is a fraction of what a full secret key would do.
Creating your first PaymentIntent
The PaymentIntent is Stripe’s primary payment primitive. Instead of a single “charge the card” call, you create an intent that describes what you want to collect, then confirm it. That two-step shape is what makes regulatory requirements like SCA and 3D Secure work without your code having to understand them.
The minimum viable create call:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'order_id' => (string) $orderId,
],
]);
echo $intent->client_secret;
Stripe echoes metadata back on every object and webhook event it sends, which makes it the cleanest bridge between your own domain IDs and the intents you’ve created. Values have to be strings (or will be coerced to strings), so cast anything numeric explicitly.
The amount parameter is in the smallest currency unit, so 2500 means $25.00, not $2,500. For USD, EUR and GBP that’s cents. For zero-decimal currencies like JPY, the value is the whole number – there’s a full list in the Stripe docs, but in practice JPY is the one most PHP projects hit.
Currency codes go lowercase: 'usd', not 'USD'. Stripe accepts uppercase in some places and rejects it in others, and the error message when it rejects doesn’t always mention case.
The client_secret returned on the intent is sensitive, but not at the level of the secret key. It’s a per-intent token that lets the browser complete that one payment and nothing else. Pass it to the browser through your normal rendering or a JSON endpoint.
Idempotency keys
Network blips happen. If your server times out waiting for Stripe’s response, you don’t know whether the PaymentIntent was created or not. Retrying blindly risks creating two intents. The idempotency key solves this: Stripe remembers the key for 24 hours and returns the original result on retry.
$intent = $stripe->paymentIntents->create(
[
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
],
[
'idempotency_key' => 'order_' . $orderId . '_attempt',
]
);
Use something that ties back to your domain logic – an order ID, a cart hash, anything stable across retries but unique per logical request. Don’t use uniqid() or rand(); those defeat the point by generating a new key every call.
The client side: Payment Element
The current recommended client-side approach is the Payment Element, which renders all enabled payment methods in one iframe – card, Apple Pay, Google Pay, Link, regional methods like iDEAL or BLIK depending on the customer’s location. You include Stripe.js once and mount the element into a <div>:
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="payment-form">
<div id="payment-element"></div>
<button type="submit" id="submit">Pay</button>
<div id="error-message"></div>
</form>
<script>
const stripe = Stripe(<?= json_encode($publishableKey) ?>);
const clientSecret = <?= json_encode($clientSecret) ?>;
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
document.getElementById('payment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) {
document.getElementById('error-message').textContent = error.message;
}
});
</script>
</body>
</html>
The return_url is where Stripe sends the customer after 3D Secure authentication (if the card requires it) or after completing any other redirect-based payment method. Your code at that URL needs to retrieve the PaymentIntent and check its status – more on that below.
Use json_encode() rather than htmlspecialchars() when emitting values into JavaScript. HTML escaping doesn’t cover the characters that actually matter in a JS string context – single/double quotes, backslashes, line terminators – whereas json_encode produces a valid JS literal for any string input.
Confirming the payment on the server
After the browser confirms the payment, Stripe redirects to your return_url with two query parameters: payment_intent and payment_intent_client_secret. The browser has already seen the success, but you don’t trust the browser – you verify server-side before marking the order as paid:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intentId = $_GET['payment_intent'] ?? null;
if (!$intentId) {
http_response_code(400);
exit('Missing payment intent');
}
$intent = $stripe->paymentIntents->retrieve($intentId);
// The client_secret from the query must match the one bound to the intent.
// Protects against someone swapping in another account's payment_intent ID.
if ($intent->client_secret !== ($_GET['payment_intent_client_secret'] ?? '')) {
http_response_code(400);
exit('Client secret mismatch');
}
switch ($intent->status) {
case 'succeeded':
markOrderAsPaid($intent->metadata['order_id'], $intent);
header('Location: /order/thanks');
break;
case 'processing':
// Payment methods like SEPA or bank debits settle asynchronously.
// Tell the customer we'll confirm by email once settlement completes.
markOrderAsPending($intent->metadata['order_id']);
header('Location: /order/pending');
break;
case 'requires_payment_method':
// Previous attempt failed. Send them back to the form.
header('Location: /checkout?error=1');
break;
default:
error_log("Unexpected intent status: {$intent->status}");
header('Location: /order/error');
}
The redirect flow is fine for simple cases, but it’s not authoritative. A customer who closes the tab after Stripe redirects but before your handler runs leaves you with a paid intent and no order update. A webhook listening for payment_intent.succeeded closes that gap – it updates the database regardless of whether the customer returned to your site.
Handling 3D Secure and SCA
PSD2 in Europe and the equivalent rules in the UK require strong customer authentication for most card payments. In practice that means some cards will ask the customer to verify through their bank’s app or an SMS code before the payment completes. PaymentIntent handles it automatically – the Payment Element triggers the challenge, the customer completes it, Stripe redirects back.
When the authentication step misfires, the cause is usually a missing return_url. Server-side confirmation with confirm: true on the create call needs the return URL baked in, otherwise Stripe has nowhere to send the customer after the 3DS challenge. Second-most-common cause: a test card that simply doesn’t trigger 3DS. 4242 4242 4242 4242 skips authentication entirely; to exercise the flow use 4000 0025 0000 3155, which forces 3DS on every charge. If both of those check out and you still don’t see the challenge, open the page in an incognito window – ad blockers and privacy extensions occasionally interfere with the cross-origin redirect to the bank’s verification page.
The full manual confirmation flow, for cases where automatic confirmation doesn’t fit (custom checkout, mobile app backends):
try {
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
'confirm' => true,
'return_url' => 'https://example.com/return',
]);
if ('requires_action' === $intent->status) {
// Client needs to handle 3DS. Return the client_secret
// so stripe.handleNextAction() can run in the browser.
return ['requires_action' => true, 'client_secret' => $intent->client_secret];
}
if ('succeeded' === $intent->status) {
return ['success' => true];
}
return ['error' => 'Unexpected status: ' . $intent->status];
} catch (\Stripe\Exception\CardException $e) {
return ['error' => $e->getMessage()];
}
CardException extends ApiErrorException, so if you add broader catches later, keep the card-specific one first. Otherwise declines end up classified as generic API errors.
Testing with test cards
Stripe ships a library of test cards covering success, decline, and authentication scenarios. The essential handful:
| Number | Behaviour |
|---|---|
4242 4242 4242 4242 | Generic success, no 3DS |
4000 0025 0000 3155 | Requires 3DS authentication |
4000 0000 0000 0002 | Generic decline |
4000 0000 0000 9995 | Insufficient funds decline |
4000 0000 0000 0069 | Expired card |
4000 0000 0000 0127 | Incorrect CVC |
CVC can be any 3 digits, expiry any future date. Postal code, for cards that require it, any 5 digits.
A common mistake: mixing test-mode objects with live-mode keys. If you created a PaymentIntent with sk_test_... and try to retrieve it with sk_live_..., Stripe returns “No such payment_intent”. The intent exists, you’re just looking at it through the wrong window. The error message doesn’t say “wrong mode” – see the errors article for the signs of a mode mismatch.
Common pitfalls
Publishable key on the server. Symptom: Invalid API Key provided: pk_live_... on any SDK call. The publishable key goes in the browser, the secret key on the server. A surprisingly common mix-up when copy-pasting between files.
Secret key in frontend code. Worse symptom: the key works, and now it’s in your users’ browsers. Rotate immediately.
Forgetting the Composer autoloader. Class 'Stripe\StripeClient' not found. You included the SDK but didn’t load the autoloader in the entry point that’s actually running.
Stringly-typed amounts. Some PHP code passes amounts as strings because they came from a form: 'amount' => '25.00'. Stripe expects integer minor units. Convert explicitly: (int) round($price * 100).
Assuming currency conversion. Stripe does not convert between currencies. If your customer pays in EUR and your Stripe account is configured for USD, the payment fails with invalid_currency. Settlement currency and transaction currency are separate concerns handled at the account level.
Next steps
A single PaymentIntent integration is the foundation. For recurring revenue you’ll want the Subscriptions API, which creates PaymentIntents on each billing cycle on top of Customer and Price objects. If hosting your own payment form doesn’t fit the project, Checkout Sessions move the entire flow to Stripe’s servers. Production also calls for a webhook receiver – customer networks drop the redirect-based confirmation often enough that the intent status is only authoritative when you fetch it directly, and Stripe webhook signature verification and handler patterns get a guide of their own. And when things misbehave, the catalogue of exceptions and their meanings lives in common Stripe PHP errors.
One small habit that pays back many times over: plug the SDK logger into your non-production environment. \Stripe\Stripe::setLogger($psr3Logger) accepts any PSR-3 logger and writes out full request/response bodies with keys redacted. When a call does something unexpected, the logger is the shortest path to the cause.
Spotted something inaccurate on this page?
Report an error