feat: Setup projet Classeo avec infrastructure Docker et architecture DDD

Configure l'environnement de développement complet avec Docker Compose,
structure DDD 4 Bounded Contexts, et pipeline CI/CD GitHub Actions.

Corrections compatibilité CI:
- Symfony 8 nécessite monolog-bundle ^4.0 (la v3.x ne supporte que jusqu'à Symfony 7)
- ESLint v9 nécessite flat config (eslint.config.js) - le format .eslintrc.cjs est obsolète
This commit is contained in:
2026-01-30 09:55:58 +01:00
parent ddefa927c7
commit 6da5996340
125 changed files with 10032 additions and 0 deletions

54
backend/.env Normal file
View File

@@ -0,0 +1,54 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=change_me_in_production_12345678
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_HOSTS='^(localhost|php|127\.0\.0\.1)$'
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
DATABASE_URL="postgresql://classeo:classeo@db:5432/classeo_master?serverVersion=18&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@rabbitmq:5672/%2f/messages
###< symfony/messenger ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=classeo_jwt_passphrase_change_me
###< lexik/jwt-authentication-bundle ###
###> redis ###
REDIS_URL=redis://redis:6379
###< redis ###
###> mercure ###
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
MERCURE_JWT_SECRET=mercure_publisher_secret_change_me_in_production
###< mercure ###
###> meilisearch ###
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=masterKey
###< meilisearch ###
###> symfony/mailer ###
MAILER_DSN=smtp://mailpit:1025
###< symfony/mailer ###

72
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# =============================================================================
# Symfony
# =============================================================================
/var/
/vendor/
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
# Fichiers auto-générés par Symfony
/config/bundles.php
/config/preload.php
/config/reference.php
# =============================================================================
# Doctrine Fixtures (auto-généré)
# =============================================================================
/src/DataFixtures/
# =============================================================================
# PHPUnit
# =============================================================================
/phpunit.xml
.phpunit.cache/
.phpunit.result.cache
# =============================================================================
# PHPStan
# =============================================================================
phpstan.neon.dist
# =============================================================================
# PHP CS Fixer
# =============================================================================
.php-cs-fixer.cache
# =============================================================================
# JWT Keys
# =============================================================================
/config/jwt/*.pem
# =============================================================================
# Composer
# =============================================================================
composer.phar
composer.lock
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

60
backend/.php-cs-fixer.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
// Fichiers auto-générés par Symfony/Doctrine
->notPath('config/bundles.php')
->notPath('config/preload.php')
->notPath('config/reference.php')
->notPath('src/DataFixtures/AppFixtures.php')
// Exclusions spécifiques
->notPath('src/Shared/Domain/AggregateRoot.php')
->notPath('src/Shared/Domain/EntityId.php')
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,
'declare_strict_types' => true,
'strict_param' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'not_operator_with_successor_space' => true,
'trailing_comma_in_multiline' => true,
'phpdoc_order' => true,
'phpdoc_separation' => true,
'phpdoc_no_empty_return' => true,
'native_function_invocation' => [
'include' => ['@compiler_optimized'],
'scope' => 'namespaced',
'strict' => true,
],
'native_constant_invocation' => true,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => true,
],
'final_class' => true,
'class_definition' => [
'single_line' => true,
],
'concat_space' => [
'spacing' => 'one',
],
'single_line_throw' => false,
// NO Yoda conditions
'yoda_style' => false,
'blank_line_before_statement' => [
'statements' => ['return', 'throw', 'try'],
],
])
->setRiskyAllowed(true)
->setFinder($finder)
;

107
backend/Dockerfile Normal file
View File

@@ -0,0 +1,107 @@
# syntax=docker/dockerfile:1
# =============================================================================
# PHP 8.5 + FrankenPHP - Backend Classeo
# =============================================================================
FROM dunglas/frankenphp:1-php8.5-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
acl \
fcgi \
file \
gettext \
git \
icu-dev \
libzip-dev \
postgresql-dev \
rabbitmq-c-dev \
linux-headers \
$PHPIZE_DEPS
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
RUN docker-php-ext-install intl pdo_pgsql zip sockets
# Install AMQP extension for RabbitMQ
RUN pecl install amqp && docker-php-ext-enable amqp
# Install Redis extension
RUN pecl install redis && docker-php-ext-enable redis
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /app
# Configure PHP for production
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Custom PHP configuration
RUN echo "opcache.enable=1" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "opcache.memory_consumption=256" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "opcache.interned_strings_buffer=16" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "opcache.max_accelerated_files=20000" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "opcache.validate_timestamps=0" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "realpath_cache_size=4096K" >> "$PHP_INI_DIR/conf.d/opcache.ini" \
&& echo "realpath_cache_ttl=600" >> "$PHP_INI_DIR/conf.d/opcache.ini"
# =============================================================================
# Development stage
# =============================================================================
FROM base AS dev
# Enable opcache revalidation for dev (zz- prefix loads last alphabetically)
RUN echo "opcache.validate_timestamps=1" >> "$PHP_INI_DIR/conf.d/zz-opcache-dev.ini"
# Enable Xdebug for development
RUN pecl install xdebug && docker-php-ext-enable xdebug
RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini" \
&& echo "xdebug.client_host=host.docker.internal" >> "$PHP_INI_DIR/conf.d/xdebug.ini" \
&& echo "xdebug.start_with_request=trigger" >> "$PHP_INI_DIR/conf.d/xdebug.ini"
# Caddy config for FrankenPHP
ENV SERVER_NAME=:8000
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
# Create entrypoint script for dev (installs deps if needed)
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'if [ ! -f /app/vendor/autoload.php ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
echo ' echo "Installing Composer dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
echo ' composer install --prefer-dist --no-progress --no-interaction' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'mkdir -p var/cache var/log && chmod -R 777 var' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
# =============================================================================
# Production stage
# =============================================================================
FROM base AS prod
ENV APP_ENV=prod
ENV SERVER_NAME=:8000
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
# Copy application files
COPY . /app
# Install dependencies (prod only, optimized)
RUN composer install --no-dev --prefer-dist --no-progress --no-interaction --optimize-autoloader
# Warmup cache
RUN php bin/console cache:warmup
# Ensure var directory exists with proper permissions
RUN mkdir -p var/cache var/log && chmod -R 755 var
EXPOSE 8000
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]

19
backend/bin/console Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_file(dirname(__DIR__) . '/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
return static function (array $context): Application {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

109
backend/composer.json Normal file
View File

@@ -0,0 +1,109 @@
{
"name": "classeo/backend",
"description": "Classeo - Backend API (Symfony 8 + API Platform)",
"type": "project",
"license": "proprietary",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.5",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-intl": "*",
"api-platform/core": "^4.0",
"doctrine/dbal": "^4.0",
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2",
"ramsey/uuid": "^4.7",
"symfony/amqp-messenger": "^8.0",
"symfony/asset": "^8.0",
"symfony/console": "^8.0",
"symfony/doctrine-messenger": "^8.0",
"symfony/dotenv": "^8.0",
"symfony/flex": "^2",
"symfony/framework-bundle": "^8.0",
"symfony/messenger": "^8.0",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "^8.0",
"symfony/property-info": "^8.0",
"symfony/runtime": "^8.0",
"symfony/security-bundle": "^8.0",
"symfony/serializer": "^8.0",
"symfony/twig-bundle": "^8.0",
"symfony/uid": "^8.0",
"symfony/validator": "^8.0",
"symfony/yaml": "^8.0"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^11.0",
"symfony/browser-kit": "^8.0",
"symfony/css-selector": "^8.0",
"symfony/debug-bundle": "^8.0",
"symfony/maker-bundle": "^1.62",
"symfony/phpunit-bridge": "^8.0",
"symfony/stopwatch": "^8.0",
"symfony/web-profiler-bundle": "^8.0",
"friendsofphp/php-cs-fixer": "^3.65"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
],
"test": "phpunit",
"phpstan": "phpstan analyse --memory-limit=512M",
"cs-fix": "php-cs-fixer fix",
"cs-check": "php-cs-fixer fix --dry-run --diff"
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "8.0.*"
}
}
}

View File

@@ -0,0 +1,31 @@
api_platform:
title: 'Classeo API'
description: 'API for Classeo - School Management System'
version: '1.0.0'
# Enable OpenAPI documentation
formats:
jsonld: ['application/ld+json']
json: ['application/json']
html: ['text/html']
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
# Defaults
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
extra_properties:
standard_put: true
rfc_7807_compliant_errors: true
pagination_items_per_page: 30
# Pagination
collection:
pagination:
enabled: true
items_per_page_parameter_name: 'itemsPerPage'

View File

@@ -0,0 +1,13 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: classeo/backend
when@prod:
framework:
cache:
pools:
doctrine.system_cache_pool:
adapter: cache.adapter.system
doctrine.result_cache_pool:
adapter: cache.adapter.system

View File

@@ -0,0 +1,52 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
# Infrastructure mappings - keep entities separate from Domain
Administration:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Administration/Infrastructure/Persistence/Mapping'
prefix: 'App\Administration\Infrastructure\Persistence\Mapping'
alias: Administration
Scolarite:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Scolarite/Infrastructure/Persistence/Mapping'
prefix: 'App\Scolarite\Infrastructure\Persistence\Mapping'
alias: Scolarite
VieScolaire:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/VieScolaire/Infrastructure/Persistence/Mapping'
prefix: 'App\VieScolaire\Infrastructure\Persistence\Mapping'
alias: VieScolaire
Communication:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Communication/Infrastructure/Persistence/Mapping'
prefix: 'App\Communication\Infrastructure\Persistence\Mapping'
alias: Communication
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool

View File

@@ -0,0 +1,8 @@
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false
organize_migrations: none
all_or_nothing: true
transactional: true
check_database_platform: true

View File

@@ -0,0 +1,25 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
csrf_protection: true
handle_all_throwables: true
http_method_override: false
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_hosts: '%env(TRUSTED_HOSTS)%'
# Enables session support
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
# Enable php_attributes routing
php_errors:
log: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,17 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
user_id_claim: username
clock_skew: 0
# Automatically extracts the token from cookies
token_extractors:
authorization_header:
enabled: true
prefix: Bearer
name: Authorization
cookie:
enabled: true
name: BEARER

View File

@@ -0,0 +1,44 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
failure_transport: failed
# Three buses: Command, Query, Event (CQRS + Event-driven)
default_bus: command.bus
buses:
command.bus:
default_middleware: true
middleware:
- doctrine_transaction
query.bus:
default_middleware: true
event.bus:
default_middleware:
allow_no_handlers: true
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
exchange:
name: classeo_messages
type: topic
queues:
messages:
binding_keys: ['#']
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 60000
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -0,0 +1,57 @@
monolog:
channels:
- deprecation
- security
- audit
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@@ -0,0 +1,41 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
# used to reload user from session & other features (e.g. switch_user)
# Configure user provider when User entity is created
users_in_memory:
memory:
users:
admin: { password: 'admin', roles: ['ROLE_ADMIN'] }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~
main:
lazy: true
provider: users_in_memory
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -0,0 +1,8 @@
framework:
validation:
email_validation_mode: html5
when@test:
framework:
validation:
not_compromised_password: false

View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/
namespace: App\
type: attribute

View File

@@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@@ -0,0 +1,27 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in this file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Exclude Domain layers - they should be pure PHP with no framework deps
- '../src/*/Domain/'
# Domain services need to be registered explicitly to avoid framework coupling
# Example: App\Administration\Application\Command\:
# resource: '../src/Administration/Application/Command/'

12
backend/phpstan.neon Normal file
View File

@@ -0,0 +1,12 @@
parameters:
level: 9
paths:
- src
excludePaths:
- src/Kernel.php
treatPhpDocTypesAsCertain: false
reportUnmatchedIgnoredErrors: false
includes:
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/extension.neon

11
backend/public/index.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
use App\Kernel;
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
return static function (array $context): Kernel {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

13
backend/src/Kernel.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

View File

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
abstract class AggregateRoot
{
/** @var DomainEvent[] */
private array $domainEvents = [];
protected function recordEvent(DomainEvent $event): void
{
$this->domainEvents[] = $event;
}
/** @return DomainEvent[] */
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
use DateTimeImmutable;
interface Clock
{
public function now(): DateTimeImmutable;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
use Ramsey\Uuid\Uuid;
final readonly class CorrelationId
{
private function __construct(
private string $value,
) {
}
public static function generate(): self
{
return new self(Uuid::uuid4()->toString());
}
public static function fromString(string $value): self
{
return new self($value);
}
public function value(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
use DateTimeImmutable;
use Ramsey\Uuid\UuidInterface;
interface DomainEvent
{
public function occurredOn(): DateTimeImmutable;
public function aggregateId(): UuidInterface;
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
/**
* @phpstan-consistent-constructor
*/
abstract readonly class EntityId
{
protected function __construct(
public UuidInterface $value,
) {
}
public static function generate(): static
{
return new static(Uuid::uuid4());
}
public static function fromString(string $value): static
{
return new static(Uuid::fromString($value));
}
public function equals(EntityId $other): bool
{
return $this->value->equals($other->value);
}
public function __toString(): string
{
return $this->value->toString();
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Clock;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
final readonly class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Domain;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final class AggregateRootTest extends TestCase
{
public function testPullDomainEventsReturnsRecordedEvents(): void
{
$aggregate = new TestAggregate();
$event1 = new TestDomainEvent('test1');
$event2 = new TestDomainEvent('test2');
$aggregate->doSomething($event1);
$aggregate->doSomething($event2);
$events = $aggregate->pullDomainEvents();
$this->assertCount(2, $events);
$this->assertSame($event1, $events[0]);
$this->assertSame($event2, $events[1]);
}
public function testPullDomainEventsClearsEventsAfterPulling(): void
{
$aggregate = new TestAggregate();
$event = new TestDomainEvent('test');
$aggregate->doSomething($event);
$aggregate->pullDomainEvents();
$secondPull = $aggregate->pullDomainEvents();
$this->assertCount(0, $secondPull);
}
public function testRecordEventAddsEventToInternalList(): void
{
$aggregate = new TestAggregate();
$event = new TestDomainEvent('test');
$aggregate->doSomething($event);
$events = $aggregate->pullDomainEvents();
$this->assertCount(1, $events);
$this->assertInstanceOf(TestDomainEvent::class, $events[0]);
}
}
// Test implementations
final class TestAggregate extends AggregateRoot
{
public function doSomething(DomainEvent $event): void
{
$this->recordEvent($event);
}
}
final readonly class TestDomainEvent implements DomainEvent
{
private DateTimeImmutable $occurredOn;
public function __construct(
public string $data,
private ?UuidInterface $testAggregateId = null,
) {
$this->occurredOn = new DateTimeImmutable();
}
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
public function aggregateId(): UuidInterface
{
return $this->testAggregateId ?? Uuid::uuid4();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Domain;
use App\Shared\Domain\CorrelationId;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
final class CorrelationIdTest extends TestCase
{
public function testGenerateCreatesValidUuid(): void
{
$correlationId = CorrelationId::generate();
$this->assertTrue(Uuid::isValid($correlationId->value()));
}
public function testFromStringCreatesCorrelationIdFromValidUuid(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$correlationId = CorrelationId::fromString($uuid);
$this->assertSame($uuid, $correlationId->value());
}
public function testValueReturnsUuidString(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$correlationId = CorrelationId::fromString($uuid);
$this->assertSame($uuid, $correlationId->value());
}
public function testToStringReturnsUuidString(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$correlationId = CorrelationId::fromString($uuid);
$this->assertSame($uuid, (string) $correlationId);
}
public function testGenerateCreatesDifferentIdsEachTime(): void
{
$id1 = CorrelationId::generate();
$id2 = CorrelationId::generate();
$this->assertNotSame($id1->value(), $id2->value());
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Domain;
use App\Shared\Domain\EntityId;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
final class EntityIdTest extends TestCase
{
public function testGenerateCreatesValidUuid(): void
{
$id = TestEntityId::generate();
$this->assertInstanceOf(TestEntityId::class, $id);
$this->assertTrue(Uuid::isValid((string) $id));
}
public function testFromStringCreatesEntityIdFromValidUuid(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id = TestEntityId::fromString($uuid);
$this->assertSame($uuid, (string) $id);
}
public function testEqualsReturnsTrueForSameValue(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id1 = TestEntityId::fromString($uuid);
$id2 = TestEntityId::fromString($uuid);
$this->assertTrue($id1->equals($id2));
}
public function testEqualsReturnsFalseForDifferentValue(): void
{
$id1 = TestEntityId::generate();
$id2 = TestEntityId::generate();
$this->assertFalse($id1->equals($id2));
}
public function testToStringReturnsUuidString(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id = TestEntityId::fromString($uuid);
$this->assertSame($uuid, (string) $id);
}
}
// Test concrete implementation
final readonly class TestEntityId extends EntityId
{
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/config/bootstrap.php')) {
require dirname(__DIR__) . '/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}