Stripe PHP Payment Integration Guide

14 min read 2,610 words
Stripe Integration in PHP: The Full Payment Flow Without a Framework featured image

Stripe PHP Payment Integration: A Production-Ready Guide

Integrating Stripe payments in PHP without a framework gives you complete control over the payment flow while keeping sensitive card data off your server. The Stripe PHP library handles communication with the Stripe API, leaving your application to manage the PaymentIntent lifecycle, webhook events, and error states. This guide walks through the complete implementation pattern used in production applications, from initial setup through webhook handling, error management, and security best practices.

If you are evaluating payment processors for a booking system or appointment scheduling tool, it is worth reviewing a comparison of payment processing options for booking systems alongside this guide.

Understanding the PaymentIntent Workflow

Stripe moved to the PaymentIntent model as the standard integration path, deprecating older approaches that relied on Checkout sessions for many use cases. A PaymentIntent tracks a single payment from creation through confirmation to completion. It holds the amount, currency, payment method types, and returns a client secret that your frontend uses to finalise the transaction.

The integration follows three distinct stages:

  • Server creates the PaymentIntent: Your PHP application calls the Stripe API to create a PaymentIntent, receives a client secret, and passes it to the frontend.
  • Client confirms the payment: Stripe.js collects card details in the browser and confirms the payment using the client secret from your server.
  • Server receives the webhook: Stripe notifies your application when the payment succeeds or fails, and you update your database accordingly.

This separation is fundamental to how Stripe PHP payment integration works. Sensitive card data never touches your server. Stripe.js collects the information client-side and passes a secure token directly to Stripe. Your PHP application only handles the PaymentIntent ID and its status, never raw card numbers.

Setting Up the Stripe PHP Library

Install the official Stripe PHP library using Composer:

composer require stripe/stripe-php

Store your API keys as environment variables. Never hardcode keys in source files. Use a .env file with a library like vlucas/phpdotenv to load them at runtime:

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

Load the secret key before making any API calls:

\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);

Use test keys during development. Stripe's test mode operates completely separately from live mode, so development with test keys has no impact on production data.

Creating a PaymentIntent on the Server

When a user initiates checkout, your server creates a PaymentIntent before rendering the payment form. The API call sets the amount in the smallest currency unit, the currency, and optionally metadata and payment method types.

\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);

try {
    $paymentIntent = \Stripe\PaymentIntent::create([
        'amount' => 4999,
        'currency' => 'gbp',
        'metadata' => ['order_id' => $orderId],
        'payment_method_types' => ['card'],
    ]);
    
    $clientSecret = $paymentIntent->client_secret;
    
} catch (\Stripe\Exception\CardException $e) {
    $error = $e->getError();
    error_log("Card decline: " . $error->code);
} catch (\Exception $e) {
    error_log("Stripe error: " . $e->getMessage());
}

The amount 4999 represents £49.99 in this example. Always use the smallest currency unit for the specified currency. Passing 49.99 directly for GBP results in Stripe treating it as £0.4999, and the API will reject the payment.

Store the PaymentIntent ID in your database alongside the order record at this stage. You need this to match webhook events back to your internal records later.

Collecting Card Details Client-Side

Include Stripe.js in your HTML page before the closing body tag:

<script src="https://js.stripe.com/v3/"></script>

Initialise Stripe with your publishable key and mount a card input element:

const stripe = Stripe('pk_test_xxxxxxxxxxxxxxxxxxxxxxxx');
const elements = stripe.elements();

const cardElement = elements.create('card', {
    style: {
        base: {
            fontSize: '16px',
            color: '#32325d',
            '::placeholder': { color: '#aab7c4' },
        },
        invalid: { color: '#fa755a' },
    },
});

cardElement.mount('#card-element');

Place the div with id card-element in your checkout form where the card input should appear. Stripe renders a unified field that handles card number, expiry date, and CVC in a single element.

Confirming the Payment

When the customer submits the form, call stripe.confirmCardPayment with the client secret from your server and the payment method from the card element:

document.getElementById('payment-form').addEventListener('submit', async function(event) {
    event.preventDefault();
    
    const result = await stripe.confirmCardPayment(clientSecret, {
        payment_method: {
            card: cardElement,
            billing_details: {
                name: document.getElementById('cardholder-name').value,
                email: 'customer@example.com',
            },
        },
    });
    
    if (result.error) {
        document.getElementById('card-errors').textContent = result.error.message;
    } else {
        window.location.href = '/order-confirmation?payment_intent=' + result.paymentIntent.id;
    }
});

The confirmCardPayment call communicates directly between the browser and Stripe. Your server never sees raw card details. If the card is declined, Stripe returns an error immediately and you display the message to the customer.

Strong Customer Authentication and 3D Secure

If you are accepting payments from customers in the UK or European Union, your integration must handle Strong Customer Authentication (SCA). This is a regulatory requirement under PSD2 that requires customers to verify their identity with at least two factors when making card payments above certain thresholds.

Stripe.js handles SCA automatically. When you call confirmCardPayment and the card requires additional authentication, Stripe presents a modal to the customer asking them to verify through their bank. Your code does not need to change. The confirmCardPayment call simply waits for the authentication to complete before returning.

To test your SCA handling during development, use the test card number 4000 0000 0000 3220. This card always triggers 3D Secure authentication. Using this card in your test environment verifies that your integration correctly handles the authentication flow before you go live.

If you build booking systems or appointment tools that take deposits or recurring payments, understanding SCA requirements is important for compliance. For a broader view of payment security requirements affecting UK businesses, reviewing PCI DSS compliance for small businesses provides useful context on the standards that apply to card processing.

Webhook Handling on the Server

When a payment is confirmed or fails, Stripe sends an HTTP POST to a webhook endpoint you configure in the Stripe Dashboard. Your endpoint receives a JSON payload containing the event type and associated data.

Webhook security requires signature verification. Stripe signs every request using a hash generated with your webhook signing secret and the raw request body. Always verify this signature before processing any event.

Registering Your Webhook Endpoint

Register your endpoint in the Stripe Dashboard under Webhooks. Stripe provides a signing secret for each endpoint in the format whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. Store this in your environment variables.

For local development, use the Stripe CLI to forward webhooks to your development server:

stripe listen --forward-to localhost:3000/webhook.php

The CLI outputs the signing secret it uses, which you copy to your .env file for testing.

PHP Webhook Handler

\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);

$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
$payload = @file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];

$event = null;

try {
    $event = \Stripe\Webhook::constructEvent(
        $payload,
        $sigHeader,
        $webhookSecret
    );
} catch (\UnexpectedValueException $e) {
    http_response_code(400);
    exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);
    exit('Invalid signature');
}

switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object;
        $orderId = $paymentIntent->metadata->order_id;
        
        $db->query(
            "UPDATE orders SET status = 'paid' WHERE id = ?",
            [$orderId]
        );
        break;
        
    case 'payment_intent.payment_failed':
        $paymentIntent = $event->data->object;
        $orderId = $paymentIntent->metadata->order_id;
        
        $db->query(
            "UPDATE orders SET status = 'payment_failed' WHERE id = ?",
            [$orderId]
        );
        break;
        
    default:
        error_log("Unhandled event type: " . $event->type);
}

http_response_code(200);

The constructEvent method verifies the signature and throws an exception if it does not match. This verification step is critical. Never process webhook events without signature verification, as an attacker could POST fake events to your endpoint and manipulate your order status.

Always return a 200 response code after processing an event. If your script crashes or returns an error code, Stripe retries delivery over 72 hours with exponential backoff. Repeated failures to acknowledge events can result in your endpoint being disabled.

For more detail on webhook security patterns including handling duplicate events, the guide to receiving webhooks in PHP covers HMAC-SHA256 verification and idempotent event processing.

Handling Errors Effectively

Stripe API errors fall into two categories that require different handling: card decline errors and API operational errors.

Card Decline Errors

When a card is declined, Stripe throws a CardException. This is the most common error in a Stripe PHP payment integration.

try {
    $paymentIntent = \Stripe\PaymentIntent::create([
        'amount' => 4999,
        'currency' => 'gbp',
    ]);
} catch (\Stripe\Exception\CardException $e) {
    $error = $e->getError();
    
    switch ($error->code) {
        case 'card_declined':
            $userMessage = 'Your card was declined. Please try a different payment method.';
            break;
        case 'expired_card':
            $userMessage = 'Your card has expired. Please use a different card.';
            break;
        case 'insufficient_funds':
            $userMessage = 'Your card has insufficient funds.';
            break;
        case 'incorrect_cvc':
            $userMessage = 'The security code was incorrect.';
            break;
        default:
            $userMessage = 'Payment failed. Please try again.';
    }
    
    error_log("Stripe CardException: {$error->code}");
}

Never expose raw Stripe error codes or decline codes directly to the user. Codes like generic_decline or do_not_honor are confusing to customers and can undermine trust.

API and Network Errors

try {
    $paymentIntent = \Stripe\PaymentIntent::create([
        'amount' => 4999,
        'currency' => 'gbp',
    ]);
} catch (\Stripe\Exception\AuthenticationException $e) {
    error_log("Stripe auth failed: " . $e->getMessage());
} catch (\Stripe\Exception\ApiConnectionException $e) {
    error_log("Stripe connection error: " . $e->getMessage());
} catch (\Stripe\Exception\ApiErrorException $e) {
    error_log("Stripe API error: " . $e->getMessage());
} catch (\Exception $e) {
    error_log("Unexpected error: " . $e->getMessage());
}

Log all errors with enough context to trace failures. The PaymentIntent ID is the key piece — log it alongside error messages so you can look up the full event in the Stripe Dashboard.

Idempotency and Safe Retries

The Stripe PHP library handles retries automatically for network errors, but only when you pass an idempotency key on write operations. Without one, a failed network request that did not reach Stripe could be retried and create a duplicate charge.

$paymentIntent = \Stripe\PaymentIntent::create(
    [
        'amount' => 4999,
        'currency' => 'gbp',
    ],
    [
        'idempotencyKey' => 'order-' . $orderId . '-attempt-1',
    ]
);

Use a stable idempotency key derived from your order ID and attempt number. Stripe stores the result of the first request and returns the same result if you call again with the same key within 24 hours.

Saving Payment Methods for Future Charges

If you need to charge the same card again without collecting details each time, attach a PaymentMethod to a Customer in Stripe and store the Stripe Customer ID in your database.

$customer = \Stripe\Customer::create([
    'email' => 'customer@example.com',
]);

$paymentIntent = \Stripe\PaymentIntent::retrieve('pi_xxxxxxxxxxxxxxxxxx');
$paymentIntent->payment_method->attach(['customer' => $customer->id]);

For subsequent charges, use the customer ID to charge without requiring the card present:

$charge = \Stripe\PaymentIntent::create([
    'amount' => 4999,
    'currency' => 'gbp',
    'customer' => 'cus_xxxxxxxxxxxxxxxxxx',
    'payment_method' => 'pm_xxxxxxxxxxxxxxxxxx',
    'off_session' => true,
]);

The off_session flag indicates a merchant-initiated charge where the customer is not present. Stripe applies the appropriate SCA rules for these transactions.

Processing Refunds

Process refunds through the Stripe API when orders are cancelled or customers request returns. You can refund full or partial amounts.

$refund = \Stripe\Refund::create([
    'payment_intent' => 'pi_xxxxxxxxxxxxxxxxxx',
]);

$refund = \Stripe\Refund::create([
    'payment_intent' => 'pi_xxxxxxxxxxxxxxxxxx',
    'amount' => 1999,
]);

Store the refund ID in your database and update the order record. Notify the customer of the expected timeline for funds to appear on their statement, typically 5 to 10 business days depending on the card issuer.

Testing Your Integration

Stripe provides test card numbers that simulate different scenarios without moving real money. Use these throughout development and QA.

  • 4242 4242 4242 4242 — creates a successful payment
  • 4000 0000 0000 0002 — declines with a generic decline
  • 4000 0000 0000 3220 — requires 3D Secure authentication
  • 4000 0000 0000 9995 — declined due to insufficient funds

Use any future expiry date, any three-digit CVC, and any five-digit postcode for test cards. Test the 3D Secure card specifically to verify your integration handles SCA correctly, as this is mandatory for card payments in the UK and European Union.

Diagnosing Payment Failures

If a payment fails unexpectedly, check the PaymentIntent status in the Stripe Dashboard first. Each PaymentIntent has a status field indicating where the flow stopped: requires_payment_method, requires_confirmation, requires_action, processing, succeeded, or canceled.

Review your server logs for the Stripe error message and code. Common causes include:

  • Currency mismatch: Amount passed in the wrong format for the currency specified.
  • Invalid payment method type: PaymentIntent limited to card but customer used a different method.
  • Expired API key: Secret key has been rotated and needs updating in your environment variables.
  • Missing webhook registration: Endpoint not registered in the Stripe Dashboard, so events never arrive.

The Stripe Dashboard webhook log shows delivery attempts and your server's response. Check this when debugging missing webhook events.

Security Best Practices

A secure Stripe PHP payment integration goes beyond webhook signature verification. Apply security best practices across your entire application.

Use HTTPS on every page that collects payment information. This is required by Stripe and aligns with PCI DSS standards. Keep API keys secure and rotate them if they are ever exposed. Never log full card details or payment method information.

Implement rate limiting on payment endpoints to prevent automated attacks. If you store customer data alongside payment records, ensure your database access controls are appropriate for the sensitivity of that data.

PCI DSS compliance applies to any business accepting card payments. Understanding the requirements helps you make informed decisions about what your integration needs to handle directly versus what Stripe manages on your behalf.

Building a Reliable Payment Integration

The Stripe PHP payment integration pattern is straightforward once you understand how the server-side PaymentIntent creation, client-side card collection via Stripe.js, and server-side webhook handling work together. Build it correctly from the start: verify webhook signatures without exception, use idempotency keys on write operations, handle card decline errors with clear user-facing messages, and log enough context to trace failures without storing sensitive card data.

Test thoroughly with Stripe's test cards before going live. Your integration is only as reliable as the error handling you built around it.

Frequently Asked Questions

Do I need SSL to use Stripe.js?
Yes. Stripe requires that any page collecting card details is served over HTTPS. This aligns with PCI DSS requirements. For local development, you can serve over HTTP, but Stripe's test environment requires the page originates from localhost or uses HTTPS. In production, always use a valid TLS certificate.
How does Strong Customer Authentication work with Stripe?
Stripe.js handles SCA automatically. When you call confirmCardPayment and the card requires additional verification, Stripe presents a modal for the customer to authenticate with their bank. Your code does not change. Use the test card 4000 0000 0000 3220 to verify your integration handles this flow correctly before going live.
Can I accept payment methods other than cards?
Yes. When creating a PaymentIntent, list the payment method types you want to accept:
What should I do if my webhook script crashes after Stripe sends an event?
Stripe retries delivery for up to 72 hours using exponential backoff. To avoid processing the same event twice, use the PaymentIntent ID as a unique check before updating your database. Only process a payment_intent.succeeded event if you have not already marked that order as paid.
How do I prevent test webhooks from affecting my development database?
Use the Stripe CLI stripe listen command with the --forward-to flag to forward events to your local server only when you are actively testing. When the CLI is not running, no test events reach your endpoint. This keeps your development environment clean during the integration process.
What data should I store alongside payment records?
Store the PaymentIntent ID, customer email, amount, currency, status, and any metadata you attached. Do not store raw card numbers or card validation codes. Store the order ID in PaymentIntent metadata when creating it, so you can match payments to orders later.