How Stripe Integration Works in PHP Without a Framework

Stripe handles the complexity of online payment processing so you do not have to store card numbers, manage PCI compliance, or deal with merchant accounts directly. When you integrate Stripe in raw PHP, you control the entire flow: creating a payment intent on the server, collecting card details through Stripe's client-side library, confirming the payment, and handling the result.

This article walks through the full integration pattern used in production PHP applications. It covers the PaymentIntent workflow, real webhook handling with signature verification, error states, idempotency, and the code patterns you need to ship something that actually works. You can also review a broader comparison of payment processing for booking systems if your project involves appointment scheduling or recurring bookings.

Why the PaymentIntent Workflow Is the Right Approach

Stripe deprecated the older Checkout session approach for many use cases and moved to the PaymentIntent model as the standard integration path. A PaymentIntent tracks a single payment from creation through confirmation to completion. It holds metadata about the amount, currency, and payment method types, and it returns a client secret that your frontend uses to finalise the payment.

The flow follows three distinct steps:

  • Server: Create a PaymentIntent via the Stripe API, receive a client secret.
  • Client: Use Stripe.js to collect card details and confirm the payment using the client secret.
  • Server: Handle the webhook confirmation when Stripe notifies your application that payment succeeded.

This separation means sensitive card data never touches your server. Stripe.js handles collection on the client side and passes a secure token to Stripe directly. Your PHP application only ever deals with the PaymentIntent ID and status, never with raw card numbers.

Setting Up the Stripe PHP Library

Install the official Stripe PHP library via Composer:

composer require stripe/stripe-php

Set your API keys as environment variables. Never hardcode keys in source files. In a typical PHP setup using a .env file and a library like vlucas/phpdotenv:

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

Load the secret key at runtime when you need to make API calls:

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

Use the test keys during development. Switch to live keys only when you deploy to production. Stripe's test mode is completely separate from live mode, so using test keys in development causes no issues with production data.

Creating a PaymentIntent on the Server

When a user initiates a payment, your server creates a PaymentIntent before rendering the checkout page. The PaymentIntent creation call sets the amount in the smallest currency unit (cents for USD, pence for GBP), 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;
    
    // Pass $clientSecret to your frontend
    
} catch (\Stripe\Exception\CardException $e) {
    // Card was declined
    $error = $e->getError();
    error_log("Card decline: " . $error->code);
} catch (\Exception $e) {
    error_log("Stripe error: " . $e->getMessage());
}

The amount 4999 represents £49.99. Always use the smallest currency unit. If you pass 49.99 directly, Stripe treats it as 49.99 GBP, which is £0.4999 — a fraction of a penny — and Stripe will reject it.

Store the PaymentIntent ID in your database at this point so you can match webhook events back to your internal records later.

Client-Side: Collecting Card Details with Stripe.js

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 create an Elements instance to mount a card input field:

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 form where you want the card input to appear. Stripe renders a unified card input that handles card number, expiry, and CVC in one field.

Confirming the Payment

When the user submits your form, call stripe.confirmCardPayment with the client secret you received from your server and the payment method data 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: '[email protected]',
            },
        },
    });
    
    if (result.error) {
        // Show error to the customer
        document.getElementById('card-errors').textContent = result.error.message;
    } else {
        // Payment succeeded — server will confirm via webhook
        window.location.href = '/order-confirmation?payment_intent=' + result.paymentIntent.id;
    }
});

The confirmCardPayment call communicates directly with Stripe from the browser. Your server never sees the raw card details. If the card is declined, Stripe returns an error object immediately and you display it to the user.

Handling Webhooks on the Server

When a payment is confirmed, Stripe sends an HTTP POST to a webhook endpoint you specify in the Stripe Dashboard under Webhooks. Your endpoint receives a JSON payload with the event type and associated object data.

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

Setting Up Webhook Signature Verification

Register your webhook endpoint in the Stripe Dashboard. Stripe provides a webhook signing secret for each endpoint in the format whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. Store this in your environment variables alongside your API keys.

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

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

The CLI outputs the webhook signing secret it uses. Copy it to your .env file for local 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');
}

// Handle the event
switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object;
        $orderId = $paymentIntent->metadata->order_id;
        
        // Update order status in your database
        $db->query(
            "UPDATE orders SET status = 'paid' WHERE id = ?",
            [$orderId]
        );
        
        error_log("Payment succeeded for order: " . $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]
        );
        
        error_log("Payment failed for order: " . $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 the signature does not match. This is the critical security step — never skip it and never process webhook events without signature verification. Without it, an attacker could POST fake events to your endpoint and manipulate your order status.

Always return a 200 response code to Stripe after processing an event. If your script crashes or returns an error code, Stripe retries the webhook delivery over 72 hours with exponential backoff. However, repeatedly failing to acknowledge events can put your endpoint at risk of being disabled by Stripe.

For a more detailed guide on webhook security patterns including duplicate event handling, you may find it useful to review how to receive webhooks in PHP with proper HMAC-SHA256 verification.

Error Handling Patterns

Stripe API errors fall into two categories you must handle differently: 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 your integration will encounter.

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} (decline code: {$error->decline_code})");
}

Never expose raw Stripe error codes or messages directly to the user. Decline codes like generic_decline or do_not_honor are meaningless to customers and can be confusing or alarming.

API Errors and Network Failures

try {
    $paymentIntent = \Stripe\PaymentIntent::create([
        'amount' => 4999,
        'currency' => 'gbp',
    ]);
} catch (\Stripe\Exception\AuthenticationException $e) {
    error_log("Stripe auth failed: " . $e->getMessage());
    // Your API key is wrong or has been revoked
} catch (\Stripe\Exception\ApiConnectionException $e) {
    error_log("Stripe connection error: " . $e->getMessage());
    // Network issue — Stripe may be having problems
} catch (\Stripe\Exception\ApiErrorException $e) {
    error_log("Stripe API error: " . $e->getMessage());
    // General API error — check Stripe status page
} catch (\Exception $e) {
    error_log("Unexpected error: " . $e->getMessage());
}

Log all errors with context. When debugging payment failures, you need enough information in your logs to trace what happened without storing sensitive card data. The PaymentIntent ID is the key piece — if you log that alongside the error message, you can look up the full event in the Stripe Dashboard.

Idempotency and Safe Retries

The Stripe PHP library handles retries automatically when network errors occur, but only if you pass an idempotency key on write operations that could be retried. Without an idempotency key, 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 internal order ID and the attempt number. Stripe stores the result of the first request with that key and returns the same result if you call again with the same key within 24 hours.

Saving Payment Methods for Future Use

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.

// Create a customer in Stripe
$customer = \Stripe\Customer::create([
    'email' => '[email protected]',
]);

// Attach the payment method from the confirmed PaymentIntent
$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 tells Stripe this is a merchant-initiated charge for a saved card where the customer is not present. Stripe handles the required authentication rules for merchant-initiated transactions under SCA.

Refunding Payments

Process refunds through the Stripe API when an order is cancelled or a customer requests a return. You can refund full or partial amounts.

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

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

Store the refund ID from the response in your database. After a refund is issued, update your order record accordingly and notify the customer with the expected timeline for the 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 in your development environment.

  • 4242 4242 4242 4242 — passes, creates a successful payment
  • 4000 0000 0000 0002 — declines with generic decline
  • 4000 0000 0000 3220 — 3D Secure authentication required
  • 4000 0000 0000 9995 — insufficient funds decline

Use any future expiry date, any three-digit CVC, and any five-digit postcode. Test the exact card number 4000 0000 0000 3220 to verify your integration handles 3D Secure authentication correctly, as this is mandatory for SCA compliance in the UK and Europe.

Enable test mode in the Stripe Dashboard and use your test API keys during the entire development and QA process. Test mode and live mode are completely separate environments. Charges made with test keys do not affect live data and Stripe does not charge your live account for test transactions.

What to Do When Payments Stop Working

If a payment fails unexpectedly, the first step is to check the PaymentIntent status in the Stripe Dashboard. Every PaymentIntent has a status field: requires_payment_method, requires_confirmation, requires_action, processing, succeeded, or canceled. The status tells you exactly where the flow stopped.

Check your server logs for the Stripe error message and code. Common culprits include:

  • Currency mismatch: You passed an amount in the wrong format for the currency.
  • Invalid payment method type: You limited PaymentIntent to card but the customer used a different method.
  • Expired API key: Your secret key has been rotated and needs updating in your environment variables.
  • Missing webhook endpoint registration: You set up your endpoint in code but never registered it in the Stripe Dashboard, so events never arrive.

The Stripe Dashboard also has a section for webhook delivery logs. If your server is not receiving events, check the webhook log to see if Stripe is attempting delivery and what response your server returns.

Understanding why payments fail and how to recover from them is part of building a reliable system. If you are building booking systems or appointment scheduling tools that rely on payment processing, it is worth reviewing your overall approach to disaster recovery testing to ensure your payment records and order status are properly backed up.

Security Considerations

When accepting payments on a website, security extends beyond webhook signature verification. Your integration should follow security best practices at every layer.

Use HTTPS on every page that collects payment information. Stripe requires this, and it aligns with PCI DSS requirements. Keep your API keys secure and rotate them if they are ever exposed. Never log full card details or payment method information.

Consider implementing rate limiting on your payment endpoints to prevent automated attacks. If you store customer data alongside payment records, ensure your database security and access controls are appropriate for the data you hold.

For a broader overview of the security risks that affect web applications, reviewing the OWASP Top 10 for business web applications can help you understand the landscape beyond payment processing.

Wrapping Up

The Stripe PHP integration pattern is straightforward once you understand the separation between server-side PaymentIntent creation, client-side card collection via Stripe.js, and server-side webhook handling for confirmation. Build it correctly from the start: verify webhook signatures, use idempotency keys, handle card decline errors with user-friendly messages, and always 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.