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 |
|---|---|
| StripeService | src/Service/Billing/StripeService.php |
| PlanRegistry | src/Service/Billing/PlanRegistry.php |
| SubscriptionManager | src/Service/Billing/SubscriptionManager.php |
| StripeWebhookController | src/Controller/Webhook/StripeWebhookController.php |
| BillingController | src/Controller/Settings/BillingController.php |
| Subscription entity | src/Entity/Billing/Subscription.php |
| WebhookEvent entity | src/Entity/Billing/WebhookEvent.php |
| Plans configuration | config/plans.yaml |
| CreateStripeCustomerSubscriber | src/EventSubscriber/Organization/CreateStripeCustomerSubscriber.php |
| PlanFeatureVoter | src/Security/Voter/PlanFeatureVoter.php |
| ExpireGracePeriodHandler | src/MessageHandler/Billing/ExpireGracePeriodHandler.php |
Setup
-
1
Set the Stripe environment variables in your .env.local:
STRIPE_SECRET_KEY=sk_test_… STRIPE_WEBHOOK_SECRET=whsec_… -
2
In development, forward Stripe webhooks to the app using the Stripe CLI:
stripe listen --forward-to localhost:8080/webhook/stripeThe Stripe CLI automatically generates a webhook secret and displays it in the terminal. Copy it into STRIPE_WEBHOOK_SECRET.
-
3
Enable the Customer Portal in the Stripe dashboard: Billing → Customer Portal → Enable.
-
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:
- A Stripe Checkout Session is created server-side with the corresponding Price ID.
- The user is redirected to the Stripe-hosted payment page.
- After payment, Stripe redirects to /settings/billing/success.
- 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.completed | Entry point — the subscription arrives via customer.subscription.updated |
customer.subscription.updated | Creates or updates the Subscription in DB, updates currentSubscription |
customer.subscription.deleted | Marks the subscription as canceled, clears currentSubscription |
invoice.payment_succeeded | Sets status back to active after a successful payment |
invoice.payment_failed | Sets 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)
- Go to the Stripe dashboard → Billing → Customer Portal.
- Enable the portal and configure the allowed actions (cancellation, plan change, card update).
- 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):
- Add the field in the features section of config/plans.yaml for each plan.
- Add a getter in src/Billing/Plan.php (e.g. hasApiAccess()).
- 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
Stripe sends invoice.payment_failed → the webhook sets the status to past_due and records pastDueAt = now() on the Subscription.
-
2
During the grace period (7 days by default): isActive() returns true, the organization retains all access.
-
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
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 registration | src/Scheduler/Schedule.php |
| findExpiredGracePeriod() query | src/Repository/Billing/SubscriptionRepository.php |