feat: Infrastructure multi-tenant avec isolation par sous-domaine

Une application SaaS éducative nécessite une séparation stricte des données
entre établissements scolaires. L'architecture multi-tenant par sous-domaine
(ecole-alpha.classeo.local) permet cette isolation tout en utilisant une
base de code unique.

Le choix d'une résolution basée sur les sous-domaines plutôt que sur des
headers ou tokens facilite le routage au niveau infrastructure (reverse proxy)
et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
This commit is contained in:
2026-01-30 23:34:10 +01:00
parent 6da5996340
commit 1fd256346a
71 changed files with 14390 additions and 37 deletions

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\BuildStep;
use PHPat\Test\PHPat;
/**
* Tests ensuring Bounded Contexts are properly isolated.
*
* Bounded Contexts must communicate through events (via Shared),
* not through direct dependencies.
*/
final class BoundedContextIsolationTest
{
public function test_scolarite_should_not_depend_on_vie_scolaire(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Scolarite'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\VieScolaire'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_scolarite_should_not_depend_on_communication(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Scolarite'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Communication'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_scolarite_should_not_depend_on_administration(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Scolarite'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Administration'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_vie_scolaire_should_not_depend_on_scolarite(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Scolarite'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_vie_scolaire_should_not_depend_on_communication(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Communication'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_vie_scolaire_should_not_depend_on_administration(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Administration'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_communication_should_not_depend_on_scolarite(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Communication'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Scolarite'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_communication_should_not_depend_on_vie_scolaire(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Communication'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\VieScolaire'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_communication_should_not_depend_on_administration(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Communication'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Administration'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_administration_should_not_depend_on_scolarite(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Administration'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Scolarite'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_administration_should_not_depend_on_vie_scolaire(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Administration'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
public function test_administration_should_not_depend_on_communication(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Administration'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Communication'))
->because('Bounded Contexts must communicate through events, not direct calls');
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\BuildStep;
use PHPat\Test\PHPat;
/**
* Tests ensuring Domain layer purity across all Bounded Contexts.
*
* The Domain layer must be pure PHP without framework dependencies.
* This is critical for DDD architecture and testability.
*/
final class DomainPurityTest
{
public function test_scolarite_domain_should_not_depend_on_infrastructure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Scolarite\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('App\Scolarite\Infrastructure'),
Selector::inNamespace('App\Shared\Infrastructure'),
Selector::inNamespace('Symfony'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('ApiPlatform'),
)
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
}
public function test_vie_scolaire_domain_should_not_depend_on_infrastructure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('App\VieScolaire\Infrastructure'),
Selector::inNamespace('App\Shared\Infrastructure'),
Selector::inNamespace('Symfony'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('ApiPlatform'),
)
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
}
public function test_communication_domain_should_not_depend_on_infrastructure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Communication\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('App\Communication\Infrastructure'),
Selector::inNamespace('App\Shared\Infrastructure'),
Selector::inNamespace('Symfony'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('ApiPlatform'),
)
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
}
public function test_administration_domain_should_not_depend_on_infrastructure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Administration\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('App\Administration\Infrastructure'),
Selector::inNamespace('App\Shared\Infrastructure'),
Selector::inNamespace('Symfony'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('ApiPlatform'),
)
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
}
public function test_scolarite_domain_should_not_depend_on_application(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Scolarite\Domain'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Scolarite\Application'))
->because('Domain must not know about Application layer');
}
public function test_vie_scolaire_domain_should_not_depend_on_application(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\VieScolaire\Application'))
->because('Domain must not know about Application layer');
}
public function test_communication_domain_should_not_depend_on_application(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Communication\Domain'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Communication\Application'))
->because('Domain must not know about Application layer');
}
public function test_administration_domain_should_not_depend_on_application(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Administration\Domain'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Administration\Application'))
->because('Domain must not know about Application layer');
}
public function test_shared_domain_should_be_pure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Shared\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Symfony'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('ApiPlatform'),
Selector::inNamespace('App\Shared\Infrastructure'),
Selector::inNamespace('App\Shared\Application'),
)
->because('Shared Domain must be pure PHP');
}
}