Aller au contenu principal
Retour au blog

Lancer son SaaS Symfony — Partie 2 : Architecture et développement

Flavien Métivier10 mars 20268 min

Dans la partie 1, nous avons validé l'idée et structuré le backlog. Maintenant, place à l'architecture technique. Le choix de l'architecture d'un SaaS est déterminant : un mauvais choix initial coûte 10x plus cher à corriger 6 mois plus tard. Voici les patterns éprouvés pour un SaaS Symfony production-ready.

Architecture hexagonale : le bon choix pour un SaaS

L'architecture hexagonale (Ports & Adapters) isole la logique métier de l'infrastructure technique. Pour un SaaS, c'est un avantage majeur : tu pourras changer de provider de paiement, de base de données ou de système de file d'attente sans toucher au code métier.

src/
├── Application/          # Use Cases / CQRS
   ├── Command/         # Write operations
   ├── Query/           # Read operations
   └── Handler/         # Command/Query handlers
├── Domain/              # Logique métier pure
   ├── Entity/          # Entités avec factory
   ├── ValueObject/     # Immutable value objects
   ├── Repository/      # Interfaces uniquement
   ├── Event/           # Domain events
   └── Exception/       # Domain exceptions
├── Infrastructure/      # Implémentation technique
   ├── Doctrine/        # Repositories + Mapping
   ├── Symfony/         # Controllers, Forms
   ├── Security/        # Voters, Authenticators
   └── Messaging/       # Message handlers
└── Presentation/        # UI Layer
    ├── Controller/      # Slim controllers
    ├── DTO/             # Data Transfer Objects
    └── Transformer/     # Entity to DTO

La règle fondamentale : le Domain ne dépend de rien. Il ne connaît ni Doctrine, ni Symfony, ni Stripe. Il définit des interfaces (ports) que l'Infrastructure implémente (adapters). Cette inversion de dépendance est la clé de la testabilité et de l'évolutivité.

Multi-tenancy : quel pattern choisir ?

Un SaaS sert plusieurs clients (tenants) sur la même infrastructure. Trois approches existent :

  • Base de données partagée, schéma partagé : une colonne tenant_id sur chaque table. Simple à mettre en place, complexe à sécuriser (risque de data leak entre tenants). Adapté aux MVP.
  • Base de données partagée, schémas séparés : un schéma PostgreSQL par tenant. Bonne isolation, migration plus complexe. Adapté aux SaaS B2B.
  • Bases de données séparées : une base par tenant. Isolation maximale, coût infra plus élevé. Adapté aux SaaS enterprise avec exigences de conformité.

Pour un MVP, commence avec l'approche colonne tenant_id. Avec Doctrine, un filtre SQL global garantit l'isolation :

<?php

declare(strict_types=1);

namespace App\Infrastructure\Doctrine\Filter;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

final class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(
        ClassMetadata $targetEntity,
        string $targetTableAlias
    ): string {
        if (!$targetEntity->reflClass->implementsInterface(
            \App\Domain\TenantAwareInterface::class
        )) {
            return '';
        }

        return sprintf(
            '%s.tenant_id = %s',
            $targetTableAlias,
            $this->getParameter('tenant_id')
        );
    }
}

Authentification : JWT + refresh tokens

Pour un SaaS avec une API (et potentiellement une SPA ou une app mobile), l'authentification JWT est le standard. Le bundle lexik/jwt-authentication-bundle avec un système de refresh tokens offre le meilleur compromis sécurité/expérience :

  • Access token : JWT court (15 min), contient l'ID utilisateur, le tenant et les rôles. Vérifié côté serveur sans hit en base.
  • Refresh token : token opaque long (30 jours), stocké en base, permet de renouveler l'access token sans re-saisir le mot de passe.
  • Rotation des refresh tokens : à chaque utilisation, l'ancien refresh token est invalidé et un nouveau est émis. Protège contre le vol de token.

Besoin d'un expert Symfony ?

Réserver un appel
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 900 # 15 minutes

# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
    ttl: 2592000 # 30 jours
    single_use: true # Rotation

Stack technique recommandée

  • Framework : Symfony 7.x LTS — stabilité, écosystème, documentation
  • API : API Platform 4 — génération automatique OpenAPI, filtres, pagination
  • Base de données : PostgreSQL 16 — JSONB, full-text search, schémas pour multi-tenancy
  • Cache / Queue : Redis — sessions, cache applicatif, transport Messenger
  • Conteneurisation : Docker Compose pour le dev, Kubernetes ou Fly.io pour la prod
  • CI/CD : GitHub Actions — PHPStan, PHPUnit, Deptrac, déploiement automatique

Workflow de développement : TDD + CI dès le jour 1

La tentation d'un MVP est de coder vite et d'ajouter les tests "plus tard". C'est un piège. Le coût d'ajout de tests après coup est 3-5x plus élevé que le TDD dès le départ. Voici le workflow minimal :

  • Docker Compose : un docker-compose.yml avec PHP, PostgreSQL, Redis, Mailpit. Tout le monde développe dans le même environnement.
  • Makefile : make test, make lint, make analyse. Commandes standardisées pour toute l'équipe.
  • CI dès le premier commit : PHPStan niveau 9, PHPUnit avec couverture, Deptrac pour l'architecture. Si la CI est rouge, on ne merge pas.
  • TDD sur le Domain : le code métier est testé en premier. Les tests du Domain sont rapides (pas de base, pas de framework) et servent de documentation vivante.
# docker-compose.yml (simplifié)
services:
  php:
    build: .docker/php
    volumes: ['./:/app']
    depends_on: [database, redis]

  database:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports: ['5432:5432']

  redis:
    image: redis:7-alpine
    ports: ['6379:6379']

  mailpit:
    image: axllent/mailpit
    ports: ['8025:8025', '1025:1025']

Gestion des abonnements

Pour le paiement, Stripe reste le standard pour les SaaS. Le pattern recommandé : utiliser les webhooks Stripe avec Symfony Messenger pour le traitement asynchrone. Chaque événement Stripe (subscription.created, invoice.paid, subscription.canceled) est dispatché vers un handler dédié.

Ne construis pas ton propre système de facturation. Stripe gère les abonnements, les prorata, les taxes et la conformité SCA. Ton code métier n'a besoin que de deux informations : le tenant a-t-il un abonnement actif ? Quel est son plan ?

Prochaine étape : déploiement et premiers utilisateurs

Dans la partie 3, nous couvrirons le déploiement, le monitoring, et la stratégie d'acquisition des premiers utilisateurs payants. Si tu veux passer directement de l'idée au code production-ready avec un accompagnement technique senior, Bear MVP te livre un SaaS Symfony complet — architecture hexagonale, multi-tenancy, CI/CD, tests — avec un prix algorithmique transparent et une garantie 30 jours.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.