DÉMO

Changelog

All notable changes to the Symfony SaaS Boilerplate are documented here.


v1.0.0 — April 2026

Added

  • Testing — PHPUnit suite (2.27) — 72 tests, 132 assertions across three suites: Unit (21), Integration (31) and E2E / Functional (20); phpunit.dist.xml with three named test suites, APP_ENV=test forced in both $_ENV and $_SERVER to override Docker's system env; config/packages/test/framework.yaml enabling framework.test, setting router.default_uri, disabling Symfony Form CSRF, and overriding all rate limiters to no_limit; config/services_test.yaml replacing StripeService with StripeServiceStub after the main service container (loaded post-config/services.yaml)

  • Testing — Factories (2.27) — 8 Zenstruck Foundry factories: UserFactory, OrganizationFactory, OrganizationMemberFactory (owner(), suspended()), InvitationFactory (expired(), accepted()), SubscriptionFactory (trialing(), active(), pastDue(), expired()), PasswordResetTokenFactory, ScheduledTaskFactory (lastRunSuccess(), lastRunFailure(), disabled()), ScheduledTaskExecutionFactory

  • Testing — AppStory (2.27)AppStory loads a realistic development dataset: two organisations (Alpha SaaS / Beta SaaS), one Owner + one Admin + two Members per org, pending and accepted invitations, trialing subscription, scheduled tasks with execution history; callable via make seed

  • Testing — Unit tests (2.27)PlanRegistryTest, OrganizationVoterTest, PlanFeatureVoterTest, TwoFactorServiceTest

  • Testing — Integration tests (2.27)RegistrationServiceTest, PasswordResetServiceTest, InvitationServiceTest, TwoFactorServiceTest

  • Testing — E2E tests (2.27)LoginFlowTest, RegistrationFlowTest, PasswordResetFlowTest, TwoFactorFlowTest

  • Testing — StripeServiceInterface (2.27) — extracted interface App\Service\Billing\StripeServiceInterface so CreateStripeCustomerSubscriber and tests can depend on the abstraction rather than the concrete class; StripeService implements the interface; StripeServiceStub implements it for the test environment

  • Testing — Make commands (2.27)make test-db (create test DB + migrate), make test (full suite), make test-unit, make test-integration, make test-functional, make lint-fix added to the Makefile

  • Docs — Testing page (2.27)/docs/features/testing: suite overview table, first-time setup, Make commands reference, Foundry factories table with state methods, AppStory, test isolation (stub + config files), key conventions (createClient-first, Foundry v2 state syntax, rate limiters, CSRF)

  • Multi-tenancy — Switch de contexte inter-organisations — route POST /account/switch-organization/{id} (CSRF, vérification membership actif, écriture _current_organization_id en session, redirect referer) ; OrganizationMemberRepository::findAllActiveMemberships() ; OrganizationExtension + OrganizationRuntime Twig pour exposer user_organizations() globalement ; sélecteur d'organisation dans la navbar desktop (dropdown avec org courante cochée + boutons de switch pour les autres) et mobile (section dédiée dans le panneau burger) ; affiché uniquement si l'utilisateur appartient à au moins une organisation

  • Multi-tenancy — RBAC OrganizationVoterOrganizationVoter avec 6 permissions : ORG_SETTINGS, ORG_INVITE, ORG_MANAGE_MEMBERS, ORG_REVOKE_INVITATION, ORG_DELETE, ORG_TRANSFER_OWNERSHIP ; matrix Owner/Admin → toutes les actions de gestion, Owner seul → suppression et transfert ; MembersController protège les actions POST (revoke, suspend, reactivate) via denyAccessUnlessGranted ; OrganizationController remplace ROLE_USER par ORG_SETTINGS sur l'organisation courante ; formulaire d'invitation et boutons d'action masqués en template via is_granted ; carte "Votre rôle" dans l'aside de la page Membres et de la page Profil — badge de rôle (Owner / Admin / Membre) et checklist des permissions (inviter, gérer, paramètres, supprimer)

  • Multi-tenancy — Invitation systemInvitationService with invite() (token bin2hex(random_bytes(32)), 7-day expiry, duplicate guard), accept() (creates OrganizationMember, dispatches MemberJoinedEvent), revoke(), suspendMember(), reactivateMember(); MemberInvitedEventSendInvitationEmailSubscriberInvitationMailer (HTML + text templates); rate limiting 10 invitations/h per organization; full registration-via-invitation flow: token stored in session, org creation skipped, email pre-filled and locked, redirect to invitation page after email verification; email mismatch guard blocks accepting an invitation as a different user

  • Multi-tenancy — Members pageGET/POST /settings/members with invite form, pending invitations list (revoke), active members list (suspend/reactivate); contextual aside with role descriptions and counters; MembersController with CSRF-protected actions

  • Multi-tenancy — Invitation acceptance — public routes GET /invitation/{token} (show details, unauthenticated-friendly), POST /invitation/{token}/accept, POST /invitation/{token}/decline; expired invitation page; PUBLIC_ACCESS in firewall

  • Multi-tenancy — Organization creation at registrationorganizationName field added to RegistrationFormType; RegistrationService::register() now accepts an organizationName parameter and calls OrganizationService::createForUser() to persist User + Organization + OrganizationMember atomically in a single flush; OrganizationCreatedEvent dispatched after creation

  • Multi-tenancy — OrganizationServicecreateForUser(string $name, User $owner) generates a unique slug via cocur/slugify (4-char random suffix on collision), persists Organization + Owner membership and dispatches OrganizationCreatedEvent; updateName() and updateSlug() for settings management (slug uniqueness enforced, throws \InvalidArgumentException if taken)

  • Multi-tenancy — Settings pageGET/POST /settings/organization with OrganizationSettingsFormType (name + slug with format validation); flash messages on success or slug conflict; resolves the active organization via TenantContext

  • Multi-tenancy — TenantContext — request-scoped service (src/Service/Organization/TenantContext.php) holding the active organization for the current request; exposes getCurrentOrganization(), setCurrentOrganization(), requireCurrentOrganization() (throws LogicException if no organization is set) and hasOrganization()

  • Multi-tenancy — TenantSubscriberkernel.request listener (priority 0, after the Symfony firewall) that resolves the active organization from the session, verifies the user is still an active non-suspended member, auto-selects the first membership when no organization is stored, and stores the selection under the _current_organization_id session key

  • Multi-tenancy — OrganizationMemberRepositoryfindActiveMembership(User, Organization) returns the membership record if the user is an active (non-suspended) member of the given organization; findFirstActiveMembership(User) returns the earliest membership for auto-selection on first login

  • Authentication — RegistrationRegistrationFormType with first name, last name, email and repeated password; server-side validation with dedicated validators translation domain; anti-enumeration (same redirect regardless of email existence)

  • Authentication — Email verification — time-limited HMAC-SHA256 signed URLs (keyed with APP_SECRET, no database token); timing-safe comparison via hash_equals(); automatic login via Security::login() after verification

  • Authentication — Rate limiting — token bucket limiter on registration (5 attempts / 15 min / IP) via Symfony RateLimiter + Redis

  • Authentication — EventsUserRegisteredEvent and UserEmailVerifiedEvent; SendVerificationEmailSubscriber decouples email dispatch from the controller

  • Authentication — Email template — light-theme table-based HTML email layout coherent with the design system; plain-text fallback; locale-aware via preferredLocale on the User entity

  • Authentication — Password UX — client-side strength gauge (Stimulus password-strength controller) with i18n labels via data-* attributes; password visibility toggle

  • Authentication — Translations — dedicated auth domain (auth.fr.yaml / auth.en.yaml) for all auth strings; validators.fr.yaml / validators.en.yaml for Symfony constraint messages

  • User entitypreferredLocale field stores locale at registration time for locale-aware transactional emails

  • Navbar — User menu — avatar with initials replaces login/register links when authenticated; dropdown with greeting, profile link and logout; separate mobile-optimised inline section in the burger panel

  • Securitytrusted_proxies with default:: env prefix (safe with empty value in dev); preferredLocale input validation against allowed locales

  • Design system — two-tier CSS token architecture (primitives → component tokens), Dark Precision theme, light/dark mode with FOUC prevention via inline script

  • Component library — buttons, badges, flash messages, modals, tabs, tables, pagination, skeleton, spinner, avatar, form controls (input, textarea, select, checkbox, radio)

  • Responsive base layout — sticky navbar with hamburger menu (< 768px), docs sidebar drawer with overlay (< 1024px), stats 2-column grid, Why comparison table stacked on mobile

  • 3-column footer — brand + dynamic Symfony version, Resources links, Project links (GitHub, Changelog, License) with copyright bar

  • Template decompositionbase.html.twig split into components/_navbar.html.twig, components/_footer.html.twig, components/_flash.html.twig

  • Scroll-timeline — reusable scroll-timeline__* component with animated fill line, active/past dot states and label visibility

  • Home page — hero section, tech stack grid, features grid, stats (2-column), comparison table (Why), roadmap

  • Documentation — 13 dedicated pages: Getting Started (installation, configuration, make commands), Architecture (structure, design system, stack), Features (auth, 2FA, organizations, billing, scheduler, i18n)

  • Design System page — interactive component cards grouped by category, modal documentation per component

  • Self-hosted fonts — Geist Sans and Geist Mono via @font-face, no external CDN dependency

  • Grain texture — SVG noise overlay for visual depth

  • Lucide icons — tree-shaken SVG icon set via AssetMapper (Menu, X, Sun, Moon, Shield, Users, CreditCard, Clock, and more)

  • Domain entities — Auth (User, PasswordResetToken), Billing (Subscription, WebhookEvent), Organizations (Organization, OrganizationMember, Invitation), Scheduler (ScheduledTask, ScheduledTaskExecution)

  • Doctrine migrations — versioned migrations for all domain entities

  • Symfony AssetMapper + Dart Sass — no Node.js required in production

  • Stimulus controllerstheme-toggle, scroll-spy, scroll-timeline, clipboard, navbar, docs-nav, modal, tabs, dropdown, char-counter, password-toggle, scroll-restore

  • Internationalization — FR / EN support, locale detection from URL, session persistence, scroll restoration on switch, dedicated boilerplate translation domain separate from user translations

  • Stack page — versions read dynamically from Composer\InstalledVersions and PHP runtime, no hardcoded values

  • Makefilemake up, make install, make migrate, make seed, make lint, make test, make doctor, make shell and more

  • Commercial license — Standard (€149) and Agency (€299) tiers with clear usage terms

  • Public pages/license and /changelog with content read from source files

  • Themed scrollbar--scrollbar-* token system (width, track, thumb, hover, radius) cascading automatically on dark/light switch, supported on Webkit and Firefox

  • Account area — Multi-page layout — shared sidebar (account/base.html.twig) with two navigation groups: "Mon compte" (profile, security, preferences) and "Organisation" (general, members/billing/danger as disabled stubs); active state resolved from _route attribute; responsive: horizontal scroll on mobile, fixed 240px column on desktop

  • Account area — Profile pageGET/POST /account/profile; ProfileFormType (firstName + lastName); ProfileService::updateProfile(); aside preview card (avatar initials, name, email) + tip

  • Account area — Security pageGET/POST /account/security with two independent sub-forms; email change sub-form with pending-email banner and cancel action; password change sub-form with current-password verification and new-password strength meter; 2FA stub (badge "Bientôt", disabled button); aside security-score checklist (email verified, password set, 2FA) + password requirements list

  • Account area — Email change flowrequestEmailChange() generates bin2hex(random_bytes(32)) raw token, SHA-256 hash stored in pendingEmailToken, 1h expiry in pendingEmailTokenExpiresAt; EmailChangeRequestedEvent dispatched → SendEmailChangeEmailSubscriberEmailChangeMailer (HTML + plain-text); confirmEmailChange() validates token with hash_equals() and swaps email; cancelEmailChange() clears pending fields; routes: POST /account/security/email, GET /account/security/email/confirm/{token}, POST /account/security/email/cancel

  • Account area — Preferences pageGET/POST /account/preferences; PreferencesFormType with preferredLocale ChoiceType (required: true, NotBlank constraint); locale persisted on User; aside card listing what the locale affects (emails, UI)

  • Account area — Organisation settings/settings/organization migrated to extend account/base.html.twig; aside card with org preview (initials avatar, name, slug), creation date, member count and slug meta

  • Account area — Aside panels.account-page CSS grid (main + sticky aside column: 340px lg / 400px xl / 440px 2xl); .info-card BEM block with __preview, __checklist, __list, __meta, __tip elements

  • Authentication — Login — email / password form with visibility toggle, CSRF token, "remember me" checkbox (30 days via kernel.secret)

  • Authentication — Login rate limitingLoginRateLimiterSubscriber: two independent counters (IP + email, 5 attempts / 15 min) via CheckPassportEvent (priority 8), LoginFailureEvent and LoginSuccessEvent

  • Authentication — Password reset — secure token flow: bin2hex(random_bytes(32)) raw token, SHA-256 hash stored in DB, expires after 1 hour, soft-deleted after use (deleted_at); PasswordResetToken entity with email as primary key (one active token per address); rate limiting on /auth/forgot-password (3 req / 1h per IP + per email); anti-enumeration (same redirect whether email exists or not); auto-login via Security::login() after reset

  • Authentication — Password reset emailPasswordResetEmailMailer + PasswordResetRequestedEvent + SendPasswordResetEmailSubscriber; HTML + plain-text templates

  • Auth docs — Login section (fields, dual rate limiting, remember me); Reset password section (full flow, token entity, rate limiting, anti-enumeration); overview table updated with all new files

  • Billing — Stripe integrationStripeService (checkout session, portal session), PlanRegistry (loads config/plans.yaml, findByPriceId()), Plan value object with feature helpers (hasCustomDomain(), hasPrioritySupport(), hasUnlimitedMembers()); BillingController (GET /settings/billing, POST /settings/billing/checkout/{planId}/{interval}, POST /settings/billing/portal, GET /settings/billing/success); billing settings page with plan cards, monthly/yearly toggle, comparison table and current subscription block; billing-interval Stimulus controller for toggle UX

  • Billing — Stripe webhooksStripeWebhookController (POST /webhook/stripe, PUBLIC_ACCESS); HMAC-SHA256 signature verification via Stripe\Webhook::constructEvent(); idempotency via WebhookEvent entity (skips already-processed Stripe event IDs); handles checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed; SubscriptionManager (syncFromStripe(), cancelFromStripe(), markPastDue())

  • Billing — Customer PortalStripeService::createPortalSession(); POST /settings/billing/portal generates a Stripe-hosted portal URL and redirects; button visible only if stripeCustomerId is set; requires one-time activation in the Stripe dashboard

  • Billing — Plan-based access control (PlanFeatureVoter) — Symfony Voter with three attributes: PLAN_FEATURE_CUSTOM_DOMAIN, PLAN_FEATURE_PRIORITY_SUPPORT, PLAN_FEATURE_ADD_MEMBER; subject is Organization; denies all features if no active subscription or if the Price ID resolves to no known plan; FEATURE_ADD_MEMBER uses OrganizationMemberRepository::countActiveMembers() to enforce max_members limit (0 = unlimited); usable in controllers via denyAccessUnlessGranted / isGranted and in Twig via is_granted()

  • Billing — Grace periodpastDueAt field added to Subscription entity, recorded when invoice.payment_failed is received; app.billing.grace_period_days: 7 parameter (configurable in services.yaml); ExpireGracePeriodMessage dispatched nightly at 2 AM via Symfony Scheduler (RecurringMessage::cron('0 2 * * *', ...)); ExpireGracePeriodHandler queries SubscriptionRepository::findExpiredGracePeriod(), moves past_due subscriptions to expired and clears currentSubscription; organizations retain access during the grace window, access is revoked automatically once it expires

  • Billing docs — complete rewrite of /docs/features/billing: overview table (10 files), setup (4 steps), plans config, checkout flow, webhooks (events table, idempotency, production URL), Customer Portal activation, architecture (entities table), PlanFeatureVoter (attributes, controller and Twig usage, extension guide), grace period (full flow, configuration, files table)

  • Admin area — Layout — standalone layout admin/base.html.twig (n'étend pas base.html.twig) avec sidebar (brand, nav groupée Overview/System/Data, footer avec email utilisateur + déconnexion), topbar avec breadcrumb, et bloc {% block admin_body %}; SCSS dédié assets/styles/components/_admin.scss (blocs BEM : .admin-layout, .admin-sidebar, .admin-nav, .admin-topbar, .admin-content, .admin-header, .admin-stat-grid, .admin-stat-card, .admin-health)

  • Admin area — DashboardGET /admin : 4 stat cards (utilisateurs, organisations, abonnements actifs, tâches planifiées) + section santé système (base de données + Redis avec latence en ms) + dernière exécution de tâche planifiée; DashboardController injecte Connection pour le check DB et lit MESSENGER_TRANSPORT_DSN pour le check Redis

  • Admin area — Authentification — page de connexion dédiée GET /admin/login (AdminLoginController) sans case "Se souvenir de moi", CSRF id admin_authenticate; redirect vers le dashboard si déjà authentifié; route de déconnexion GET /admin/logout interceptée par le firewall

  • Admin area — Sécurité — firewall Symfony admin dédié (avant main, pattern ^/admin), sans remember_me (session uniquement); role_hierarchy: ROLE_SUPER_ADMIN: [ROLE_USER]; access_control : /admin/login en PUBLIC_ACCESS, /admin nécessite ROLE_SUPER_ADMIN; TenantSubscriber ignore toutes les routes /admin; AdminAccessSubscriber logue chaque accès admin (email + IP + méthode + chemin)

  • Admin area — Commande de promotionapp:admin:promote <email> ajoute ROLE_SUPER_ADMIN au tableau de rôles de l'utilisateur et effectue un flush; détecte si le rôle est déjà présent

  • User entity — Champ roles — colonne JSON roles ajoutée à la table users (migration Version20260416140000); getRoles() retourne array_unique([...$this->roles, 'ROLE_USER']); setRoles(list<string> $roles) ajouté

  • Scheduler — Architecture DB-driven (2.18)ScheduledTaskHandlerInterface (getTaskName(): string, handle(): string); TaskHandlerRegistry auto-câblé via !tagged_iterator app.scheduled_task_handler; RunScheduledTaskMessage (readonly, taskName: string); RunScheduledTaskHandler (#[AsMessageHandler]) : crée une ScheduledTaskExecution en état Running, résout le handler via le registry, exécute, met à jour en Success/Failure avec output et horodatage; Schedule.php reécrit pour charger les tâches activées depuis la base de données et enregistrer des RecurringMessage::cron() dynamiquement; migration Version20260416150000 insère 5 tâches pré-configurées

  • Scheduler — Handlers pré-configurés (2.20) — 5 handlers inclus : billing.expire_grace_period (expire les abonnements passés en période de grâce), cleanup.expired_password_tokens (supprime les tokens de reset expirés), cleanup.expired_invitations (supprime les invitations expirées), cleanup.old_webhook_events (supprime les webhook events vieux de 30 jours), cleanup.expired_sessions (supprime les fichiers de session PHP expirés)

  • Scheduler — Page de gestion (2.21)GET /admin/scheduler : tableau avec nom/description, expression cron en <code>, date du dernier passage, badge de statut, toggle CSS pill (activé/désactivé), 3 boutons d'action icon-only avec tooltip (title) : déclencher manuellement, historique, modifier la fréquence; modal d'édition de fréquence par tâche (Stimulus modal controller); toggle POST /admin/scheduler/{id}/toggle (CSRF); run manuel POST /admin/scheduler/{id}/run (CSRF, dispatch RunScheduledTaskMessage); mise à jour de la fréquence POST /admin/scheduler/{id}/frequency (CSRF, validation via CronExpression::isValidExpression())

  • Scheduler — Historique d'exécutions (2.22)GET /admin/scheduler/{id}/executions : 50 dernières exécutions d'une tâche; colonnes : démarré le, durée (ms si < 1 s, secondes sinon), badge statut (Running/Success/Failure), sortie via <details> avec résumé tronqué; ScheduledTaskExecutionRepository::findRecentByTask(task, limit=50)

  • Admin — Sidebar responsive (2.23) — sidebar admin et sidebar account converties en drawer off-canvas sur mobile (< 1024px) ; sidebar_controller.js Stimulus (open/close/toggle, verrouillage scroll, fermeture Échap) ; .sidebar-overlay backdrop partagé ; transform: translateX(-100%)translateX(0) sur [data-open] ; hamburger .admin-topbar__toggle et .account-topbar visibles uniquement sur mobile

  • Admin — Gestion des utilisateurs (2.24)UsersController : GET /admin/users (liste avec findAllForAdmin()), GET /admin/users/{id} (détail : identité, sécurité, memberships), POST /admin/users/{id}/toggle-admin (accorder/révoquer ROLE_SUPER_ADMIN, auto-protection self), POST /admin/users/{id}/delete (force-delete via AccountDeletionService::forceDelete(), auto-protection self) ; modales de confirmation CSRF sur toutes les actions destructives ; UserRepository::findAllForAdmin() avec LEFT JOIN

  • Admin — Gestion des organisations (2.24)OrganizationsController : GET /admin/organizations (liste avec abonnement courant, propriétaire), GET /admin/organizations/{id} (détail : infos, abonnement Stripe, membres), POST /admin/organizations/{id}/delete (CSRF) ; OrganizationDeletionService::delete() supprime explicitement les subscriptions avant de retirer l'organisation ; OrganizationRepository::findAllForAdmin() avec LEFT JOIN

  • Account — Zone de danger (2.24)DangerZoneController : GET /settings/danger, POST /settings/danger/delete-organization (voter ORG_DELETE + confirmation du nom exact), POST /settings/danger/delete-account (vérification mot de passe + blocage sole-owner) ; AccountDeletionService : getSoleOwnedOrganizations() liste les orgs bloquantes, delete() supprime memberships + token reset + compte, forceDelete() contourne le check pour l'admin ; blocage affiché dans l'UI avec liste des organisations concernées

  • Design system — btn--danger — variante btn--danger ajoutée dans _btn.scss : fond teinté color-mix(error 12%), texte --color-error, hover plein rouge

  • Design system — modal --sm élargie--modal-width-sm passé de 24rem à 30rem pour éviter le wrap des boutons d'action

  • Design system — input-bg corrigé--input-bg changé de var(--color-surface) à var(--color-bg) pour rendre les champs visibles dans les cartes

Fixed

  • CSS correctly loaded via Symfony asset() helper with cache fingerprinting
  • PHP 8.4 compatibility — upgraded CI pipeline and Docker image
  • PHPStan level 8 — added generic types, fixed getUserIdentifier() return type
  • 2FA column naming and PasswordResetToken entity consolidated into a single clean migration

Changed

  • src/ reorganized into domain subdirectories — Auth, Billing, Organization, Scheduler, Docs, Home, Legal, Locale
  • messages.fr.yaml / messages.en.yaml reserved for user translations — boilerplate strings moved to dedicated boilerplate translation domain
  • Strict types (declare(strict_types=1)) enforced across all PHP files
Loading…
Loading the web debug toolbar…
Attempt #