Stripe subscriptions in Symfony: the architecture that survives production
Hosted Checkout, a webhook inbox, idempotent processing through Messenger and a normalized subscription state — the billing design patterns that don't fall over, with code.
Most Stripe + Symfony tutorials end where production begins: they create a Checkout session, listen for one webhook, flip a boolean on the user. Then reality arrives — webhooks delivered twice, out of order, or during a deploy; upgrades mid-cycle; cards failing on renewal — and the boolean becomes a support queue.
This is the architecture I use for subscription billing in Symfony, distilled from building ShipAnvil. It's built around three decisions that do most of the work.
Decision 1: let Stripe host everything it offers to host
You do not want to build card forms, proration math, dunning emails for expired cards, or SCA challenges. Stripe Checkout and the Billing Customer Portal do all of it, maintained by people whose full-time job it is.
Your app keeps exactly two responsibilities: starting a checkout and reacting to webhooks.
public function checkout(Plan $plan, Organization $org): RedirectResponse
{
$session = $this->stripe->checkout->sessions->create([
'mode' => 'subscription',
'customer' => $org->getStripeCustomerId(),
'line_items' => [['price' => $plan->stripePriceId, 'quantity' => 1]],
'success_url' => $this->urlTo('billing_success'),
'cancel_url' => $this->urlTo('billing_index'),
// Find your own entity again when the webhook arrives:
'subscription_data' => [
'metadata' => ['organization_id' => (string) $org->getId()],
],
]);
return new RedirectResponse($session->url, 303);
}
Two details that matter: the 303 status (a 302 after a POST can get replayed as POST by some clients), and the metadata — webhooks are where you'll need to map Stripe's objects back to yours, so plant the reference now.
Note the customer is attached to an Organization, not a User. Even if you don't have teams today, bill the tenant: "attach billing to the person" is the schema decision people regret most when teams arrive.
Decision 2: webhooks go into an inbox, not into your domain logic
The webhook endpoint should do three things — verify, store, acknowledge — and nothing else:
#[Route('/webhooks/stripe', methods: ['POST'])]
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->headers->get('Stripe-Signature', ''),
$this->webhookSecret,
);
} catch (SignatureVerificationException) {
return new Response('invalid signature', 400);
}
// INSERT ... ON CONFLICT DO NOTHING on (provider, event_id):
$stored = $this->inbox->storeOnce('stripe', $event->id, $event->toArray());
if ($stored) {
$this->bus->dispatch(new ProcessWebhookEvent($stored->getId()));
}
return new Response('ok', 202);
}
This buys you, in one move:
- Idempotency. Stripe retries until it gets a 2xx, and retries can race
the original. A unique constraint on
(provider, event_id)makes the duplicate a no-op at the database level — not in fragile application checks. - Speed. Stripe wants a fast answer. Your handler does signature verification and one insert; the real work happens in a Messenger worker with its own retry policy.
- An audit trail. When a customer asks "why did my plan change?", the inbox table answers from history. You will use this more than you think.
One sharp edge: signature verification needs the raw request body —
$request->getContent(), never re-encoded JSON. One byte of difference
and every webhook fails.
Decision 3: one write path, and trust the object, not the event
Stripe events can arrive out of order. If you write state from each event
type independently (payment_failed → mark past_due, updated → copy
status…), orderings you never tested will eventually disagree.
Instead, every subscription-shaped event funnels into a single updater that snapshots the subscription object Stripe sent:
final class BillingUpdater
{
public function apply(SubscriptionSnapshot $snap): void
{
$sub = $this->subscriptions->findByStripeId($snap->stripeId)
?? $this->createFor($snap); // metadata.organization_id from Decision 1
$sub->update(
status: $snap->status, // normalized enum
plan: $this->plans->byPriceId($snap->priceId),
currentPeriodEnd: $snap->currentPeriodEnd,
cancelAtPeriodEnd: $snap->cancelAtPeriodEnd,
);
}
}
Every event carries the current full state of the subscription, so the latest write is always correct, whatever order events arrived in. Side effects (the "payment failed" email, the "subscription canceled" email) hang off the state transition your updater observes — not off the raw event type.
Normalize Stripe's statuses into your own enum (active, trialing,
past_due, canceled). The rest of your codebase should never see a
provider's vocabulary — which is also what makes a second provider
possible later.
Gating features without sprinkling ifs
With state normalized on the tenant, entitlement checks belong in one service and one attribute:
#[RequiresPlan('pro')]
#[Route('/reports/advanced')]
public function advancedReports(): Response { /* ... */ }
The attribute listener asks an EntitlementChecker — the single place
that knows "what can this organization do?" — and redirects to pricing
with a flash message instead of a bare 403. Plans themselves are plain
YAML config mapping a rank, features and a Stripe price id; pricing page
and gating read the same source so they can't drift apart.
Test the money paths by replaying webhooks
The billing module is the part of your app you can least afford to guess about — and the easiest to test badly. The pattern that works: record realistic webhook payloads as fixtures, sign them with your test secret, and replay full lifecycles through the real HTTP stack:
checkout completed → invoice paid → payment failed → recovered → canceled.
Then assert on your subscription entity's state after each step, on the emails dispatched, and on the duplicate-delivery case (replay the same event twice, assert exactly one state change). If that last test isn't green, you don't have idempotent billing — you have billing that hasn't been hit twice yet.
Or skip the three weeks
Everything above — plus the Lemon Squeezy twin of the Stripe provider behind one interface, the customer portal, invoices, proration flows, the emails, and 324 functional tests including those replayed lifecycles — is what ships in ShipAnvil. There's a live demo with a sandbox provider, so you can walk the whole flow without an API key.