Stripe with Laravel and Python: best practices explained
Building payment systems that scale across different technology stacks demands consistency in how you handle Stripe integration. Whether your team runs Laravel on the backend or splits responsibilities between Django and FastAPI microservices, the core principles remain the same—but the implementation details matter enormously. This article walks through the battle-tested patterns we use in production, focusing on subscription management, webhook security, and asynchronous event handling across both ecosystems.
Why consistency matters across stacks
We often see teams repeating mistakes when they adopt Stripe in a new framework. The patterns you'd use in Laravel Cashier feel different from stripe-python, so engineers assume they need completely different approaches. In reality, the underlying Stripe API is identical. The framework just provides convenient abstractions—or sometimes, it hides critical details you need to know.
By understanding how each stack handles subscriptions, webhooks, and events, you protect yourself from subtle bugs that only surface under load or after a few months of production data.
Laravel Cashier for subscriptions
Cashier abstracts away much of the Stripe complexity, which is both a blessing and a risk. When it works, it's elegant. When it doesn't, you need to know what's happening under the hood.
Let's say you're building a SaaS platform with multiple subscription tiers. Your User model should look like this:
// app/Models/User.php
use Laravel\Cashier\Billable;
class User extends Model
{
use Billable;
protected $fillable = ['name', 'email', 'stripe_id'];
}Creating a subscription is straightforward:
$user->newSubscription('default', 'price_1234567890')
->create($paymentMethodId);
// Or with trial
$user->newSubscription('default', 'price_1234567890')
->trialDays(14)
->create($paymentMethodId);But here's where teams get into trouble: they assume Cashier handles all edge cases. It doesn't. You need to manage:
- Subscription state transitions. Cashier tracks status, but you should add your own business logic layer. Don't rely solely on
$user->subscribed(). - Proration handling. When a user upgrades mid-cycle, Stripe prorates by default. You need to decide if that's right for your product.
- Grace periods. After a payment fails, should the subscription stay active? Cashier has
gracePeriod(), but you control the logic.
A real-world scenario: a customer upgrades from Pro to Enterprise mid-billing cycle. Cashier will prorate and issue a credit. But what if your pricing model doesn't allow downgrades? You need custom middleware:
// app/Services/SubscriptionService.php
public function upgrade(User $user, string $newPriceId): void
{
$currentPrice = $user->subscription('default')->stripe_price;
// Business logic: validate upgrade path
if (!$this->canUpgrade($currentPrice, $newPriceId)) {
throw new SubscriptionException('Downgrade not allowed mid-cycle');
}
// Stripe API call, not Cashier shortcut
$user->subscription('default')->swap(
$newPriceId,
['proration_behavior' => 'create_prorations']
);
// Log for analytics
event(new SubscriptionUpgraded($user, $newPriceId));
}This approach gives you control while still leveraging Cashier's convenience methods.
stripe-python for Django and FastAPI
Python developers have more flexibility and less hand-holding. The stripe-python library is a thin wrapper around the REST API, which means you own the subscription logic entirely. This is powerful but requires discipline.
For Django, we typically wrap Stripe calls in a service layer:
// payments/services.py
import stripe
class StripeService:
def __init__(self):
stripe.api_key = settings.STRIPE_SECRET_KEY
def create_subscription(self, customer_id: str, price_id: str,
payment_method_id: str) -> stripe.Subscription:
return stripe.Subscription.create(
customer=customer_id,
items=[{'price': price_id}],
default_payment_method=payment_method_id,
off_session=True,
)
def upgrade_subscription(self, subscription_id: str, new_price_id: str,
proration_behavior: str = 'create_prorations') -> stripe.Subscription:
sub = stripe.Subscription.retrieve(subscription_id)
return sub.modify(
items=[{
'id': sub['items']['data'][0]['id'],
'price': new_price_id,
}],
proration_behavior=proration_behavior,
)For FastAPI, the pattern is identical, but you might use dependency injection:
// routers/subscriptions.py
from fastapi import APIRouter, Depends
from payments.services import StripeService
router = APIRouter()
async def get_stripe_service() -> StripeService:
return StripeService()
@router.post('/subscribe')
async def subscribe(
user_id: str,
price_id: str,
payment_method_id: str,
stripe_svc: StripeService = Depends(get_stripe_service),
):
customer = get_or_create_stripe_customer(user_id)
subscription = stripe_svc.create_subscription(
customer['id'],
price_id,
payment_method_id,
)
return {'subscription_id': subscription['id']}The advantage here is testability. You can mock StripeService in your tests without hitting the Stripe API.
Verify webhooks with the raw body
This is where most teams fail. Stripe sends webhooks as signed JSON payloads. You must verify the signature before processing. But the signature verification requires the raw body bytes, not the parsed JSON.
In Laravel, Cashier handles this automatically via the HandleWebhookCall middleware. But understand what's happening:
// Laravel's webhook handler (simplified)
$payload = json_decode($request->getContent(), true);
$sig = $request->header('Stripe-Signature');
$event = \Stripe\Webhook::constructEvent(
$request->getContent(), // Raw body
$sig,
config('cashier.webhook.secret')
);The critical detail: $request->getContent() returns the raw bytes. If you parse JSON first and then re-encode it, the signature verification will fail due to whitespace or key ordering differences.
In Python (Django), you need to handle this manually:
// payments/views.py (Django)
import stripe
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@csrf_exempt
@require_http_methods(['POST'])
def stripe_webhook(request):
payload = request.body # Raw bytes
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
settings.STRIPE_WEBHOOK_SECRET,
)
except ValueError:
return JsonResponse({'error': 'Invalid payload'}, status=400)
except stripe.error.SignatureVerificationError:
return JsonResponse({'error': 'Invalid signature'}, status=400)
# Process event
if event['type'] == 'customer.subscription.updated':
handle_subscription_updated(event['data']['object'])
return JsonResponse({'status': 'success'})For FastAPI, the pattern is nearly identical:
// routers/webhooks.py (FastAPI)
from fastapi import APIRouter, Request
router = APIRouter()
@router.post('/stripe/webhook')
async def stripe_webhook(request: Request):
payload = await request.body() # Async body
sig_header = request.headers.get('stripe-signature')
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.error.SignatureVerificationError):
return {'error': 'Webhook verification failed'}, 400
# Dispatch to queue
await queue_webhook_event(event)
return {'status': 'received'}Queue event processing
Never process Stripe webhooks synchronously. Stripe expects a 2xx response within 5 seconds. If your database is slow or your logic is complex, you'll timeout and Stripe will retry. This leads to duplicate processing, race conditions, and angry customers.
Use a job queue. In Laravel, that's easy with Queues:
// app/Jobs/ProcessStripeWebhook.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Stripe\Event;
class ProcessStripeWebhook implements ShouldQueue
{
use Queueable;
public function __construct(private Event $event) {}
public function handle()
{
match($this->event->type) {
'customer.subscription.updated' => $this->handleSubscriptionUpdated(),
'invoice.payment_succeeded' => $this->handlePaymentSucceeded(),
'invoice.payment_failed' => $this->handlePaymentFailed(),
default => null,
};
}
private function handleSubscriptionUpdated()
{
$data = $this->event->data->object;
$user = User::whereStripeId($data->customer)->firstOrFail();
// Your business logic
$user->update(['stripe_status' => $data->status]);
}
}In your webhook controller, dispatch the job:
// app/Http/Controllers/WebhookController.php
public function handleStripe(Request $request)
{
$event = \Stripe\Webhook::constructEvent(...);
ProcessStripeWebhook::dispatch($event);
return response()->json(['received' => true]);
}For Python, use Celery with Django or a background task library with FastAPI:
// payments/tasks.py (Celery + Django)
from celery import shared_task
@shared_task
def process_stripe_webhook(event_data):
event_type = event_data['type']
if event_type == 'customer.subscription.updated':
handle_subscription_updated(event_data['data']['object'])
elif event_type == 'invoice.payment_succeeded':
handle_payment_succeeded(event_data['data']['object'])Then in your webhook view:
from payments.tasks import process_stripe_webhook
@csrf_exempt
def stripe_webhook(request):
event = stripe.Webhook.construct_event(...)
# Async dispatch
process_stripe_webhook.delay(event)
return JsonResponse({'status': 'received'})This architecture ensures your webhook endpoint responds instantly and your business logic runs reliably in the background.
Common mistakes
- Processing webhooks synchronously. Always queue them. Stripe will timeout and retry if you don't respond in 5 seconds.
- Verifying signatures on parsed JSON. Use the raw request body. Parsing and re-encoding changes the bytes, breaking signature verification.
- Trusting Cashier's defaults blindly. Understand what it's doing. Custom logic often lives outside the framework's convenience methods.
- Not handling webhook duplicates. Stripe retries, so your webhook handler must be idempotent. Use an
idempotency_keyor check if you've already processed the event. - Ignoring payment failures. Don't assume subscriptions stay active. Stripe will mark them as past_due or canceled. Monitor webhook events.
Key takeaways
- Cashier and stripe-python enforce the same core patterns: verify signatures on raw bodies, queue event processing, and own your subscription business logic.
- Webhook verification fails silently if you use parsed JSON instead of raw bytes. Always pass the raw request body to
Webhook.construct_event(). - Queue all webhook processing. Your endpoint should respond in under a second; the actual work runs asynchronously in the background.
- Understand proration, grace periods, and subscription state transitions. These are business decisions, not framework magic.
- Make your webhook handlers idempotent. Stripe retries, so you must handle duplicate events gracefully.
- Test webhook flows locally with the Stripe CLI. It simulates events and lets you verify signature verification before production.






Comments
Loading comments…