Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
38 KiB
LST-56 (0.1) — Socle back modular monolith — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Poser l'infrastructure backend d'un modular monolith DDD (endpoints /api/modules + /api/sidebar, registre de modules, garde-fous Timestampable/Blamable, helper de commentaires SQL) sans toucher au métier existant.
Architecture: On ajoute un noyau src/Shared/ (Domain/Contract, Domain/Trait, Infrastructure/ApiPlatform, Infrastructure/Doctrine, Infrastructure/Database). La logique métier (filtrage sidebar, extraction des IDs de modules, estampillage) est isolée dans des classes pures testées unitairement ; des Providers API Platform minces les exposent en HTTP. Aucune entité existante n'est déplacée. Strangler : 100 % additif.
Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, PHPUnit 13.
Global Constraints
declare(strict_types=1);en tête de tout fichier PHP.- Migrations additives nullable uniquement — aucun
DROP, aucunNOT NULLrétroactif (prod Docker, BDD peuplée). - Zéro import inter-modules : passer par
src/Shared/Domain/Contract/ou domain events. - Toute
GetCollectionreste paginée (pas concerné dans ce lot, aucune collection ajoutée). - Toute colonne créée porte un
COMMENT ON COLUMN(FR, ≤200 chars). - PostgreSQL : noms de colonnes en minuscules dans le SQL brut.
- Commits : format
<type>(<scope>) : <message>(espaces autour du:). Jamais de mention IA/Claude. - Tests : exécution via
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit ….
Définitions différées (hors 0.1, ne PAS implémenter ici) : mappings Doctrine de module + migrations_paths modulaire + api_platform.mapping.paths (arrivent avec le 1er module à entités, ticket 1.1). Filtrage sidebar par permission (ticket 1.2). #[Auditable] (ticket 1.3).
Task 1: Endpoint GET /api/modules + registre de modules
Files:
- Create:
src/Shared/Domain/Module/ModuleInterface.php - Create:
src/Shared/Domain/Module/ModuleRegistry.php - Create:
src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php - Create:
src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php - Create:
config/modules.php - Modify:
config/packages/security.yaml(access_control, rendre/api/modulespublic) - Test:
tests/Unit/Shared/Module/ModuleRegistryTest.php - Test:
tests/Functional/Shared/ModulesEndpointTest.php
Interfaces:
-
Produces:
interface ModuleInterface { public static function id(): string; public static function label(): string; public static function isRequired(): bool; /** @return list<array{code: string, label: string}> */ public static function permissions(): array; }ModuleRegistry::ids(array $moduleClasses): array→list<string>(lesid()des classes implémentantModuleInterface, ignore les autres).config/modules.phpretournelist<class-string<ModuleInterface>>(vide en 0.1).
-
Step 1: Write the failing unit test for ModuleRegistry
Create tests/Unit/Shared/Module/ModuleRegistryTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Module;
use App\Shared\Domain\Module\ModuleInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ModuleRegistryTest extends TestCase
{
public function testIdsExtractsDeclaredModuleIds(): void
{
$classes = [FakeAlphaModule::class, FakeBetaModule::class];
self::assertSame(['alpha', 'beta'], ModuleRegistry::ids($classes));
}
public function testIdsIgnoresClassesNotImplementingModuleInterface(): void
{
$classes = [FakeAlphaModule::class, \stdClass::class];
self::assertSame(['alpha'], ModuleRegistry::ids($classes));
}
public function testIdsReturnsEmptyArrayForNoModules(): void
{
self::assertSame([], ModuleRegistry::ids([]));
}
}
final class FakeAlphaModule implements ModuleInterface
{
public static function id(): string { return 'alpha'; }
public static function label(): string { return 'Alpha'; }
public static function isRequired(): bool { return false; }
public static function permissions(): array { return []; }
}
final class FakeBetaModule implements ModuleInterface
{
public static function id(): string { return 'beta'; }
public static function label(): string { return 'Beta'; }
public static function isRequired(): bool { return true; }
public static function permissions(): array { return []; }
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryTest.php
Expected: FAIL — Class "App\Shared\Domain\Module\ModuleInterface" not found.
- Step 3: Create
ModuleInterface
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
/**
* Implemented by every `*Module` declaration class. The set of active modules
* is listed in config/modules.php and exposed via GET /api/modules.
*/
interface ModuleInterface
{
public static function id(): string;
public static function label(): string;
public static function isRequired(): bool;
/**
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array;
}
- Step 4: Create
ModuleRegistry
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
final class ModuleRegistry
{
/**
* @param list<class-string> $moduleClasses
*
* @return list<string>
*/
public static function ids(array $moduleClasses): array
{
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (is_a($moduleClass, ModuleInterface::class, true)) {
$ids[] = $moduleClass::id();
}
}
return $ids;
}
}
- Step 5: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryTest.php
Expected: PASS (3 tests).
- Step 6: Create
config/modules.php
<?php
declare(strict_types=1);
/*
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
return [
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
];
- Step 7: Create
ModulesResourceandModulesProvider
src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\ModulesProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/modules',
normalizationContext: ['groups' => ['modules:read']],
provider: ModulesProvider::class,
),
],
)]
final class ModulesResource
{
/**
* @var list<string>
*/
#[Groups(['modules:read'])]
public array $modules = [];
}
src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Infrastructure\ApiPlatform\Resource\ModulesResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ModulesProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ModulesResource
{
/** @var list<class-string> $classes */
$classes = require $this->projectDir.'/config/modules.php';
$dto = new ModulesResource();
$dto->modules = ModuleRegistry::ids($classes);
return $dto;
}
}
- Step 8: Make
/api/modulespublic insecurity.yaml
In config/packages/security.yaml, under access_control, add the rule immediately after the ^/api/version line (order matters — only the first matching rule applies):
# Liste des modules actifs en public (consommée au boot du front)
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- Step 9: Write the failing functional test
Create tests/Functional/Shared/ModulesEndpointTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ModulesEndpointTest extends WebTestCase
{
public function testModulesEndpointIsPublicAndReturnsModulesKey(): void
{
$client = static::createClient();
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('modules', $data);
self::assertIsArray($data['modules']);
}
}
- Step 10: Run the functional test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/ModulesEndpointTest.php
Expected: PASS. (If FAIL with 404, confirm API Platform discovers src/Shared/Infrastructure/ApiPlatform/Resource — the default API Platform path config in API Platform 4 scans src/ApiResource + src/Entity only; if 404 persists, add mapping.paths for the Shared Resource dir in config/packages/api_platform.yaml and re-run. This is the one allowed config touch in Task 1.)
- Step 11: Commit
git add src/Shared/Domain/Module config/modules.php src/Shared/Infrastructure/ApiPlatform config/packages/security.yaml config/packages/api_platform.yaml tests/Unit/Shared/Module tests/Functional/Shared/ModulesEndpointTest.php
git commit -m "feat(modules) : expose GET /api/modules and module registry"
Task 2: Endpoint GET /api/sidebar + filtre par module actif
Files:
- Create:
src/Shared/Domain/Sidebar/SidebarFilter.php - Create:
src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php - Create:
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php - Create:
config/sidebar.php - Test:
tests/Unit/Shared/Sidebar/SidebarFilterTest.php - Test:
tests/Functional/Shared/SidebarEndpointTest.php
Interfaces:
-
Consumes:
ModuleRegistry::ids()(Task 1),config/modules.php(Task 1). -
Produces:
SidebarFilter::filter(array $sections, array $activeModuleIds): array→array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}. Règle : un item portantmoduleabsent de$activeModuleIdsest masqué et sontoajouté àdisabledRoutes; une section vidée de tous ses items est supprimée ; les clés internes (module) sont retirées de la sortie.config/sidebar.phpretournelist<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}>.
-
Step 1: Write the failing unit test for SidebarFilter
Create tests/Unit/Shared/Sidebar/SidebarFilterTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Sidebar;
use App\Shared\Domain\Sidebar\SidebarFilter;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SidebarFilterTest extends TestCase
{
public function testItemWithoutModuleIsAlwaysVisible(): void
{
$sections = [
['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
]],
];
$result = SidebarFilter::filter($sections, []);
self::assertCount(1, $result['sections']);
self::assertSame('/', $result['sections'][0]['items'][0]['to']);
self::assertSame([], $result['disabledRoutes']);
self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]);
}
public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void
{
$sections = [
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
]],
];
$result = SidebarFilter::filter($sections, []);
self::assertSame([], $result['sections']);
self::assertSame(['/time-tracking'], $result['disabledRoutes']);
}
public function testItemWithActiveModuleIsVisible(): void
{
$sections = [
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
]],
];
$result = SidebarFilter::filter($sections, ['time_tracking']);
self::assertCount(1, $result['sections']);
self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
self::assertSame([], $result['disabledRoutes']);
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php
Expected: FAIL — Class "App\Shared\Domain\Sidebar\SidebarFilter" not found.
- Step 3: Create
SidebarFilter
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sections
* @param list<string> $activeModuleIds
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds): array
{
$outSections = [];
$disabledRoutes = [];
foreach ($sections as $section) {
$items = [];
foreach ($section['items'] as $item) {
$module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
$disabledRoutes[] = $item['to'];
continue;
}
$items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
}
if ([] !== $items) {
$outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
}
}
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
}
}
- Step 4: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php
Expected: PASS (3 tests).
- Step 5: Create
config/sidebar.php
Toutes les entrées actuelles sont sans clé module (donc visibles) ; les futurs modules ajouteront leur module. Labels = clés i18n.
<?php
declare(strict_types=1);
/*
* Définition de la sidebar (sections + items). Filtrée par SidebarFilter selon les modules actifs.
* Un item porte une clé `module` quand il appartient à un module activable ; sans clé, il est toujours visible.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:checkbox-marked-circle-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-outline'],
],
],
[
'label' => 'sidebar.hr.section',
'icon' => 'mdi:calendar-account-outline',
'items' => [
['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'],
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'],
],
],
];
- Step 6: Create
SidebarResourceandSidebarProvider
src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\SidebarProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/sidebar',
normalizationContext: ['groups' => ['sidebar:read']],
provider: SidebarProvider::class,
),
],
)]
final class SidebarResource
{
/**
* @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>
*/
#[Groups(['sidebar:read'])]
public array $sections = [];
/**
* @var list<string>
*/
#[Groups(['sidebar:read'])]
public array $disabledRoutes = [];
}
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
{
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sidebar */
$sidebar = require $this->projectDir.'/config/sidebar.php';
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses));
$dto = new SidebarResource();
$dto->sections = $filtered['sections'];
$dto->disabledRoutes = $filtered['disabledRoutes'];
return $dto;
}
}
- Step 7: Write the failing functional test
Create tests/Functional/Shared/SidebarEndpointTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class SidebarEndpointTest extends WebTestCase
{
public function testSidebarRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/api/sidebar');
self::assertResponseStatusCodeSame(401);
}
public function testSidebarReturnsSectionsForAuthenticatedUser(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
$client->request('GET', '/api/sidebar');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('sections', $data);
self::assertArrayHasKey('disabledRoutes', $data);
self::assertNotEmpty($data['sections']);
}
}
- Step 8: Run the functional test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/SidebarEndpointTest.php
Expected: PASS (2 tests).
- Step 9: Commit
git add src/Shared/Domain/Sidebar src/Shared/Infrastructure/ApiPlatform config/sidebar.php tests/Unit/Shared/Sidebar tests/Functional/Shared/SidebarEndpointTest.php
git commit -m "feat(sidebar) : expose GET /api/sidebar filtered by active modules"
Task 3: Garde-fou Timestampable / Blamable (trait + subscriber)
Files:
- Create:
src/Shared/Domain/Contract/UserInterface.php - Create:
src/Shared/Domain/Contract/TimestampableInterface.php - Create:
src/Shared/Domain/Contract/BlamableInterface.php - Create:
src/Shared/Application/CurrentUserProviderInterface.php - Create:
src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php - Create:
src/Shared/Domain/Trait/TimestampableBlamableTrait.php - Create:
src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php - Modify:
src/Entity/User.php(implementUserInterface) - Modify:
config/packages/doctrine.yaml(resolve_target_entities) - Test:
tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Interfaces:
- Produces:
interface UserInterface { public function getId(): ?int; }interface TimestampableInterface { public function getCreatedAt(): ?\DateTimeImmutable; public function setCreatedAt(\DateTimeImmutable $createdAt): void; public function getUpdatedAt(): ?\DateTimeImmutable; public function setUpdatedAt(\DateTimeImmutable $updatedAt): void; }interface BlamableInterface { public function getCreatedBy(): ?UserInterface; public function setCreatedBy(?UserInterface $user): void; public function getUpdatedBy(): ?UserInterface; public function setUpdatedBy(?UserInterface $user): void; }interface CurrentUserProviderInterface { public function getCurrentUser(): ?UserInterface; }TimestampableBlamableSubscriber::applyOnCreate(object $entity): voidand::applyOnUpdate(object $entity): void— pure-ish entry points used by the unit test; the Doctrine hooks delegate to them.
Note (strangler): en 0.1 le trait/subscriber n'est encore appliqué à aucune entité (les entités restent legacy). Le contrat
UserInterfaceest mappé surApp\Entity\Userviaresolve_target_entities; il sera re-pointé versApp\Module\Core\Domain\Entity\Userau ticket 1.1.
- Step 1: Write the failing unit test for the subscriber
Create tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class TimestampableBlamableSubscriberTest extends TestCase
{
public function testApplyOnCreateSetsTimestampsAndAuthor(): void
{
$user = $this->makeUser(7);
$subscriber = new TimestampableBlamableSubscriber($this->providerReturning($user));
$entity = $this->makeEntity();
$subscriber->applyOnCreate($entity);
self::assertInstanceOf(\DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(\DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertSame($user, $entity->getCreatedBy());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testApplyOnUpdateLeavesCreatedUntouched(): void
{
$creator = $this->makeUser(1);
$editor = $this->makeUser(2);
$entity = $this->makeEntity();
(new TimestampableBlamableSubscriber($this->providerReturning($creator)))->applyOnCreate($entity);
$createdAt = $entity->getCreatedAt();
(new TimestampableBlamableSubscriber($this->providerReturning($editor)))->applyOnUpdate($entity);
self::assertSame($createdAt, $entity->getCreatedAt());
self::assertSame($creator, $entity->getCreatedBy());
self::assertSame($editor, $entity->getUpdatedBy());
}
public function testApplyOnCreateIgnoresNonTimestampableEntities(): void
{
$subscriber = new TimestampableBlamableSubscriber($this->providerReturning(null));
// Must not throw.
$subscriber->applyOnCreate(new \stdClass());
$this->addToAssertionCount(1);
}
private function providerReturning(?UserInterface $user): CurrentUserProviderInterface
{
return new class($user) implements CurrentUserProviderInterface {
public function __construct(private ?UserInterface $user) {}
public function getCurrentUser(): ?UserInterface
{
return $this->user;
}
};
}
private function makeUser(int $id): UserInterface
{
return new class($id) implements UserInterface {
public function __construct(private int $id) {}
public function getId(): ?int
{
return $this->id;
}
};
}
private function makeEntity(): object
{
return new class implements TimestampableInterface, BlamableInterface {
use TimestampableBlamableTrait;
};
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Expected: FAIL — interfaces/classes not found.
- Step 3: Create the contracts
src/Shared/Domain/Contract/UserInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface UserInterface
{
public function getId(): ?int;
}
src/Shared/Domain/Contract/TimestampableInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface TimestampableInterface
{
public function getCreatedAt(): ?\DateTimeImmutable;
public function setCreatedAt(\DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?\DateTimeImmutable;
public function setUpdatedAt(\DateTimeImmutable $updatedAt): void;
}
src/Shared/Domain/Contract/BlamableInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
- Step 4: Create the current-user provider (contract + Security impl)
src/Shared/Application/CurrentUserProviderInterface.php:
<?php
declare(strict_types=1);
namespace App\Shared\Application;
use App\Shared\Domain\Contract\UserInterface;
interface CurrentUserProviderInterface
{
public function getCurrentUser(): ?UserInterface;
}
src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class SecurityCurrentUserProvider implements CurrentUserProviderInterface
{
public function __construct(
private Security $security,
) {}
public function getCurrentUser(): ?UserInterface
{
$user = $this->security->getUser();
return $user instanceof UserInterface ? $user : null;
}
}
- Step 5: Create the trait
src/Shared/Domain/Trait/TimestampableBlamableTrait.php:
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
- Step 6: Create the Doctrine subscriber
src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php:
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final readonly class TimestampableBlamableSubscriber
{
public function __construct(
private CurrentUserProviderInterface $currentUserProvider,
) {}
public function prePersist(PrePersistEventArgs $args): void
{
$this->applyOnCreate($args->getObject());
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$this->applyOnUpdate($args->getObject());
}
public function applyOnCreate(object $entity): void
{
$now = new \DateTimeImmutable();
if ($entity instanceof TimestampableInterface) {
if (null === $entity->getCreatedAt()) {
$entity->setCreatedAt($now);
}
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface) {
$user = $this->currentUserProvider->getCurrentUser();
if (null === $entity->getCreatedBy()) {
$entity->setCreatedBy($user);
}
$entity->setUpdatedBy($user);
}
}
public function applyOnUpdate(object $entity): void
{
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new \DateTimeImmutable());
}
if ($entity instanceof BlamableInterface) {
$entity->setUpdatedBy($this->currentUserProvider->getCurrentUser());
}
}
}
- Step 7: Make legacy
Userimplement the contract + addresolve_target_entities
In src/Entity/User.php, add the interface to the class declaration (the entity already has getId(): ?int, so no method to add):
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
// ...
class User implements /* existing interfaces, */ SharedUserInterface
Keep all existing
implementsclauses; appendSharedUserInterface. Alias avoids any clash withSymfony\...\UserInterfacealready imported.
In config/packages/doctrine.yaml, under orm:, add:
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Entity\User
- Step 8: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php
Expected: PASS (3 tests).
- Step 9: Run the full suite to confirm no regression
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (no entity uses the trait yet; resolve_target_entities is inert until consumed). Confirm the prior 96 tests still pass.
- Step 10: Commit
git add src/Shared/Domain/Contract src/Shared/Application src/Shared/Infrastructure/Security src/Shared/Domain/Trait src/Shared/Infrastructure/Doctrine src/Entity/User.php config/packages/doctrine.yaml tests/Unit/Shared/Doctrine
git commit -m "feat(shared) : add timestampable/blamable trait and doctrine subscriber"
Task 4: Helper ColumnCommentsCatalog (COMMENT ON COLUMN)
Files:
- Create:
src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php - Test:
tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Interfaces:
-
Produces:
ColumnCommentsCatalog::timestampableBlamableComments(string $table): list<string>→ la liste des instructionsCOMMENT ON COLUMN <table>.<col> IS '...'pour les 4 colonnes standard. Utilisé dans les migrations des modules (à partir de 1.1) via$this->addSql(...). -
Step 1: Write the failing unit test
Create tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Database;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ColumnCommentsCatalogTest extends TestCase
{
public function testTimestampableBlamableCommentsCoverFourColumns(): void
{
$sql = ColumnCommentsCatalog::timestampableBlamableComments('task');
self::assertCount(4, $sql);
self::assertSame(
"COMMENT ON COLUMN task.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'",
$sql[0],
);
self::assertStringContainsString('COMMENT ON COLUMN task.created_by IS', $sql[2]);
}
public function testTableNameIsInterpolatedForEveryColumn(): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments('time_entry') as $statement) {
self::assertStringContainsString('COMMENT ON COLUMN time_entry.', $statement);
}
}
}
- Step 2: Run the test, verify it fails
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Expected: FAIL — class not found.
- Step 3: Create
ColumnCommentsCatalog
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
final class ColumnCommentsCatalog
{
/**
* SQL `COMMENT ON COLUMN` statements for the 4 standard Timestampable/Blamable columns.
* Call from a migration: foreach (...) { $this->addSql($statement); }.
*
* @return list<string>
*/
public static function timestampableBlamableComments(string $table): array
{
return [
"COMMENT ON COLUMN {$table}.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.updated_at IS 'Date de derniere modification (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.created_by IS 'Auteur de la creation (FK user, SET NULL). Rempli automatiquement (Blamable).'",
"COMMENT ON COLUMN {$table}.updated_by IS 'Auteur de la derniere modification (FK user, SET NULL). Rempli automatiquement (Blamable).'",
];
}
}
- Step 4: Run the unit test, verify it passes
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php
Expected: PASS (2 tests).
- Step 5: Run the full suite + cs-fixer
Run: docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
Expected: PASS (all green, including the 96 pre-existing tests).
Run: make php-cs-fixer-allow-risky
Expected: no remaining violations in src/Shared / tests.
- Step 6: Commit
git add src/Shared/Infrastructure/Database tests/Unit/Shared/Database
git commit -m "feat(shared) : add column comments catalog helper for migrations"
Acceptance check (run after all tasks)
GET /api/modulesreturns{ "modules": [] }(public, 200).GET /api/sidebarreturns{ sections, disabledRoutes }(401 unauth, 200 auth).src/Shared/holds contracts, trait, subscriber, helper, providers.make testgreen (96 prior + new unit/functional tests).- No destructive migration; no business entity moved; no inter-module import.
Notes for the next ticket (0.2 — Socle front)
Le front consommera /api/modules + /api/sidebar via useModules/useSidebar, montera le shell app/ + shared/ et l'auto-détection des layers. Le filtrage par module deviendra réellement visible quand le 1er module (1.1 Core, puis 2.1 TimeTracking) déclarera sa clé module dans config/sidebar.php.