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.xmlwith three named test suites,APP_ENV=testforced in both$_ENVand$_SERVERto override Docker's system env;config/packages/test/framework.yamlenablingframework.test, settingrouter.default_uri, disabling Symfony Form CSRF, and overriding all rate limiters tono_limit;config/services_test.yamlreplacingStripeServicewithStripeServiceStubafter 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) —
AppStoryloads 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 viamake 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\StripeServiceInterfacesoCreateStripeCustomerSubscriberand tests can depend on the abstraction rather than the concrete class;StripeServiceimplements the interface;StripeServiceStubimplements 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-fixadded 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_iden session, redirect referer) ;OrganizationMemberRepository::findAllActiveMemberships();OrganizationExtension+OrganizationRuntimeTwig pour exposeruser_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 OrganizationVoter —
OrganizationVoteravec 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 ;MembersControllerprotège les actions POST (revoke, suspend, reactivate) viadenyAccessUnlessGranted;OrganizationControllerremplaceROLE_USERparORG_SETTINGSsur l'organisation courante ; formulaire d'invitation et boutons d'action masqués en template viais_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 system —
InvitationServicewithinvite()(tokenbin2hex(random_bytes(32)), 7-day expiry, duplicate guard),accept()(createsOrganizationMember, dispatchesMemberJoinedEvent),revoke(),suspendMember(),reactivateMember();MemberInvitedEvent→SendInvitationEmailSubscriber→InvitationMailer(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 page —
GET/POST /settings/memberswith invite form, pending invitations list (revoke), active members list (suspend/reactivate); contextual aside with role descriptions and counters;MembersControllerwith 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_ACCESSin firewall -
Multi-tenancy — Organization creation at registration —
organizationNamefield added toRegistrationFormType;RegistrationService::register()now accepts anorganizationNameparameter and callsOrganizationService::createForUser()to persist User + Organization + OrganizationMember atomically in a single flush;OrganizationCreatedEventdispatched after creation -
Multi-tenancy — OrganizationService —
createForUser(string $name, User $owner)generates a unique slug viacocur/slugify(4-char random suffix on collision), persists Organization + Owner membership and dispatchesOrganizationCreatedEvent;updateName()andupdateSlug()for settings management (slug uniqueness enforced, throws\InvalidArgumentExceptionif taken) -
Multi-tenancy — Settings page —
GET/POST /settings/organizationwithOrganizationSettingsFormType(name + slug with format validation); flash messages on success or slug conflict; resolves the active organization viaTenantContext -
Multi-tenancy — TenantContext — request-scoped service (
src/Service/Organization/TenantContext.php) holding the active organization for the current request; exposesgetCurrentOrganization(),setCurrentOrganization(),requireCurrentOrganization()(throwsLogicExceptionif no organization is set) andhasOrganization() -
Multi-tenancy — TenantSubscriber —
kernel.requestlistener (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_idsession key -
Multi-tenancy — OrganizationMemberRepository —
findActiveMembership(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 — Registration —
RegistrationFormTypewith first name, last name, email and repeated password; server-side validation with dedicatedvalidatorstranslation 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 viahash_equals(); automatic login viaSecurity::login()after verification -
Authentication — Rate limiting — token bucket limiter on registration (5 attempts / 15 min / IP) via Symfony RateLimiter + Redis
-
Authentication — Events —
UserRegisteredEventandUserEmailVerifiedEvent;SendVerificationEmailSubscriberdecouples 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
preferredLocaleon the User entity -
Authentication — Password UX — client-side strength gauge (Stimulus
password-strengthcontroller) with i18n labels viadata-*attributes; password visibility toggle -
Authentication — Translations — dedicated
authdomain (auth.fr.yaml/auth.en.yaml) for all auth strings;validators.fr.yaml/validators.en.yamlfor Symfony constraint messages -
User entity —
preferredLocalefield 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
-
Security —
trusted_proxieswithdefault::env prefix (safe with empty value in dev);preferredLocaleinput 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 decomposition —
base.html.twigsplit intocomponents/_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 controllers —
theme-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
boilerplatetranslation domain separate from user translations -
Stack page — versions read dynamically from
Composer\InstalledVersionsand PHP runtime, no hardcoded values -
Makefile —
make up,make install,make migrate,make seed,make lint,make test,make doctor,make shelland more -
Commercial license — Standard (€149) and Agency (€299) tiers with clear usage terms
-
Public pages —
/licenseand/changelogwith 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_routeattribute; responsive: horizontal scroll on mobile, fixed 240px column on desktop -
Account area — Profile page —
GET/POST /account/profile;ProfileFormType(firstName + lastName);ProfileService::updateProfile(); aside preview card (avatar initials, name, email) + tip -
Account area — Security page —
GET/POST /account/securitywith 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 flow —
requestEmailChange()generatesbin2hex(random_bytes(32))raw token, SHA-256 hash stored inpendingEmailToken, 1h expiry inpendingEmailTokenExpiresAt;EmailChangeRequestedEventdispatched →SendEmailChangeEmailSubscriber→EmailChangeMailer(HTML + plain-text);confirmEmailChange()validates token withhash_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 page —
GET/POST /account/preferences;PreferencesFormTypewithpreferredLocaleChoiceType (required: true,NotBlankconstraint); locale persisted on User; aside card listing what the locale affects (emails, UI) -
Account area — Organisation settings —
/settings/organizationmigrated to extendaccount/base.html.twig; aside card with org preview (initials avatar, name, slug), creation date, member count and slug meta -
Account area — Aside panels —
.account-pageCSS grid (main + sticky aside column: 340px lg / 400px xl / 440px 2xl);.info-cardBEM block with__preview,__checklist,__list,__meta,__tipelements -
Authentication — Login — email / password form with visibility toggle, CSRF token, "remember me" checkbox (30 days via
kernel.secret) -
Authentication — Login rate limiting —
LoginRateLimiterSubscriber: two independent counters (IP + email, 5 attempts / 15 min) viaCheckPassportEvent(priority 8),LoginFailureEventandLoginSuccessEvent -
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);PasswordResetTokenentity 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 viaSecurity::login()after reset -
Authentication — Password reset email —
PasswordResetEmailMailer+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 integration —
StripeService(checkout session, portal session),PlanRegistry(loadsconfig/plans.yaml,findByPriceId()),Planvalue 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-intervalStimulus controller for toggle UX -
Billing — Stripe webhooks —
StripeWebhookController(POST /webhook/stripe,PUBLIC_ACCESS); HMAC-SHA256 signature verification viaStripe\Webhook::constructEvent(); idempotency viaWebhookEvententity (skips already-processed Stripe event IDs); handlescheckout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_succeeded,invoice.payment_failed;SubscriptionManager(syncFromStripe(),cancelFromStripe(),markPastDue()) -
Billing — Customer Portal —
StripeService::createPortalSession();POST /settings/billing/portalgenerates a Stripe-hosted portal URL and redirects; button visible only ifstripeCustomerIdis 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 isOrganization; denies all features if no active subscription or if the Price ID resolves to no known plan;FEATURE_ADD_MEMBERusesOrganizationMemberRepository::countActiveMembers()to enforcemax_memberslimit (0 = unlimited); usable in controllers viadenyAccessUnlessGranted/isGrantedand in Twig viais_granted() -
Billing — Grace period —
pastDueAtfield added toSubscriptionentity, recorded wheninvoice.payment_failedis received;app.billing.grace_period_days: 7parameter (configurable inservices.yaml);ExpireGracePeriodMessagedispatched nightly at 2 AM via Symfony Scheduler (RecurringMessage::cron('0 2 * * *', ...));ExpireGracePeriodHandlerqueriesSubscriptionRepository::findExpiredGracePeriod(), movespast_duesubscriptions toexpiredand clearscurrentSubscription; 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 pasbase.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 — Dashboard —
GET /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;DashboardControllerinjecteConnectionpour le check DB et litMESSENGER_TRANSPORT_DSNpour le check Redis -
Admin area — Authentification — page de connexion dédiée
GET /admin/login(AdminLoginController) sans case "Se souvenir de moi", CSRF idadmin_authenticate; redirect vers le dashboard si déjà authentifié; route de déconnexionGET /admin/logoutinterceptée par le firewall -
Admin area — Sécurité — firewall Symfony
admindédié (avantmain, pattern^/admin), sansremember_me(session uniquement);role_hierarchy: ROLE_SUPER_ADMIN: [ROLE_USER];access_control:/admin/loginenPUBLIC_ACCESS,/adminnécessiteROLE_SUPER_ADMIN;TenantSubscriberignore toutes les routes/admin;AdminAccessSubscriberlogue chaque accès admin (email + IP + méthode + chemin) -
Admin area — Commande de promotion —
app:admin:promote <email>ajouteROLE_SUPER_ADMINau 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 JSONrolesajoutée à la tableusers(migrationVersion20260416140000);getRoles()retournearray_unique([...$this->roles, 'ROLE_USER']);setRoles(list<string> $roles)ajouté -
Scheduler — Architecture DB-driven (2.18) —
ScheduledTaskHandlerInterface(getTaskName(): string,handle(): string);TaskHandlerRegistryauto-câblé via!tagged_iterator app.scheduled_task_handler;RunScheduledTaskMessage(readonly,taskName: string);RunScheduledTaskHandler(#[AsMessageHandler]) : crée uneScheduledTaskExecutionen état Running, résout le handler via le registry, exécute, met à jour en Success/Failure avec output et horodatage;Schedule.phpreécrit pour charger les tâches activées depuis la base de données et enregistrer desRecurringMessage::cron()dynamiquement; migrationVersion20260416150000insè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 (Stimulusmodalcontroller); togglePOST /admin/scheduler/{id}/toggle(CSRF); run manuelPOST /admin/scheduler/{id}/run(CSRF, dispatchRunScheduledTaskMessage); mise à jour de la fréquencePOST /admin/scheduler/{id}/frequency(CSRF, validation viaCronExpression::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.jsStimulus (open/close/toggle, verrouillage scroll, fermeture Échap) ;.sidebar-overlaybackdrop partagé ;transform: translateX(-100%)→translateX(0)sur[data-open]; hamburger.admin-topbar__toggleet.account-topbarvisibles uniquement sur mobile -
Admin — Gestion des utilisateurs (2.24) —
UsersController:GET /admin/users(liste avecfindAllForAdmin()),GET /admin/users/{id}(détail : identité, sécurité, memberships),POST /admin/users/{id}/toggle-admin(accorder/révoquerROLE_SUPER_ADMIN, auto-protection self),POST /admin/users/{id}/delete(force-delete viaAccountDeletionService::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(voterORG_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--dangerajoutée dans_btn.scss: fond teintécolor-mix(error 12%), texte--color-error, hover plein rouge -
Design system — modal --sm élargie —
--modal-width-smpassé de24remà30rempour éviter le wrap des boutons d'action -
Design system — input-bg corrigé —
--input-bgchangé devar(--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
PasswordResetTokenentity consolidated into a single clean migration
Changed
src/reorganized into domain subdirectories —Auth,Billing,Organization,Scheduler,Docs,Home,Legal,Localemessages.fr.yaml/messages.en.yamlreserved for user translations — boilerplate strings moved to dedicatedboilerplatetranslation domain- Strict types (
declare(strict_types=1)) enforced across all PHP files