DÉMO
dev only

Billing

Stripe integration for subscriptions, webhooks and billing portal.

Overview

The Stripe integration handles customer creation, hosted checkout, webhooks and the billing portal. No card data is stored server-side.

Feature Detail
StripeServicesrc/Service/Billing/StripeService.php
PlanRegistrysrc/Service/Billing/PlanRegistry.php
SubscriptionManagersrc/Service/Billing/SubscriptionManager.php
StripeWebhookControllersrc/Controller/Webhook/StripeWebhookController.php
BillingControllersrc/Controller/Settings/BillingController.php
Subscription entitysrc/Entity/Billing/Subscription.php
WebhookEvent entitysrc/Entity/Billing/WebhookEvent.php
Plans configurationconfig/plans.yaml
CreateStripeCustomerSubscribersrc/EventSubscriber/Organization/CreateStripeCustomerSubscriber.php
PlanFeatureVotersrc/Security/Voter/PlanFeatureVoter.php
ExpireGracePeriodHandlersrc/MessageHandler/Billing/ExpireGracePeriodHandler.php

Setup

  1. 1

    Set the Stripe environment variables in your .env.local:

    STRIPE_SECRET_KEY=sk_test_… STRIPE_WEBHOOK_SECRET=whsec_…
  2. 2

    In development, forward Stripe webhooks to the app using the Stripe CLI:

    stripe listen --forward-to localhost:8080/webhook/stripe

    The Stripe CLI automatically generates a webhook secret and displays it in the terminal. Copy it into STRIPE_WEBHOOK_SECRET.

  3. 3

    Enable the Customer Portal in the Stripe dashboard: Billing → Customer Portal → Enable.

  4. 4

    Run Doctrine migrations to create the subscriptions and webhook_events tables:

    php bin/console doctrine:migrations:migrate

Subscription plans

Plans are defined in config/plans.yaml. Each plan references Stripe Price IDs (one for monthly, one for yearly).

plans: starter: name: "Starter" stripe_price_id_monthly: "price_xxx" stripe_price_id_yearly: "price_yyy" amount_monthly: 2900 # centimes amount_yearly: 27840 currency: EUR trial_days: 14 features: max_members: 5 # 0 = illimité custom_domain: false priority_support: false

Price IDs (price_xxx) can be found in the Stripe dashboard under Products → your product → Pricing.

Checkout flow

When a user clicks "Subscribe", here is what happens:

  1. A Stripe Checkout Session is created server-side with the corresponding Price ID.
  2. The user is redirected to the Stripe-hosted payment page.
  3. After payment, Stripe redirects to /settings/billing/success.
  4. Stripe sends a customer.subscription.updated webhook which creates the Subscription in the database.

The Stripe Customer is automatically created when the organization is created, via:

src/EventSubscriber/Organization/CreateStripeCustomerSubscriber.php

Webhooks

The POST /webhook/stripe endpoint receives Stripe events. The signature is verified with STRIPE_WEBHOOK_SECRET before any processing.

Handled events

Event Action
checkout.session.completedEntry point — the subscription arrives via customer.subscription.updated
customer.subscription.updatedCreates or updates the Subscription in DB, updates currentSubscription
customer.subscription.deletedMarks the subscription as canceled, clears currentSubscription
invoice.payment_succeededSets status back to active after a successful payment
invoice.payment_failedSets status to past_due (grace period)

Idempotency

Each received event is recorded in the webhook_events table. If the Stripe ID is already present, the event is skipped — protects against Stripe duplicates.

src/Entity/Billing/WebhookEvent.php

In production

Register the following URL in the Stripe dashboard (Developers → Webhooks → Add endpoint):

https://yourdomain.com/webhook/stripe

Customer Portal

The Customer Portal is a Stripe-hosted interface that allows users to manage their subscription, change plans, update their card and download invoices.

Activation (one-time setup)

  1. Go to the Stripe dashboard → Billing → Customer Portal.
  2. Enable the portal and configure the allowed actions (cancellation, plan change, card update).
  3. Save. The portal is immediately available.

The "Manage my subscription" button appears in /settings/billing only if the organization has a stripeCustomerId. The redirect is generated in:

src/Controller/Settings/BillingController.php

Architecture

Billing data is owned by the organization, not the user. Each organization has a Stripe Customer and a current active subscription pointer.

Field / Entity Role
Organization.stripeCustomerId Stripe Customer ID linked to the organization. Created at registration.
Organization.currentSubscription Pointer to the current active subscription (nullable ManyToOne).
Subscription Full subscription history for the organization (status, period, trial).
WebhookEvent Log of received Stripe events (idempotency).
SubscriptionStatus Enum: trialing, active, past_due, pending_cancel, canceled, expired.

Plan-based access control (PlanFeatureVoter)

PlanFeatureVoter is a Symfony Voter that grants or denies access to a feature based on the organization's active subscription plan. It works exactly like any other Symfony voter: in controllers via denyAccessUnlessGranted / isGranted, and in Twig via is_granted.

Available attributes

Attribute Access condition
PLAN_FEATURE_CUSTOM_DOMAIN The plan has custom_domain: true in config/plans.yaml.
PLAN_FEATURE_PRIORITY_SUPPORT The plan has priority_support: true in config/plans.yaml.
PLAN_FEATURE_ADD_MEMBER The plan is unlimited (max_members: 0) or the current active member count is below max_members.

Access is denied if the organization has no active subscription, or if the active Price ID does not match any plan in the registry.

Usage in a controller

// Dans un controller $this->denyAccessUnlessGranted(PlanFeatureVoter::FEATURE_ADD_MEMBER, $organization); // Vérification souple if (!$this->isGranted(PlanFeatureVoter::FEATURE_CUSTOM_DOMAIN, $organization)) { $this->addFlash('warning', 'Cette fonctionnalité nécessite un plan supérieur.'); }

Usage in Twig

{% if is_granted('PLAN_FEATURE_CUSTOM_DOMAIN', organization) %} {# afficher le formulaire custom domain #} {% else %} {# afficher un badge "upgrade" #} {% endif %}

Adding a new plan feature

To expose a new feature (e.g. api_access):

  1. Add the field in the features section of config/plans.yaml for each plan.
  2. Add a getter in src/Billing/Plan.php (e.g. hasApiAccess()).
  3. Add a constant and a match case in PlanFeatureVoter.
src/Security/Voter/PlanFeatureVoter.php

Grace period

When a payment fails, the subscription moves to past_due. The organization retains access for the full grace period duration. When it expires, a Symfony scheduler automatically moves the status to expired — access is then revoked.

Full flow

  1. 1

    Stripe sends invoice.payment_failed → the webhook sets the status to past_due and records pastDueAt = now() on the Subscription.

  2. 2

    During the grace period (7 days by default): isActive() returns true, the organization retains all access.

  3. 3

    Every night at 2 AM, the scheduler fires ExpireGracePeriodMessage. The handler finds all past_due subscriptions where pastDueAt ≤ now() - gracePeriodDays.

    0 2 * * * ExpireGracePeriodMessage
  4. 4

    These subscriptions move to expired. isActive() returns false. The organization's currentSubscription pointer is cleared.

Configuring the duration

The grace period duration is defined in services.yaml. Adjust the value to fit your business model.

# config/services.yaml parameters: app.billing.grace_period_days: 7

Files involved

Feature Detail
Message (trigger)src/Message/Billing/ExpireGracePeriodMessage.php
Handler (expiration logic)src/MessageHandler/Billing/ExpireGracePeriodHandler.php
Cron registrationsrc/Scheduler/Schedule.php
findExpiredGracePeriod() querysrc/Repository/Billing/SubscriptionRepository.php
Loading…
Loading the web debug toolbar…
Attempt #