Introduction
Payment processing is critical infrastructure that must be implemented correctly—errors mean lost revenue or compliance violations. Stripe provides robust APIs for accepting payments, managing subscriptions, and handling complex marketplace scenarios with Stripe Connect. This guide covers practical Stripe integration patterns for common payment scenarios.
Initial Setup
Account Configuration
- Create a Stripe account at stripe.com
- Configure your business profile in Dashboard → Settings → Profile
- Set supported currencies in Dashboard → Settings → Payouts
- Enable payment methods in Dashboard → Settings → Payment Methods
- Generate API keys in Dashboard → Developers → API Keys
PHP Integration
composer require stripe/stripe-php
// config/services.php or .env
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
// Initialize Stripe
\Stripe\Stripe::setApiKey(env('STRIPE_SECRET'));
JavaScript Integration
npm install @stripe/stripe-js
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe('pk_test_xxx');
Checkout Sessions
Server-Side (PHP)
// Create checkout session
public function createCheckoutSession(Request $request)
{
$session = \Stripe\Checkout\Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'product_data' => [
'name' => $request->product_name,
'description' => $request->description,
'images' => [$request->image_url],
],
'unit_amount' => $request->price * 100, // Amount in cents
],
'quantity' => $request->quantity,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
'customer_email' => auth()->user()->email,
'metadata' => [
'user_id' => auth()->id(),
'order_id' => $request->order_id,
],
]);
return response()->json(['id' => $session->id]);
}
Client-Side Redirect
async function handleCheckout() {
const response = await fetch('/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: 123, quantity: 1 }),
});
const { id } = await response.json();
const stripe = await stripePromise;
const { error } = await stripe.redirectToCheckout({ sessionId: id });
if (error) {
console.error(error);
}
}
Payment Intents (Custom UI)
Create Payment Intent
public function createPaymentIntent(Request $request)
{
$intent = \Stripe\PaymentIntent::create([
'amount' => $request->amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
'metadata' => [
'order_id' => $request->order_id,
],
]);
return response()->json([
'clientSecret' => $intent->client_secret,
]);
}
Client-Side Payment Form
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
function PaymentForm({ amount, orderId }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState(null);
const [processing, setProcessing] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setProcessing(true);
// Get client secret from server
const response = await fetch('/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, order_id: orderId }),
});
const { clientSecret } = await response.json();
// Confirm payment
const { error, paymentIntent } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: {
card: elements.getElement(CardElement),
billing_details: {
name: 'Customer Name',
},
},
}
);
setProcessing(false);
if (error) {
setError(error.message);
} else if (paymentIntent.status === 'succeeded') {
// Payment successful
window.location.href = '/success';
}
}
return (
<form onSubmit={handleSubmit}>
<CardElement />
{error && <div className="error">{error}</div>}
<button disabled={!stripe || processing}>
{processing ? 'Processing...' : `Pay $${amount}`}
</button>
</form>
);
}
Webhooks
Set Up Webhook Endpoint
// routes/web.php (exclude CSRF for webhooks)
Route::post('/webhook/stripe', [WebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Handle Webhook Events
// app/Http/Controllers/WebhookController.php
public function handle(Request $request)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
return response('Webhook Error', 400);
}
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
$this->handleSuccessfulPayment($session);
break;
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
$this->handlePaymentFailed($paymentIntent);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
$subscription = $event->data->object;
$this->handleSubscriptionChange($subscription);
break;
default:
Log::info('Unhandled webhook event: ' . $event->type);
}
return response('OK', 200);
}
protected function handleSuccessfulPayment($session)
{
$orderId = $session->metadata->order_id;
$order = Order::find($orderId);
$order->update([
'status' => 'paid',
'stripe_session_id' => $session->id,
'paid_at' => now(),
]);
// Send a confirmation email
Mail::to($order->user)->send(new OrderConfirmation($order));
}
Subscriptions
Create Subscription
public function subscribe(Request $request)
{
$user = auth()->user();
// Create or get customer
if (!$user->stripe_customer_id) {
$customer = \Stripe\Customer::create([
'email' => $user->email,
'name' => $user->name,
]);
$user->update(['stripe_customer_id' => $customer->id]);
}
// Create subscription
$subscription = \Stripe\Subscription::create([
'customer' => $user->stripe_customer_id,
'items' => [
['price' => $request->price_id], // price_xxx from the Stripe Dashboard
],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
]);
return response()->json([
'subscriptionId' => $subscription->id,
'clientSecret' => $subscription->latest_invoice->payment_intent->client_secret,
]);
}
Cancel Subscription
public function cancel(Request $request)
{
$subscription = \Stripe\Subscription::retrieve($request->subscription_id);
// Cancel at period end (recommended)
$subscription->cancel_at_period_end = true;
$subscription->save();
// Or cancel immediately
// $subscription->cancel();
return response()->json(['status' => 'cancelled']);
}
Stripe Connect (Marketplaces)
Onboard Connected Accounts
public function createConnectAccount(Request $request)
{
$account = \Stripe\Account::create([
'type' => 'express',
'country' => $request->country,
'email' => $request->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
]);
// Create an account link for onboarding
$accountLink = \Stripe\AccountLink::create([
'account' => $account->id,
'refresh_url' => route('connect.refresh'),
'return_url' => route('connect.return'),
'type' => 'account_onboarding',
]);
// Save account ID
$user = auth()->user();
$user->update(['stripe_connect_id' => $account->id]);
return redirect($accountLink->url);
}
Process Marketplace Payment
public function processMarketplacePayment(Request $request)
{
$seller = User::find($request->seller_id);
$platformFee = $request->amount * 0.10; // 10% platform fee
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $request->amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
'transfer_data' => [
'destination' => $seller->stripe_connect_id,
],
'application_fee_amount' => $platformFee * 100,
]);
return response()->json([
'clientSecret' => $paymentIntent->client_secret,
]);
}
Testing
Test Cards
| Card Number | Scenario |
|---|
| 4242424242424242 | Successful payment |
| 4000000000000077 | Automatic approval |
| 4000000000009995 | Insufficient funds |
| 4000000000000069 | Expired card |
| 4000002500003155 | Requires authentication |
Local Webhook Testing
# Install Stripe CLI
# Download from https://stripe.com/docs/stripe-cli
# Log in
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:8000/webhook/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
Security Best Practices
- Never expose secret keys in client-side code
- Verify webhooks using signing secrets
- Use HTTPS for all Stripe communication
- Store minimal card data—let Stripe handle PCI compliance
- Validate amounts server-side before creating charges
- Use idempotency keys for mutation requests
- Log all payment events for debugging and auditing
Idempotency: The Critical Pattern
Network retries are common. Without idempotency, retries can double-charge customers:
// Always include an idempotency key for payment writes
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
], [
'idempotency_key' => 'order_' . $orderId . '_' . $attemptNumber,
]);
// Store the key with the payment record
$order->update([
'idempotency_key' => $idempotencyKey,
'stripe_payment_intent_id' => $paymentIntent->id,
]);
This applies to refunds and payouts as well. If a refund request is retried, it must not create multiple refunds.
Reconciliation Process
Even with webhooks, data can drift. Build reconciliation that compares your records against Stripe:
// Daily reconciliation job
class ReconcilePayments implements ShouldQueue
{
public function handle()
{
$yesterday = now()->subDay();
// Get charges from Stripe
$stripeCharges = \Stripe\Charge::all([
'created' => [
'gte' => $yesterday->startOfDay()->timestamp,
'lte' => $yesterday->endOfDay()->timestamp,
],
'limit' => 100,
]);
foreach ($stripeCharges->autoPagingIterator() as $charge) {
$localPayment = Payment::where('stripe_charge_id', $charge->id)->first();
if (!$localPayment) {
Log::warning('Missing local payment record', ['charge_id' => $charge->id]);
// Create missing record or alert
} elseif ($localPayment->amount != $charge->amount) {
Log::warning('Amount mismatch', [
'charge_id' => $charge->id,
'local' => $localPayment->amount,
'stripe' => $charge->amount,
]);
}
}
}
}
Check:
- Total charges vs. total orders
- Refund totals vs. refund records
- Payouts vs. platform balances
Refunds and Disputes
Model refunds and disputes as explicit states:
// Refund model states
const REFUND_STATES = [
'initiated',
'submitted',
'succeeded',
'failed',
];
public function processRefund(Order $order, string $reason, float $amount = null)
{
$refund = Refund::create([
'order_id' => $order->id,
'amount' => $amount ?? $order->amount,
'reason' => $reason,
'initiated_by' => auth()->id(),
'status' => 'initiated',
]);
try {
$stripeRefund = \Stripe\Refund::create([
'charge' => $order->stripe_charge_id,
'amount' => ($amount ?? $order->amount) * 100,
], [
'idempotency_key' => 'refund_' . $refund->id,
]);
$refund->update([
'status' => 'submitted',
'stripe_refund_id' => $stripeRefund->id,
]);
} catch (\Stripe\Exception\ApiErrorException $e) {
$refund->update(['status' => 'failed', 'error' => $e->getMessage()]);
throw $e;
}
}
Monitor Key Payment Metrics
Track operational visibility metrics:
// Payment metrics for monitoring dashboards
class PaymentMetrics
{
public function getMetrics(): array
{
return [
'success_rate' => $this->calculateSuccessRate(),
'failure_rate' => $this->calculateFailureRate(),
'webhook_processing_delay' => $this->getWebhookDelay(),
'refund_volume' => $this->getRefundVolume(),
'dispute_volume' => $this->getDisputeVolume(),
'payout_failures' => $this->getPayoutFailures(),
];
}
private function calculateSuccessRate(): float
{
$total = Payment::whereDate('created_at', today())->count();
$succeeded = Payment::whereDate('created_at', today())
->where('status', 'succeeded')->count();
return $total > 0 ? round(($succeeded / $total) * 100, 2) : 0;
}
}
A sudden drop in payment success rates should page the team immediately.
Common Pitfalls
- Treating a client response as payment success without webhook confirmation
- Missing idempotency in payment creation
- Logging sensitive data in plain text
- Ignoring provider outages until customers report problems
- Leaving payout schedules ambiguous
Avoid these by designing payment flows as distributed systems, not simple request-response calls.
The Senior Payments Mindset
Senior engineers treat payments as a risk-heavy system that demands discipline. They design for safety, auditability, and recovery. When payments are reliable, customers trust the platform and revenue stays predictable.
Conclusion
Stripe provides comprehensive payment infrastructure, but proper implementation is crucial for security and reliability. Use Checkout Sessions for simple flows, Payment Intents for custom UIs, and webhooks for reliable payment confirmation. Always test thoroughly with test cards before going live, and implement proper error handling and logging. Remember: payments are financial events—treat them with the respect they deserve.