← Back to blog

Stripe billing in Symfony without losing your mind

Webhooks, subscription states, grace periods, failed payments — Stripe billing is deceptively complex. Here is a straightforward Symfony implementation that handles all of it correctly.

Integrating Stripe always looks simple at first. Create a customer, attach a payment method, start a subscription. A few API calls and you are done.

Then the edge cases arrive. What happens when a payment fails? What if a webhook is delivered twice? What if the user updates their plan mid-cycle? What if they cancel and come back?

Stripe’s API is excellent. Its documentation is thorough. But stitching everything together into a production-ready billing system in Symfony is the kind of work that takes longer than expected every single time. This article documents the decisions that matter.

The data model

Before touching Stripe, get your local data model right. You will need to store enough information to avoid round-trips to the Stripe API on every request.

#[ORM\Entity]
class Subscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\OneToOne(targetEntity: Organization::class, inversedBy: 'subscription')]
    private Organization $organization;

    #[ORM\Column(length: 255)]
    private string $stripeCustomerId;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $stripeSubscriptionId = null;

    #[ORM\Column(length: 50)]
    private string $status = 'inactive'; // active, trialing, past_due, canceled, inactive

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $planId = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $currentPeriodEnd = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $trialEnd = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $gracePeriodEnd = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $canceledAt = null;
}

The gracePeriodEnd field is something Stripe does not manage for you. When a subscription moves to past_due or canceled, you probably do not want to cut access immediately. You give users a few extra days. That window is the grace period, and you track it locally.

Creating the Stripe customer

Create the Stripe customer when an organization is created, not when they start a trial or purchase. This way, you always have a customerId to work with, and you can attach payment methods or apply credits at any point.

class StripeCustomerService
{
    public function __construct(
        private \Stripe\StripeClient $stripe,
        private EntityManagerInterface $em,
    ) {}

    public function createForOrganization(Organization $organization): void
    {
        $customer = $this->stripe->customers->create([
            'name'  => $organization->getName(),
            'email' => $organization->getOwner()->getEmail(),
            'metadata' => [
                'organization_id' => $organization->getId(),
            ],
        ]);

        $subscription = new Subscription();
        $subscription->setOrganization($organization);
        $subscription->setStripeCustomerId($customer->id);

        $this->em->persist($subscription);
        $this->em->flush();
    }
}

Always store the organization_id in Stripe’s metadata. It makes webhook processing significantly easier.

The checkout flow

For most SaaS products, Stripe Checkout is the right choice over a custom payment form. It handles 3D Secure, SCA compliance, card input validation, and localization. Building this yourself is months of work.

class CheckoutController extends AbstractController
{
    public function __construct(
        private \Stripe\StripeClient $stripe,
        private SubscriptionRepository $subscriptions,
    ) {}

    #[Route('/billing/checkout/{plan}', name: 'billing_checkout')]
    public function checkout(string $plan, TenantContext $context): Response
    {
        $organization = $context->require();
        $subscription = $this->subscriptions->findByOrganization($organization);

        $session = $this->stripe->checkout->sessions->create([
            'customer'    => $subscription->getStripeCustomerId(),
            'mode'        => 'subscription',
            'line_items'  => [[
                'price'    => $this->resolvePriceId($plan),
                'quantity' => 1,
            ]],
            'success_url' => $this->generateUrl('billing_success', [], UrlGeneratorInterface::ABSOLUTE_URL),
            'cancel_url'  => $this->generateUrl('billing_plans', [], UrlGeneratorInterface::ABSOLUTE_URL),
            'metadata'    => [
                'organization_id' => $organization->getId(),
            ],
        ]);

        return $this->redirect($session->url);
    }

    private function resolvePriceId(string $plan): string
    {
        return match ($plan) {
            'starter'  => $_ENV['STRIPE_PRICE_STARTER'],
            'pro'      => $_ENV['STRIPE_PRICE_PRO'],
            'business' => $_ENV['STRIPE_PRICE_BUSINESS'],
            default    => throw new \InvalidArgumentException("Unknown plan: $plan"),
        };
    }
}

Do not redirect to a success page and immediately update the subscription. The checkout session completing does not mean the payment succeeded. Wait for the webhook.

Webhooks — the part that matters most

Webhooks are where billing integrations break. Here is what you need to handle correctly.

Verify the signature

Every webhook must be verified before processing. Stripe signs each request with a secret, and you must check it.

#[Route('/webhooks/stripe', name: 'stripe_webhook', methods: ['POST'])]
public function webhook(Request $request): Response
{
    $payload   = $request->getContent();
    $sigHeader = $request->headers->get('Stripe-Signature');
    $secret    = $_ENV['STRIPE_WEBHOOK_SECRET'];

    try {
        $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
    } catch (\Stripe\Exception\SignatureVerificationException $e) {
        return new Response('Invalid signature', 400);
    }

    $this->handler->handle($event);

    return new Response('ok', 200);
}

Return 200 immediately after signature verification even if processing fails. If you return a non-2xx, Stripe will retry — which can be useful, but only if your handler is idempotent.

Handle these events

The minimum set of events you need to handle for a subscription product:

checkout.session.completed        → Subscription started
invoice.payment_succeeded         → Payment succeeded, extend access
invoice.payment_failed            → Payment failed, start grace period
customer.subscription.updated     → Plan changed, cancel scheduled
customer.subscription.deleted     → Subscription ended

Process events idempotently

Stripe delivers webhooks at least once. The same event may arrive twice, especially after retries. Your handler must be safe to call multiple times with the same event.

The simplest approach: store processed event IDs.

#[ORM\Entity]
class ProcessedWebhookEvent
{
    #[ORM\Id]
    #[ORM\Column(length: 255)]
    private string $stripeEventId;

    #[ORM\Column]
    private \DateTimeImmutable $processedAt;
}

Check before processing:

public function handle(\Stripe\Event $event): void
{
    if ($this->isAlreadyProcessed($event->id)) {
        return;
    }

    $this->process($event);
    $this->markProcessed($event->id);
}

The event handler

class StripeWebhookHandler
{
    public function process(\Stripe\Event $event): void
    {
        match ($event->type) {
            'checkout.session.completed'    => $this->handleCheckoutCompleted($event->data->object),
            'invoice.payment_succeeded'     => $this->handlePaymentSucceeded($event->data->object),
            'invoice.payment_failed'        => $this->handlePaymentFailed($event->data->object),
            'customer.subscription.updated' => $this->handleSubscriptionUpdated($event->data->object),
            'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->data->object),
            default => null,
        };
    }

    private function handleCheckoutCompleted(\Stripe\Checkout\Session $session): void
    {
        $subscription = $this->findByCustomerId($session->customer);

        $stripeSubscription = $this->stripe->subscriptions->retrieve($session->subscription);

        $subscription->setStripeSubscriptionId($stripeSubscription->id);
        $subscription->setStatus('active');
        $subscription->setPlanId($stripeSubscription->items->data[0]->price->id);
        $subscription->setCurrentPeriodEnd(
            new \DateTimeImmutable('@' . $stripeSubscription->current_period_end)
        );
        $subscription->setGracePeriodEnd(null);

        $this->em->flush();
    }

    private function handlePaymentFailed(\Stripe\Invoice $invoice): void
    {
        $subscription = $this->findByCustomerId($invoice->customer);

        $subscription->setStatus('past_due');
        $subscription->setGracePeriodEnd(
            new \DateTimeImmutable('+7 days')
        );

        $this->em->flush();

        // Send dunning email
        $this->mailer->sendPaymentFailedEmail($subscription->getOrganization());
    }

    private function handleSubscriptionDeleted(\Stripe\Subscription $stripeSubscription): void
    {
        $subscription = $this->findByCustomerId($stripeSubscription->customer);

        $subscription->setStatus('canceled');
        $subscription->setCanceledAt(new \DateTimeImmutable());

        // Keep grace period if already set from payment failure
        if ($subscription->getGracePeriodEnd() === null) {
            $subscription->setGracePeriodEnd(new \DateTimeImmutable('+3 days'));
        }

        $this->em->flush();
    }
}

The grace period

A grace period is the window between a billing failure and actually locking the account. It reduces churn from involuntary cancellation and gives users time to update their payment method.

The rule for access checks:

class BillingGuard
{
    public function hasAccess(Organization $organization): bool
    {
        $subscription = $organization->getSubscription();

        if ($subscription === null) {
            return false;
        }

        // Active subscription
        if ($subscription->getStatus() === 'active') {
            return true;
        }

        // Trialing
        if ($subscription->getStatus() === 'trialing') {
            $trialEnd = $subscription->getTrialEnd();
            return $trialEnd === null || $trialEnd > new \DateTimeImmutable();
        }

        // Within grace period (past_due, canceled)
        $gracePeriodEnd = $subscription->getGracePeriodEnd();
        if ($gracePeriodEnd !== null && $gracePeriodEnd > new \DateTimeImmutable()) {
            return true;
        }

        return false;
    }
}

Use this guard in a Symfony subscriber that fires on each request and redirects to a billing wall if access is denied.

Plan upgrades and downgrades

When a user switches plans mid-cycle, Stripe handles the proration automatically. You just need to update the subscription:

public function changePlan(Organization $organization, string $newPriceId): void
{
    $subscription = $this->subscriptions->findByOrganization($organization);

    $stripeSubscription = $this->stripe->subscriptions->retrieve(
        $subscription->getStripeSubscriptionId()
    );

    $this->stripe->subscriptions->update($stripeSubscription->id, [
        'items' => [[
            'id'    => $stripeSubscription->items->data[0]->id,
            'price' => $newPriceId,
        ]],
        'proration_behavior' => 'create_prorations',
    ]);

    // The webhook `customer.subscription.updated` will fire and update your local record
}

Do not update your local subscription record here. Let the webhook do it. This keeps your state consistent even if the API call succeeds but your application crashes before saving.

Testing webhooks locally

Use the Stripe CLI to forward events to your local server:

stripe listen --forward-to http://localhost:8080/webhooks/stripe

The CLI prints the webhook secret to use as STRIPE_WEBHOOK_SECRET in your local environment. Test specific events with:

stripe trigger invoice.payment_failed

Write integration tests that construct real \Stripe\Event objects and pass them to your handler directly, bypassing the HTTP layer. Do not mock Stripe responses in these tests — use Stripe’s test mode and fixtures instead.

Common mistakes

Updating state on the success redirect, not the webhook. Users close the browser tab. Networks time out. The redirect is not reliable. The webhook is.

Not verifying signatures. Anyone can POST to your webhook endpoint. Always verify.

Storing card data. Never. Stripe handles this. You store the customer ID and nothing else.

Assuming synchronous consistency. When you call the Stripe API, the corresponding webhook may arrive milliseconds or minutes later. Design your system to handle this lag.

No grace period. Cutting access the instant a payment fails leads to churn from card expiry, bank failures, and temporary holds. Give users a window to fix it.

The complete picture

What a production-grade Symfony billing integration looks like:

  • StripeCustomerService — creates and manages Stripe customers
  • CheckoutController — initiates Stripe Checkout sessions
  • StripeWebhookController — receives and verifies webhook events
  • StripeWebhookHandler — dispatches events to specific handlers
  • ProcessedWebhookEvent — prevents duplicate processing
  • BillingGuard — single source of truth for access decisions
  • Subscription entity — local mirror of Stripe’s subscription state

This is the same structure implemented in the Horme Symfony SaaS Boilerplate. The full source includes the Stripe customer portal integration, dunning email templates, billing plan page, and test coverage for the webhook handler.

Billing is not glamorous. But getting it right means users can pay you, and that tends to matter.