Testing payments locally can save you from major headaches once your app is live. Many teams rush to production, only to discover their payment system isn't handling certain cases correctly.

What could go wrong? Here are some real examples we've seen:

  • Users getting charged twice for the same product
  • Failed payments that should have worked
  • Subscription renewals quietly failing without any alerts
  • European cards getting declined due to missing 3D Secure support

In this guide, you'll learn how to test common payment flows in your Laravel app before launching. We'll cover both the happy path (when everything works) and various edge cases that could trip up your system.

By the end, you'll know how to check if your payment system can handle:

  • Basic product purchases
  • 3D Secure verification
  • Credit card failures
  • Subscription renewals
  • Payment method updates
  • Insufficient funds

We'll use real test card numbers and walk through each scenario step by step.

Setting Up Your Local Environment

Before we can test payments, we need to set up a few tools. Don't worry - it's simpler than it sounds.

1. Get Your Stripe Test Keys

First, grab your test API keys from Stripe:

  1. Log into your Stripe Dashboard
  2. Go to Developers → API keys
  3. Copy your "Secret key" that starts with sk_test_
  4. Copy your "Publishable key" that starts with pk_test_

Add these to your .env file:

STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...

.env

2. Install Stripe CLI

The Stripe CLI lets you test webhooks locally. Here's how to set it up:

  1. Install the CLI: brew install stripe/stripe-cli/stripe
    For Windows or Linux, check Stripe's install guide.
  2. Log in to connect your account: stripe login
  3. Start listening for webhooks: stripe listen --forward-to localhost:8000/stripe/webhook
    Replace the URL above with your own Laravel app's local URL

The CLI will show a webhook signing secret. Add it to your .env:

STRIPE_WEBHOOK_SECRET=whsec_...

.env

3. Set Up Laravel Cashier

If you haven't already, install Laravel Cashier:

composer require laravel/cashier
php artisan vendor:publish --provider="Laravel\Cashier\CashierServiceProvider"
php artisan migrate

That's it! Your local environment is ready for testing. In the next section, we'll start with testing a basic payment flow.

Testing Basic Payment Flow (Happy Path)

Let's start with the simplest case - a customer buys your product and everything works fine.

Test Card Details

For successful payments, use this test card:

Card:   4242 4242 4242 4242
Expiry: Any future date
CVC:    Any 3 digits

If you're wondering where this number comes from, Stripe offers numerous "test cards" to use in the test environment. Each test card has a specific behaviour attached as well, which can be useful when testing different outcomes. We'll come back to it a bit later!

Testing Steps

  1. First, make sure your Stripe listener is running:
stripe listen --forward-to localhost:8000/stripe/webhook
  1. Now assuming you have a specific route set up where you charge your user - go and make that charge right now using the test card mentioned above. If you don't have any code written for placing charges yet, don't worry! Here's an example:
use Stripe\StripeClient;

public function purchase(Request $request)
{
    $stripe = new StripeClient(config('cashier.secret'));
    
    try {
        $paymentIntent = $stripe->paymentIntents->create([
            'amount' => 2000, // $20.00
            'currency' => 'usd',
            'payment_method' => $request->payment_method_id,
            'confirm' => true,
            'automatic_payment_methods' => [
                'enabled' => true,
                'allow_redirects' => 'never'
            ],
        ]);

        return response()->json([
            'status' => $paymentIntent->status,
            'client_secret' => $paymentIntent->client_secret,
        ]);
    } catch (\Stripe\Exception\CardException $e) {
        return response()->json([
            'error' => $e->getMessage()
        ], 422);
    }
}

app/Http/Controllers/PaymentsController.php

  1. Check your Stripe dashboard (Events tab) - you should see:
  • payment_intent.created
  • payment_intent.succeeded
  • charge.succeeded

What to Watch For

Even in this simple flow, check these things in the Stripe dashboard:

  • Was the correct amount charged?
  • Did your webhook handler run (if you have one set up)?
  • Did your database record the payment (if you're recording payments locally)?
  • Did the customer get a receipt (if you're sending the receipts)?

Common Issues

Here are problems we often see with the happy path:

  • Forgetting to handle decimals (sending 20 instead of 2000 for $20.00). Remember, Stripe always works in cents (or the lowest possible denomination in any other currency)
  • Missing webhook handling
  • Not catching network errors. These can be especially difficult to debug after-the-fact, leaving developers confused as to why payment states are out of sync. This is one reason why you should also rely on webhooks from Stripe
  • Not showing clear success messages to users. Let users know what had happened, so they can retry or otherwise fix whatever issue is at hand (e.g. incorrect card number or card being blocked)

Great, let's move to testing the most common reasons for payment failure.

Testing 3D Secure (3DS) Payments

If you have European customers, you need to handle 3D Secure. It's an extra security step where customers verify payments through their bank.

Test Card Details

Use this test card to always trigger 3D Secure:

Card:   4000 0027 6000 3184
Expiry: Any future date
CVC:    Any 3 digits

There are other 3D Secure related test cards which can help test similar behaviours.

Testing Steps

  1. Update your payment code to handle 3DS:
use Stripe\StripeClient;

public function purchase(Request $request)
{
    $stripe = new StripeClient(config('cashier.secret'));
    
    try {
        $paymentIntent = $stripe->paymentIntents->create([
            'amount' => 2000,
            'currency' => 'usd',
            'payment_method' => $request->payment_method_id,
            'confirm' => true,
            'automatic_payment_methods' => [
                'enabled' => true,
                'allow_redirects' => 'always' // Changed from 'never'
            ],
        ]);

        // Handle the 3DS flow
        if ($paymentIntent->status === 'requires_action') {
            return response()->json([
                'requires_action' => true,
                'client_secret' => $paymentIntent->client_secret
            ]);
        }

        return response()->json(['status' => $paymentIntent->status]);
    } catch (\Stripe\Exception\CardException $e) {
        return response()->json([
            'error' => $e->getMessage()
        ], 422);
    }
}

app/Http/Controllers/PaymentsController.php

  1. Add this JavaScript to handle the 3DS popup:
stripe.confirmCardPayment(clientSecret).then(function(result) {
    if (result.error) {
        // Handle error
        console.error(result.error);
    } else {
        // Payment successful
        console.log('Payment completed');
    }
});

What to Test

  1. Failed Authentication:
    • Use card 4000 0027 6000 3184
    • Click "Fail" in the 3DS popup
    • Check that your error handling works
  2. Successful Authentication:
    • Use the same card
    • Click "Complete" in the 3DS popup
    • Verify the payment completes

Common Issues

Watch out for these problems:

  • Not showing a loading state during 3DS
  • Missing error handling if user closes the 3DS popup
  • Not testing on mobile devices
  • Timeout handling issues

Testing Card Declines

Sometimes cards get declined. Let's test these cases so your app handles them gracefully.

Test Cards for Declines

Different decline types need different test cards:

Generic Decline:    4000 0000 0000 0002
Insufficient Funds: 4000 0000 0000 9995
Expired Card:       4000 0000 0000 0069

Testing Card Addition

First, let's test what happens when a user tries to add a declined card:

public function addCard(Request $request)
{
    try {
        $user = $request->user();
        
        // Store the card and make it default
        $user->createOrGetStripeCustomer();
        $user->addPaymentMethod($request->payment_method_id);
        $user->updateDefaultPaymentMethod($request->payment_method_id);
        
        return response()->json(['success' => true]);
    } catch (\Stripe\Exception\CardException $e) {
        return response()->json([
            'error' => $e->getMessage(),
            'decline_code' => $e->getDeclineCode(),
        ], 422);
    }
}

app/Http/Controllers/PaymentMethodsController.php

Testing Subscription Creation

Next, test starting a subscription with a bad card. Make sure to replace the price_xxxxxx price identifier with your own recurring plan price ID.

public function startSubscription(Request $request)
{
    try {
        $user = $request->user();
        
        $user->newSubscription('default', 'price_xxxxxx')
            ->create($request->payment_method_id);
            
        return response()->json(['success' => true]);
    } catch (\Stripe\Exception\CardException $e) {
        return response()->json([
            'error' => $e->getMessage(),
            'decline_code' => $e->getDeclineCode(),
        ], 422);
    }
}

app/Http/Controllers/SubscriptionsController.php

What to Check

For each decline case:

  • Does your error message make sense to users?
  • Is the decline reason shown clearly?
  • Can users easily try another card?
  • Does your app stay in a good state after the decline?

Common Issues

Here's what often goes wrong:

  • Showing technical error messages to users. Rewrite and localise error messages if needed, you don't want users quitting on their journey to giving you money!
  • Not clearing loading states after declines
  • Creating database records before charging the card. Another reason why depending on webhooks from Stripe is a good idea
  • Not handling network errors

Testing Subscription Renewals

Failed renewals can silently kill your revenue. Let's test these cases carefully.

Stripe has this new feature called "Test Clocks" which allow you to simulate subscriptions moving through time and renewing, so you can test what exactly happens during renewals. Perfect tool for our use case here! After all, you don't want to deploy to production and find out 30 days later that your app cannot handle renewals.

Let's test subscription renewals using Stripe's UI and your local environment.

Setup Steps

  1. Ensure your local app is listening to Stripe webhooks:
stripe listen --forward-to localhost:8000/stripe/webhook
  1. Go to Stripe Dashboard, switch to Test Mode, and visit the Subscriptions page.

Creating a Test Subscription

Create a subscription in your app using one of these cards:

Success: 4242 4242 4242 4242
Fails After First Payment: 4000 0000 0000 0259

Running the Renewal Test

  1. Find and click on the new subscription in the Stripe's Subscriptions page
  2. Look for "Run simulation" at the top of the page
  3. Click it and pick a future date (you can go up to 2 renewal cycles ahead)
  4. Click "Advance time"

What to Check

Watch your terminal running stripe listen. You should see webhooks coming in:

For successful renewals:

  • invoice.created
  • invoice.paid
  • invoice.payment_succeeded

For failed renewals:

  • invoice.created
  • invoice.payment_failed

Check that your app:

  • Logs these events
  • Updates subscription status
  • Sends user notifications
  • Handles payment retries

Common Issues

  • Make sure your webhook listener is running before advancing time
  • Check your logs for any webhook handling errors
  • Verify your database shows the correct subscription status
  • Confirm notification emails are being sent (check your local logs)

Tip: If you need to test multiple renewals, you can run several simulations. Just remember you can only advance 2 billing cycles at a time.

Need to start fresh? Click "Finish simulation" in Stripe Dashboard to clean up the test subscription and customer.

Best Practices and Tips

Here are key things we've learned from years of working with Stripe payments.

Logging Is Your Friend

Always log these things:

// In your webhook handler
Log::info('Stripe webhook received', [
    'type' => $event['type'],
    'customer' => $event['data']['object']['customer'],
    'amount' => $event['data']['object']['amount'] ?? null,
]);

// In your error handler
Log::error('Stripe payment failed', [
    'error' => $e->getMessage(),
    'decline_code' => $e->getDeclineCode(),
    'customer_id' => $user->stripe_id,
]);

Debug Webhooks Like a Pro

  1. Keep the stripe listen output visible while testing
  2. Check both failed and successful webhook delivery in Stripe Dashboard
  3. Use Stripe's webhook test button to retry specific events
  4. Save failed webhook payloads for debugging

Database Tips

Store these fields for better debugging:

Schema::table('subscriptions', function (Blueprint $table) {
    $table->json('last_payment_error')->nullable();  // Store payment errors
    $table->timestamp('last_webhook_received_at');  // Track webhook timing
});

Testing in CI/CD

Add these to your CI pipeline:

# Example GitHub Actions workflow
steps:
  - name: Test Stripe Integration
    env:
      STRIPE_KEY: ${{ secrets.TEST_STRIPE_KEY }}
      STRIPE_SECRET: ${{ secrets.TEST_STRIPE_SECRET }}
    run: |
      php artisan test --filter=Stripe

Test critical paths in CI:

  • Payment creation
  • Webhook signature validation
  • Basic subscription flows

Skip tests that need webhook delivery in CI - test these in your local environment instead.

Testing Rules

  1. Always test with the test cards provided by Stripe, not fake numbers
  2. Test every new payment flow in local before staging/production
  3. Keep a checklist of scenarios to test before each deploy
  4. Test with different currencies if you support them

Common Production Issues to Test For

  • Network timeouts
  • Double charges
  • Webhook delays
  • Subscription downgrades/upgrades
  • Payment method changes during trial
  • Failed payments during trial to paid transition

Quick Test Checklist

Before each deploy, check:

 [ ] Basic payment works
 [ ] 3D Secure flow completes
 [ ] Subscription can be created
 [ ] Card updates work
 [ ] Webhook endpoint is correct
 [ ] Error messages are clear
 [ ] Logging is helpful
 [ ] Email notifications work

The extra time spent testing will save you from losing revenue and customers.

Conclusion

Testing payment flows might feel like extra work now, but it's far cheaper than fixing issues in production. A single failed payment can cost you more than just the dollar amount - it damages customer trust and can lead to subscription cancellations.

Let's put this in perspective:

  • A declined card that should've worked = lost revenue
  • A failed renewal without proper notification = angry customer
  • A 3D Secure error = European customers can't pay
  • A webhook that wasn't tested = silent subscription failures

Each of these issues directly impacts your bottom line. The good news? All of them are preventable with proper testing.

Keep this guide bookmarked, use the test cards we covered, and always test your payment flows locally before deploying. Your future self (and your revenue) will thank you.

Remember: In the world of SaaS, your billing system isn't just infrastructure - it's the heart of your business. Treat it that way.


Additional Resources

Want to learn more? Check out:

For local testing tools:

Remember: Payment testing is an ongoing process. Keep these resources handy and update your test scenarios as you add new payment features.


Want to skip the complexity of building and maintaining your own billing infrastructure?

Spike Billing provides a complete, self-hosted billing solution that integrates seamlessly with both Stripe and Paddle. Your customers get a fully-featured portal to manage their subscriptions, one-time purchases, and billing details, while you maintain full control over the experience.

Get started in minutes with our Laravel package, or explore our extensive customization options to match your specific business needs. Visit documentation to get started, or have a look at a live demo.