├── CODEOWNERS ├── tests ├── Integration │ ├── Fixtures │ │ ├── config │ │ │ └── test_config.yaml │ │ ├── Domain │ │ │ ├── Event │ │ │ │ ├── UserUpdatedEvent.php │ │ │ │ └── UserStateChangedEvent.php │ │ │ └── Model │ │ │ │ └── User.php │ │ └── TestKernel.php │ ├── GeekCellMakerTestKernel.php │ └── Domain │ │ └── AggregateRootTest.php └── Unit │ ├── Support │ └── Traits │ │ └── DispatchableTraitTest.php │ ├── Infrastructure │ ├── Messenger │ │ ├── CommandBusTest.php │ │ └── QueryBusTest.php │ └── Doctrine │ │ ├── Type │ │ ├── AbstractIdTypeTest.php │ │ └── AbstractUuidTypeTest.php │ │ └── PaginatorTest.php │ ├── Maker │ └── PathGeneratorTest.php │ └── Domain │ └── AggregateRootTest.php ├── .gitignore ├── src ├── Resources │ └── skeleton │ │ ├── resource │ │ ├── ApiPlatformConfig.tpl.php │ │ ├── PropertiesXmlConfig.tpl.php │ │ ├── Processor.tpl.php │ │ ├── Provider.tpl.php │ │ ├── ResourceXmlConfig.tpl.php │ │ └── Resource.tpl.php │ │ ├── controller │ │ ├── RouteConfig.tpl.php │ │ └── Controller.tpl.php │ │ ├── query │ │ ├── Query.tpl.php │ │ └── QueryHandler.tpl.php │ │ ├── command │ │ ├── Command.tpl.php │ │ └── CommandHandler.tpl.php │ │ ├── model │ │ ├── Identity.tpl.php │ │ ├── RepositoryInterface.tpl.php │ │ ├── DoctrineMappingType.tpl.php │ │ ├── Repository.tpl.php │ │ └── Model.tpl.php │ │ └── doctrine │ │ └── Mapping.tpl.xml.php ├── Support │ ├── Facades │ │ └── EventDispatcher.php │ └── Traits │ │ └── DispatchableTrait.php ├── Domain │ └── AggregateRoot.php ├── Maker │ ├── ApiPlatform │ │ └── ApiPlatformConfigUpdater.php │ ├── AbstractBaseConfigUpdater.php │ ├── PathGenerator.php │ ├── MakeCommand.php │ ├── MakeQuery.php │ ├── Doctrine │ │ └── DoctrineConfigUpdater.php │ ├── AbstractBaseMakerCQRS.php │ ├── MakeController.php │ ├── MakeResource.php │ └── MakeModel.php ├── DependencyInjection │ └── GeekCellDddExtension.php ├── GeekCellDddBundle.php └── Infrastructure │ ├── Messenger │ ├── QueryBus.php │ └── CommandBus.php │ └── Doctrine │ ├── Type │ ├── AbstractIdType.php │ └── AbstractUuidType.php │ ├── Paginator.php │ └── Repository.php ├── sonar-project.properties ├── phpstan.neon ├── .github └── workflows │ ├── release-please.yml │ └── test.yml ├── .editorconfig ├── .php-cs-fixer.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── config └── services.yaml ├── CHANGELOG.md └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # global owners 2 | * @ckappen 3 | -------------------------------------------------------------------------------- /tests/Integration/Fixtures/config/test_config.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /vendor/ 3 | 4 | /.php-cs-fixer.cache 5 | /composer.phar 6 | /.idea 7 | -------------------------------------------------------------------------------- /src/Resources/skeleton/resource/ApiPlatformConfig.tpl.php: -------------------------------------------------------------------------------- 1 | api_platform: 2 | mapping: 3 | paths: 4 | - '' 5 | -------------------------------------------------------------------------------- /src/Resources/skeleton/controller/RouteConfig.tpl.php: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: 3 | path: 4 | namespace: 5 | type: attribute 6 | -------------------------------------------------------------------------------- /src/Resources/skeleton/query/Query.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class implements Query 8 | { 9 | } 10 | -------------------------------------------------------------------------------- /src/Resources/skeleton/command/Command.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class implements Command 8 | { 9 | } 10 | -------------------------------------------------------------------------------- /src/Resources/skeleton/model/Identity.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class extends 8 | { 9 | } 10 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=geekcell_ddd-symfony-bundle 2 | sonar.organization=geekcell 3 | 4 | sonar.sources=src 5 | sonar.exclusions=tests/**, src/Maker/Make*, src/Maker/AbstractBaseMakerCQRS.php 6 | sonar.tests=tests 7 | sonar.php.coverage.reportPaths=reports/coverage.xml 8 | -------------------------------------------------------------------------------- /src/Resources/skeleton/resource/PropertiesXmlConfig.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/Resources/skeleton/command/CommandHandler.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | #[AsMessageHandler] 8 | class implements CommandHandler 9 | { 10 | public function __invoke( $query): void 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Resources/skeleton/query/QueryHandler.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | #[AsMessageHandler] 8 | class implements QueryHandler 9 | { 10 | public function __invoke( $query): Collection 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Resources/skeleton/model/RepositoryInterface.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | interface extends Repository 8 | { 9 | 10 | public function findById( $id): ?; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-mockery/extension.neon 3 | - vendor/phpstan/phpstan-beberlei-assert/extension.neon 4 | 5 | parameters: 6 | level: max 7 | ignoreErrors: 8 | - identifier: missingType.generics 9 | paths: 10 | - src 11 | - tests 12 | excludePaths: 13 | - src/Resources/skeleton 14 | - vendor/ 15 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release-please 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: google-github-actions/release-please-action@v3 11 | with: 12 | release-type: php 13 | package-name: release-please-action 14 | -------------------------------------------------------------------------------- /src/Support/Facades/EventDispatcher.php: -------------------------------------------------------------------------------- 1 | exclude([ 5 | 'src/Resources/skeleton', 6 | 'vendor' 7 | ]) 8 | ->in(__DIR__) 9 | ; 10 | 11 | $config = new PhpCsFixer\Config(); 12 | return $config 13 | ->setRules([ 14 | '@PSR12' => true, 15 | 'strict_param' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | ]) 18 | ->setRiskyAllowed(true) 19 | ->setFinder($finder); 20 | -------------------------------------------------------------------------------- /tests/Integration/Fixtures/Domain/Event/UserUpdatedEvent.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class implements ProcessorInterface 8 | { 9 | public function __construct( 10 | private readonly CommandBus $commandBus 11 | ) {} 12 | 13 | /** 14 | * @inheritDoc 15 | */ 16 | public function process($data, Operation $operation, array $uriVariables = [], array $context = []): void 17 | { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Resources/skeleton/model/DoctrineMappingType.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class extends 8 | { 9 | public const NAME = ''; 10 | 11 | public function getName(): string 12 | { 13 | return self::NAME; 14 | } 15 | 16 | protected function getIdType(): string 17 | { 18 | return ::class; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Integration/Fixtures/Domain/Event/UserStateChangedEvent.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class implements ProviderInterface 8 | { 9 | public function __construct( 10 | private readonly QueryBus $queryBus 11 | ) {} 12 | 13 | /** 14 | * @inheritDoc 15 | */ 16 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null 17 | { 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Resources/skeleton/controller/Controller.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class extends AbstractController 8 | { 9 | 0): ?> 10 | public function __construct() 11 | {} 12 | 13 | 14 | #[Route('/', name: '')] 15 | public function (): Response 16 | { 17 | return new Response(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Resources/skeleton/resource/ResourceXmlConfig.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /src/Domain/AggregateRoot.php: -------------------------------------------------------------------------------- 1 | releaseEvents() as $event) { 22 | $this->dispatch($event); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Resources/skeleton/doctrine/Mapping.tpl.xml.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | unit-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: php-actions/composer@v6 19 | - name: PHPUnit Tests 20 | uses: php-actions/phpunit@v3 21 | with: 22 | version: 11.5 23 | php_version: 8.3 24 | test_suffix: Test.php 25 | configuration: phpunit.xml 26 | -------------------------------------------------------------------------------- /src/Maker/ApiPlatform/ApiPlatformConfigUpdater.php: -------------------------------------------------------------------------------- 1 | read($yamlSource); 15 | 16 | if (isset($data['api_platform']['mapping']) && is_array($data['api_platform']['mapping'])) { 17 | $currentPaths = $data['api_platform']['mapping']['paths'] ?? []; 18 | $data['api_platform']['mapping']['paths'] = array_unique(array_merge($currentPaths, [$path])); 19 | } 20 | 21 | return $this->write($data); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/DependencyInjection/GeekCellDddExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yaml'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Resources/skeleton/model/Repository.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | class extends OrmRepository implements 8 | { 9 | 10 | public function findById( $id): ? 11 | { 12 | // TODO: Implement me! 13 | 14 | return null; 15 | } 16 | 17 | 18 | // public function findByExampleField($value): self 19 | // { 20 | // return $this->filter(function(QueryBuilder $queryBuilder) use ($value) { 21 | // $queryBuilder 22 | // ->andWhere('t.exampleField = :val') 23 | // ->setParameter('val', $value) 24 | // ->orderBy('t.id', 'ASC') 25 | // ; 26 | // }); 27 | // } 28 | } 29 | -------------------------------------------------------------------------------- /src/GeekCellDddBundle.php: -------------------------------------------------------------------------------- 1 | container instanceof ContainerInterface) { 38 | Facade::setContainer($this->container); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | src 23 | 24 | 25 | src/Resources/skeleton 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/Integration/Fixtures/TestKernel.php: -------------------------------------------------------------------------------- 1 | load(__DIR__.'/config/test_config.yaml'); 26 | } 27 | 28 | public function getCacheDir(): string 29 | { 30 | return sys_get_temp_dir().'/GeekCellDddBundleTests/cache'; 31 | } 32 | 33 | public function shutdown(): void 34 | { 35 | parent::shutdown(); 36 | (new Filesystem())->remove($this->getCacheDir()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Geek Cell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Infrastructure/Messenger/QueryBus.php: -------------------------------------------------------------------------------- 1 | messageBus = $messageBus; 20 | } 21 | 22 | public function dispatch(Query $query): mixed 23 | { 24 | try { 25 | return $this->handle($query); 26 | } catch (HandlerFailedException $handlerFailedException) { 27 | $exceptions = $handlerFailedException->getWrappedExceptions(); 28 | $first = array_shift($exceptions); 29 | if ($first) { 30 | throw $first; 31 | } 32 | 33 | throw $handlerFailedException; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Maker/AbstractBaseConfigUpdater.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected function read(string $yamlSource): array 22 | { 23 | $this->manipulator = new YamlSourceManipulator($yamlSource); 24 | return $this->manipulator->getData(); 25 | } 26 | 27 | /** 28 | * Returns the updated YAML contents for the given data. 29 | * 30 | * @param array $yamlData 31 | * @throws AssertionFailedException 32 | */ 33 | protected function write(array $yamlData): string 34 | { 35 | Assertion::notNull($this->manipulator); 36 | $this->manipulator->setData($yamlData); 37 | 38 | return $this->manipulator->getContents(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Infrastructure/Messenger/CommandBus.php: -------------------------------------------------------------------------------- 1 | messageBus = $messageBus; 20 | } 21 | 22 | public function dispatch(Command $command): mixed 23 | { 24 | try { 25 | return $this->handle($command); 26 | } catch (HandlerFailedException $handlerFailedException) { 27 | $exceptions = $handlerFailedException->getWrappedExceptions(); 28 | $first = array_shift($exceptions); 29 | if ($first) { 30 | throw $first; 31 | } 32 | 33 | throw $handlerFailedException; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Resources/skeleton/resource/Resource.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | 8 | #[ApiResource( 9 | provider: ::class, 10 | processor: ::class, 11 | )] 12 | 13 | final class 14 | { 15 | public function __construct( 16 | 17 | #[ApiProperty(identifier: true)] 18 | 19 | 20 | public string $uuid, 21 | 22 | public int $id, 23 | 24 | // TODO: Add more properties ... 25 | ) {} 26 | 27 | /** 28 | * Convenience factory method to create the resource from an instance of the model 29 | * 30 | * @param $model 31 | * 32 | * @return static 33 | */ 34 | public static function create( $model): static 35 | { 36 | return new static( 37 | 38 | strval($model->getUuid()), 39 | 40 | intval($model->getId()), 41 | 42 | // TODO: Initialize further ... 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Maker/PathGenerator.php: -------------------------------------------------------------------------------- 1 | endsWith('/')) { 16 | $basePath .= '/'; 17 | } 18 | 19 | $this->basePath = u($basePath)->trimPrefix(self::DEFAULT_BASE_PATH); 20 | } 21 | 22 | public function namespacePrefix(string $namespacePrefix): string 23 | { 24 | if ($this->basePath !== '' && $this->basePath !== '0') { 25 | return $this->toNamespace($this->basePath) . $namespacePrefix; 26 | } 27 | 28 | return $namespacePrefix; 29 | } 30 | 31 | public function path(string $prefix, string $suffix): string 32 | { 33 | $prefix = u($prefix)->trimSuffix('/'); 34 | $suffix = u($suffix)->trimPrefix('/'); 35 | 36 | if ($this->basePath !== '' && $this->basePath !== '0') { 37 | return $prefix . '/' . $this->basePath . $suffix; 38 | } 39 | 40 | return $prefix . '/' . $suffix; 41 | } 42 | 43 | private function toNamespace(string $basePath): string 44 | { 45 | return u($basePath)->replace('/', '\\'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Infrastructure/Doctrine/Type/AbstractIdType.php: -------------------------------------------------------------------------------- 1 | getName(), 28 | [Id::class], 29 | ); 30 | } 31 | 32 | return intval($value->getValue()); 33 | } 34 | 35 | public function convertToPHPValue($value, AbstractPlatform $platform): ?Id 36 | { 37 | if ($value === null) { 38 | return null; 39 | } 40 | 41 | $idType = $this->getIdType(); 42 | 43 | return new $idType($value); 44 | } 45 | 46 | /** 47 | * @return class-string 48 | */ 49 | abstract protected function getIdType(): string; 50 | } 51 | -------------------------------------------------------------------------------- /src/Resources/skeleton/model/Model.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | 8 | #[ORM\Entity] 9 | 10 | class extends AggregateRoot 11 | { 12 | 13 | /** 14 | * @var 15 | */ 16 | 17 | #[ORM\Id] 18 | #[ORM\Column(type: ::NAME)] 19 | 20 | private $; 21 | 22 | 23 | public function __construct() 24 | { 25 | 26 | $this->id = new (1); 27 | 28 | $this->uuid = ::random(); 29 | 30 | } 31 | 32 | 33 | /** 34 | * Get 35 | * 36 | * @return 37 | */ 38 | public function get(): 39 | { 40 | return $this->; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Maker/MakeCommand.php: -------------------------------------------------------------------------------- 1 | getName(), 31 | [Uuid::class], 32 | ); 33 | } 34 | 35 | return strval($value); 36 | } 37 | 38 | /** 39 | * @inheritDoc 40 | */ 41 | public function convertToPHPValue($value, AbstractPlatform $platform): ?Uuid 42 | { 43 | if ($value === null) { 44 | return null; 45 | } 46 | 47 | $idType = $this->getIdType(); 48 | 49 | return new $idType($value); 50 | } 51 | 52 | /** 53 | * @return class-string 54 | */ 55 | abstract protected function getIdType(): string; 56 | } 57 | -------------------------------------------------------------------------------- /tests/Integration/Fixtures/Domain/Model/User.php: -------------------------------------------------------------------------------- 1 | username = $username; 23 | $this->record(new UserUpdatedEvent($this)); 24 | } 25 | 26 | public function getUsername(): string 27 | { 28 | return $this->username; 29 | } 30 | 31 | public function setEmail(string $email): void 32 | { 33 | $this->email = $email; 34 | $this->record(new UserUpdatedEvent($this)); 35 | } 36 | 37 | public function getEmail(): string 38 | { 39 | return $this->email; 40 | } 41 | 42 | public function activate(): void 43 | { 44 | $this->isActivated = true; 45 | $this->record(new UserStateChangedEvent($this, $this->isActivated)); 46 | } 47 | 48 | public function deactivate(): void 49 | { 50 | $this->isActivated = false; 51 | $this->record(new UserStateChangedEvent($this, $this->isActivated)); 52 | } 53 | 54 | public function isActivated(): bool 55 | { 56 | return $this->isActivated; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Support/Traits/DispatchableTrait.php: -------------------------------------------------------------------------------- 1 | getEventDispatcher()->dispatch($event); 31 | } 32 | 33 | /** 34 | * @codeCoverageIgnore 35 | */ 36 | public function setEventDispatcher( 37 | EventDispatcherInterface $eventDispatcher 38 | ): void { 39 | $this->eventDispatcher = $eventDispatcher; 40 | } 41 | 42 | /** 43 | * @throws ContainerExceptionInterface 44 | * @throws NotFoundExceptionInterface 45 | */ 46 | public function getEventDispatcher(): EventDispatcherInterface 47 | { 48 | if (!isset($this->eventDispatcher)) { 49 | $eventDispatcher = EventDispatcher::getFacadeRoot(); 50 | 51 | /** @var EventDispatcherInterface $eventDispatcher */ 52 | $this->eventDispatcher = $eventDispatcher; 53 | } 54 | 55 | return $this->eventDispatcher; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geekcell/ddd-bundle", 3 | "description": "A bundle for pragmatic domain driven design in Symfony.", 4 | "type": "symfony-bundle", 5 | "version": "1.4.3", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Pascal Cremer", 10 | "email": "pascal.cremer@geekcell.io" 11 | } 12 | ], 13 | "require": { 14 | "doctrine/orm": "^2.20 | ^3.0", 15 | "geekcell/container-facade": "^1.0", 16 | "geekcell/ddd": "^1.6.0", 17 | "symfony/config": "^6.0 | ^7.0", 18 | "symfony/dependency-injection": "^6.0 | ^7.0", 19 | "symfony/event-dispatcher": "^6.0 | ^7.0", 20 | "symfony/http-kernel": "^6.0 | ^7.0", 21 | "symfony/messenger": "^6.0 | ^7.0", 22 | "symfony/string": "^6.0 | ^7.0" 23 | }, 24 | "require-dev": { 25 | "friendsofphp/php-cs-fixer": "^3.68", 26 | "mockery/mockery": "^1.6", 27 | "phpstan/phpstan": "^2.0", 28 | "phpstan/phpstan-mockery": "^2.0", 29 | "phpstan/phpstan-beberlei-assert": "^2.0", 30 | "phpunit/phpunit": "^11.0 | ^12.0", 31 | "symfony/framework-bundle": "^6.0 | ^7.0", 32 | "symfony/yaml": "^6.0 | ^7.0", 33 | "symfony/filesystem": "^6.0 | ^7.0", 34 | "symfony/maker-bundle": "^1.62" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "GeekCell\\DddBundle\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "GeekCell\\DddBundle\\Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "gc:tests": "phpunit --testdox --colors=always", 48 | "gc:cs-lint": "php-cs-fixer fix --config .php-cs-fixer.php --diff -vvv --dry-run", 49 | "gc:cs-fix": "php-cs-fixer fix --config .php-cs-fixer.php -vvv || true", 50 | "gc:phpstan": "phpstan analyse --memory-limit=2G --no-progress --level=8" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | GeekCell\Ddd\Contracts\Application\CommandBus: 3 | class: GeekCell\DddBundle\Infrastructure\Messenger\CommandBus 4 | arguments: 5 | - '@Symfony\Component\Messenger\MessageBusInterface' 6 | public: true 7 | 8 | GeekCell\Ddd\Contracts\Application\QueryBus: 9 | class: GeekCell\DddBundle\Infrastructure\Messenger\QueryBus 10 | arguments: 11 | - '@Symfony\Component\Messenger\MessageBusInterface' 12 | public: true 13 | 14 | GeekCell\DddBundle\Maker\MakeModel: 15 | class: GeekCell\DddBundle\Maker\MakeModel 16 | arguments: 17 | - '@GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater' 18 | - '@maker.file_manager' 19 | tags: 20 | - { name: maker.command } 21 | 22 | GeekCell\DddBundle\Maker\MakeQuery: 23 | class: GeekCell\DddBundle\Maker\MakeQuery 24 | tags: 25 | - { name: maker.command } 26 | 27 | GeekCell\DddBundle\Maker\MakeCommand: 28 | class: GeekCell\DddBundle\Maker\MakeCommand 29 | tags: 30 | - { name: maker.command } 31 | 32 | GeekCell\DddBundle\Maker\MakeController: 33 | class: GeekCell\DddBundle\Maker\MakeController 34 | arguments: 35 | - '@maker.file_manager' 36 | tags: 37 | - { name: maker.command } 38 | 39 | GeekCell\DddBundle\Maker\MakeResource: 40 | class: GeekCell\DddBundle\Maker\MakeResource 41 | arguments: 42 | - '@maker.file_manager' 43 | - '@GeekCell\DddBundle\Maker\Doctrine\ApiPlatformConfigUpdator' 44 | tags: 45 | - { name: maker.command } 46 | 47 | GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater: 48 | class: GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater 49 | public: false 50 | 51 | GeekCell\DddBundle\Maker\Doctrine\ApiPlatformConfigUpdator: 52 | class: GeekCell\DddBundle\Maker\ApiPlatform\ApiPlatformConfigUpdater 53 | public: false 54 | -------------------------------------------------------------------------------- /tests/Unit/Support/Traits/DispatchableTraitTest.php: -------------------------------------------------------------------------------- 1 | container = new Container(); 30 | EventDispatcherFacade::setContainer($this->container); 31 | } 32 | 33 | protected function tearDown(): void 34 | { 35 | parent::tearDown(); 36 | EventDispatcherFacade::clear(); 37 | } 38 | 39 | public function testGetEventDispatcher(): void 40 | { 41 | // Given 42 | $dispatcher = new EventDispatcher(); 43 | $this->container->set(self::SERVICE_ID, $dispatcher); 44 | 45 | // When 46 | $result = $this->getEventDispatcher(); 47 | 48 | // Then 49 | $this->assertSame($dispatcher, $result); 50 | } 51 | 52 | public function testDispatch(): void 53 | { 54 | // Given 55 | $this->container->set(self::SERVICE_ID, new EventDispatcher()); 56 | 57 | $event = new class () implements DomainEvent {}; 58 | 59 | /** @var Mockery\MockInterface $dispatcherMock */ 60 | $dispatcherMock = EventDispatcherFacade::swapMock(); 61 | $dispatcherMock 62 | ->shouldReceive('dispatch') 63 | ->once() 64 | ->with($event) 65 | ; 66 | 67 | // When 68 | $this->dispatch($event); 69 | 70 | // Then 71 | $this->addToAssertionCount(1); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Infrastructure/Doctrine/Paginator.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class Paginator implements PaginatorInterface 17 | { 18 | private readonly int $firstResult; 19 | 20 | private readonly int $maxResults; 21 | 22 | /** 23 | * @param OrmPaginator $ormPaginator 24 | */ 25 | public function __construct( 26 | private readonly OrmPaginator $ormPaginator, 27 | ) { 28 | $query = $this->ormPaginator->getQuery(); 29 | $firstResult = $query->getFirstResult(); 30 | $maxResults = $query->getMaxResults(); 31 | 32 | Assert::that($maxResults)->notNull('Max results is not set'); 33 | 34 | $this->firstResult = $firstResult; 35 | 36 | /** @var int $maxResults */ 37 | $this->maxResults = $maxResults; 38 | } 39 | 40 | /** 41 | * @inheritDoc 42 | */ 43 | public function getCurrentPage(): int 44 | { 45 | if (0 === $this->firstResult) { 46 | return 1; 47 | } 48 | 49 | return (int) ceil($this->firstResult / $this->maxResults); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public function getTotalPages(): int 56 | { 57 | return (int) ceil($this->getTotalItems() / $this->maxResults); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function getItemsPerPage(): int 64 | { 65 | return $this->maxResults; 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function getTotalItems(): int 72 | { 73 | return $this->count(); 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function count(): int 80 | { 81 | return count($this->ormPaginator); 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function getIterator(): Traversable 88 | { 89 | return $this->ormPaginator->getIterator(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Maker/Doctrine/DoctrineConfigUpdater.php: -------------------------------------------------------------------------------- 1 | read($yamlSource); 25 | if (isset($data['doctrine']['dbal']['types']) && is_array($data['doctrine']['dbal']['types'])) { 26 | $data['doctrine']['dbal']['types'][$identifier] = $mappingClass; 27 | } 28 | 29 | return $this->write($data); 30 | } 31 | 32 | /** 33 | * Updates the default entity mapping configuration. 34 | * 35 | * @param string $yamlSource The contents of current doctrine.yaml 36 | * @param 'xml'|'attribute' $mappingType The type of the mapping (xml or annotation) 37 | * @param string $directory The directory where the mapping files are located 38 | * 39 | * @return string The updated doctrine.yaml contents 40 | */ 41 | public function updateORMDefaultEntityMapping(string $yamlSource, string $mappingType, string $directory): string 42 | { 43 | Assertion::inArray($mappingType, ['xml', 'attribute'], 'Invalid mapping type: %s'); 44 | 45 | $data = $this->read($yamlSource); 46 | $config = [ 47 | 'type' => $mappingType, 48 | 'dir' => $directory, 49 | 'prefix' => 'App\Domain\Model', 50 | 'alias' => 'App', 51 | 'is_bundle' => false, 52 | ]; 53 | 54 | if (isset($data['doctrine']['orm']['mappings']['App']) && is_array($data['doctrine']['orm']['mappings']['App'])) { 55 | $data['doctrine']['orm']['mappings']['App'] = $config; 56 | } 57 | 58 | return $this->write($data); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Unit/Infrastructure/Messenger/CommandBusTest.php: -------------------------------------------------------------------------------- 1 | exceptionToThrow; 36 | } 37 | } 38 | 39 | class CommandBusTest extends TestCase 40 | { 41 | public function testDispatch(): void 42 | { 43 | // Given 44 | $bus = $this->createMessageBus( 45 | TestCommand::class, 46 | new TestCommandHandler() 47 | ); 48 | $commandBus = new CommandBus($bus); 49 | 50 | // When 51 | $result = $commandBus->dispatch(new TestCommand()); 52 | 53 | // Then 54 | $this->assertSame(TestCommand::class, $result); 55 | } 56 | 57 | public function testDispatchFailsAndRethrowsException(): void 58 | { 59 | // Given 60 | $expectedException = new Exception('Not good enough'); 61 | $bus = $this->createMessageBus( 62 | TestCommand::class, 63 | new ThrowingCommandHandler($expectedException) 64 | ); 65 | $commandBus = new CommandBus($bus); 66 | 67 | // Then 68 | $this->expectExceptionObject($expectedException); 69 | 70 | // When 71 | $commandBus->dispatch(new TestCommand()); 72 | } 73 | 74 | private function createMessageBus( 75 | string $type, 76 | callable $handler 77 | ): MessageBus { 78 | return new MessageBus([ 79 | new HandleMessageMiddleware(new HandlersLocator([ 80 | $type => [$handler], 81 | ])), 82 | ]); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Unit/Infrastructure/Messenger/QueryBusTest.php: -------------------------------------------------------------------------------- 1 | exceptionToThrow; 29 | } 30 | } 31 | 32 | class TestQueryHandler 33 | { 34 | public function __invoke(TestQuery $query): mixed 35 | { 36 | return $query::class; 37 | } 38 | } 39 | 40 | class QueryBusTest extends TestCase 41 | { 42 | public function testDispatch(): void 43 | { 44 | // Given 45 | $bus = $this->createMessageBus( 46 | TestQuery::class, 47 | new TestQueryHandler() 48 | ); 49 | $queryBus = new QueryBus($bus); 50 | 51 | // When 52 | $result = $queryBus->dispatch(new TestQuery()); 53 | 54 | // Then 55 | $this->assertSame(TestQuery::class, $result); 56 | } 57 | 58 | public function testDispatchFailsAndRethrowsException(): void 59 | { 60 | // Given 61 | $expectedException = new Exception('Not good enough'); 62 | $bus = $this->createMessageBus( 63 | TestQuery::class, 64 | new ThrowingQueryHandler($expectedException) 65 | ); 66 | $commandBus = new QueryBus($bus); 67 | 68 | // Then 69 | $this->expectExceptionObject($expectedException); 70 | 71 | // When 72 | $commandBus->dispatch(new TestQuery()); 73 | } 74 | 75 | private function createMessageBus( 76 | string $type, 77 | callable $handler 78 | ): MessageBus { 79 | return new MessageBus([ 80 | new HandleMessageMiddleware(new HandlersLocator([ 81 | $type => [$handler], 82 | ])), 83 | ]); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Integration/Domain/AggregateRootTest.php: -------------------------------------------------------------------------------- 1 | $options 20 | */ 21 | protected static function createKernel(array $options = []): KernelInterface 22 | { 23 | return new TestKernel('test', true); 24 | } 25 | 26 | protected function setUp(): void 27 | { 28 | parent::setUp(); 29 | self::bootKernel(['debug' => false]); 30 | } 31 | 32 | protected function tearDown(): void 33 | { 34 | parent::tearDown(); 35 | self::ensureKernelShutdown(); 36 | Facade::clear(); 37 | // something in this test sets an exception handler and phpunit will fail because it's not restored 38 | restore_exception_handler(); 39 | } 40 | 41 | public function testDomainEvents(): void 42 | { 43 | // Given 44 | $container = static::getContainer(); 45 | 46 | $numEventsDispatched = 0; 47 | 48 | /** @var EventDispatcherInterface $eventDispatcher */ 49 | $eventDispatcher = $container->get('event_dispatcher'); 50 | $eventDispatcher->addListener( 51 | UserUpdatedEvent::class, 52 | function ($event) use (&$numEventsDispatched): void { 53 | $this->assertInstanceOf(UserUpdatedEvent::class, $event); 54 | $numEventsDispatched++; 55 | } 56 | ); 57 | $eventDispatcher->addListener( 58 | UserStateChangedEvent::class, 59 | function ($event) use (&$numEventsDispatched): void { 60 | $this->assertInstanceOf(UserStateChangedEvent::class, $event); 61 | $numEventsDispatched++; 62 | } 63 | ); 64 | 65 | // When 66 | $user = new User('jd', 'jd@example.org'); 67 | $user->setUsername('john.doe'); 68 | $user->setEmail('john.doe@example.com'); 69 | $user->activate(); 70 | $user->commit(); 71 | 72 | // Then 73 | $this->assertEquals(3, $numEventsDispatched); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Unit/Maker/PathGeneratorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $pathGenerator->namespacePrefix($namespacePrefix)); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public static function provideNamespacePrefixData(): array 23 | { 24 | return [ 25 | ['', 'Domain\\Model\\', 'Domain\\Model\\'], 26 | ['src', 'Domain\\Model\\', 'Domain\\Model\\'], 27 | ['src/', 'Domain\\Model\\', 'Domain\\Model\\'], 28 | ['src/Foo', 'Domain\\Model\\', 'Foo\\Domain\\Model\\'], 29 | ['src/Foo/Bar', 'Domain\\Model\\', 'Foo\\Bar\\Domain\\Model\\'], 30 | ]; 31 | } 32 | 33 | #[DataProvider('providePathData')] 34 | public function testPath(string $basePath, string $prefix, string $suffix, string $expected): void 35 | { 36 | $pathGenerator = new PathGenerator($basePath); 37 | 38 | $this->assertEquals($expected, $pathGenerator->path($prefix, $suffix)); 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public static function providePathData(): array 45 | { 46 | return [ 47 | ['', '%kernel.project_dir%/src', 'Domain/Model', '%kernel.project_dir%/src/Domain/Model'], 48 | ['', '%kernel.project_dir%/src/', 'Domain/Model', '%kernel.project_dir%/src/Domain/Model'], 49 | ['', '%kernel.project_dir%/src/', '/Domain/Model', '%kernel.project_dir%/src/Domain/Model'], 50 | ['src', '%kernel.project_dir%/src', 'Domain/Model', '%kernel.project_dir%/src/Domain/Model'], 51 | ['src/', '%kernel.project_dir%/src', 'Domain/Model', '%kernel.project_dir%/src/Domain/Model'], 52 | ['src/Foo', '%kernel.project_dir%/src', 'Domain/Model', '%kernel.project_dir%/src/Foo/Domain/Model'], 53 | ['src/Foo/Bar', '%kernel.project_dir%/src', 'Domain/Model', '%kernel.project_dir%/src/Foo/Bar/Domain/Model'], 54 | ['', '/src', 'Infrastructure/Doctrine/ORM/Mapping', '/src/Infrastructure/Doctrine/ORM/Mapping'], 55 | ['src/', '/src', 'Infrastructure/Doctrine/ORM/Mapping', '/src/Infrastructure/Doctrine/ORM/Mapping'], 56 | ['src/Foo', '/src', 'Infrastructure/Doctrine/ORM/Mapping', '/src/Foo/Infrastructure/Doctrine/ORM/Mapping'], 57 | ]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Infrastructure/Doctrine/Type/AbstractIdTypeTest.php: -------------------------------------------------------------------------------- 1 | convertToDatabaseValue(new FooId($intId), $platform); 48 | 49 | // Then 50 | $this->assertSame($intId, $result); 51 | } 52 | 53 | public function testConvertToDatabaseValueScalar(): void 54 | { 55 | // Given 56 | $intId = 42; 57 | $type = new FooIdType(); 58 | $platform = Mockery::mock(AbstractPlatform::class); 59 | 60 | // When 61 | $result = $type->convertToDatabaseValue($intId, $platform); 62 | 63 | // Then 64 | $this->assertSame($intId, $result); 65 | } 66 | 67 | public function testConvertToDatabaseValueInvalidType(): void 68 | { 69 | // Given 70 | $type = new FooIdType(); 71 | $platform = Mockery::mock(AbstractPlatform::class); 72 | 73 | // Then 74 | $this->expectException(ConversionException::class); 75 | 76 | // When 77 | $type->convertToDatabaseValue('foo', $platform); 78 | } 79 | 80 | public function testConvertToDatabaseValueNull(): void 81 | { 82 | // Given 83 | $type = new FooIdType(); 84 | $platform = Mockery::mock(AbstractPlatform::class); 85 | 86 | // When 87 | $result = $type->convertToDatabaseValue(null, $platform); 88 | 89 | // Then 90 | $this->assertNull($result); 91 | } 92 | 93 | public function testConvertToPhpValue(): void 94 | { 95 | // Given 96 | $intId = 42; 97 | $type = new FooIdType(); 98 | $platform = Mockery::mock(AbstractPlatform::class); 99 | 100 | // When 101 | $result = $type->convertToPHPValue($intId, $platform); 102 | 103 | // Then 104 | $this->assertInstanceOf(FooId::class, $result); 105 | $this->assertSame($intId, $result->getValue()); 106 | } 107 | 108 | public function testConvertToPhpValueNull(): void 109 | { 110 | // Given 111 | $type = new FooIdType(); 112 | $platform = Mockery::mock(AbstractPlatform::class); 113 | 114 | // When 115 | $result = $type->convertToPHPValue(null, $platform); 116 | 117 | // Then 118 | $this->assertNull($result); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/Unit/Infrastructure/Doctrine/Type/AbstractUuidTypeTest.php: -------------------------------------------------------------------------------- 1 | convertToDatabaseValue( 44 | new FooUuid($uuidString), 45 | $platform, 46 | ); 47 | 48 | // Then 49 | $this->assertSame($uuidString, $result); 50 | } 51 | 52 | public function testConvertToDatabaseValueScalar(): void 53 | { 54 | // Given 55 | $uuidString = self::UUID_STRING; 56 | $platform = Mockery::mock(AbstractPlatform::class); 57 | $type = new FooUuidType(); 58 | 59 | // When 60 | $result = $type->convertToDatabaseValue($uuidString, $platform); 61 | 62 | // Then 63 | $this->assertSame($uuidString, $result); 64 | } 65 | 66 | public function testConvertToDatabaseValueNull(): void 67 | { 68 | // Given 69 | $platform = Mockery::mock(AbstractPlatform::class); 70 | $type = new FooUuidType(); 71 | 72 | // When 73 | $result = $type->convertToDatabaseValue(null, $platform); 74 | 75 | // Then 76 | $this->assertNull($result); 77 | } 78 | 79 | public function testConvertToDatabaseValueInvalidType(): void 80 | { 81 | // Given 82 | $platform = Mockery::mock(AbstractPlatform::class); 83 | $type = new FooUuidType(); 84 | 85 | // Then 86 | $this->expectException(ConversionException::class); 87 | 88 | // When 89 | $type->convertToDatabaseValue(42, $platform); 90 | } 91 | 92 | public function testConvertToPhpValue(): void 93 | { 94 | // Given 95 | $uuidString = self::UUID_STRING; 96 | $platform = Mockery::mock(AbstractPlatform::class); 97 | $type = new FooUuidType(); 98 | 99 | // When 100 | $result = $type->convertToPHPValue($uuidString, $platform); 101 | 102 | // Then 103 | $this->assertInstanceOf(FooUuid::class, $result); 104 | $this->assertSame($uuidString, $result->getValue()); 105 | } 106 | 107 | public function testConvertToPhpValueNull(): void 108 | { 109 | // Given 110 | $platform = Mockery::mock(AbstractPlatform::class); 111 | $type = new FooUuidType(); 112 | 113 | // When 114 | $result = $type->convertToPHPValue(null, $platform); 115 | 116 | // Then 117 | $this->assertNull($result); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Infrastructure/Doctrine/Repository.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | abstract class Repository implements RepositoryInterface 25 | { 26 | private QueryBuilder $queryBuilder; 27 | 28 | /** 29 | * @param class-string $entityType 30 | */ 31 | public function __construct( 32 | protected EntityManagerInterface $entityManager, 33 | string $entityType, 34 | ?string $alias = null, 35 | ) { 36 | Assert::that($entityType)->classExists(); 37 | 38 | if (null === $alias) { 39 | $alias = $this->determineAlias($entityType); 40 | } 41 | 42 | $this->queryBuilder = $this->entityManager 43 | ->createQueryBuilder() 44 | ->select($alias) 45 | ->from($entityType, $alias); 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | */ 51 | public function collect(): Collection 52 | { 53 | /** @var array $results */ 54 | $results = $this->queryBuilder->getQuery()->getResult() ?? []; 55 | return new Collection($results); 56 | } 57 | 58 | /** 59 | * @inheritDoc 60 | */ 61 | public function paginate( 62 | int $itemsPerPage, 63 | int $currentPage = 1 64 | ): Paginator { 65 | $repository = $this->filter( 66 | function (QueryBuilder $queryBuilder) use ( 67 | $itemsPerPage, 68 | $currentPage 69 | ): void { 70 | $queryBuilder 71 | ->setFirstResult($itemsPerPage * ($currentPage - 1))->setMaxResults($itemsPerPage); 72 | } 73 | ); 74 | 75 | /** @var OrmPaginator $paginator */ 76 | $paginator = new OrmPaginator($repository->queryBuilder->getQuery()); 77 | return new DoctrinePaginator($paginator); 78 | } 79 | 80 | /** 81 | * @inheritDoc 82 | */ 83 | public function count(): int 84 | { 85 | return count($this->collect()); 86 | } 87 | 88 | /** 89 | * @inheritDoc 90 | */ 91 | public function getIterator(): Traversable 92 | { 93 | return $this->collect()->getIterator(); 94 | } 95 | 96 | /** 97 | * Apply a filter to the repository by adding to the query builder. 98 | * 99 | * @param callable $filter A callable that accepts a QueryBuilder 100 | */ 101 | public function filter(callable $filter): static 102 | { 103 | $clone = clone $this; 104 | $filter($clone->queryBuilder); 105 | 106 | return $clone; 107 | } 108 | 109 | public function __clone(): void 110 | { 111 | $this->queryBuilder = clone $this->queryBuilder; 112 | } 113 | 114 | /** 115 | * Determine the entity alias. 116 | * 117 | * @param class-string $entityType 118 | */ 119 | protected function determineAlias(string $entityType): string 120 | { 121 | $shortName = (new ReflectionClass($entityType))->getShortName(); 122 | return u($shortName)->camel()->snake()->toString(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Unit/Domain/AggregateRootTest.php: -------------------------------------------------------------------------------- 1 | createMock(EventDispatcherInterface::class); 26 | 27 | $testDomain->setEventDispatcher($dispatcherMock); 28 | 29 | $event1 = $this->createDomainEvent('some-event'); 30 | $event2 = $this->createDomainEvent('some-other-event'); 31 | $event3 = $this->createDomainEvent('and-another-event'); 32 | 33 | $expectedEvents = [$event1, $event2, $event3]; 34 | 35 | $nthEvent = 0; 36 | /** @var MockObject $dispatcherMock */ 37 | $dispatcherMock 38 | ->expects($this->exactly(3)) 39 | ->method('dispatch') 40 | ->with( 41 | $this->callback(function (DomainEvent $event) use ($expectedEvents, &$nthEvent) { 42 | return $expectedEvents[$nthEvent++] === $event; 43 | }) 44 | ); 45 | 46 | // When 47 | $testDomain->record($event1); 48 | $testDomain->record($event2); 49 | $testDomain->record($event3); 50 | $testDomain->commit(); 51 | } 52 | 53 | public function testRecordWithoutCommit(): void 54 | { 55 | // Given 56 | $testDomain = new TestDomain(); 57 | 58 | $event1 = $this->createDomainEvent('some-event'); 59 | $event2 = $this->createDomainEvent('some-other-event'); 60 | $event3 = $this->createDomainEvent('and-another-event'); 61 | 62 | /** @var EventDispatcherInterface&MockObject $dispatcherMock */ 63 | $dispatcherMock = $this->createMock(EventDispatcherInterface::class); 64 | 65 | $testDomain->setEventDispatcher($dispatcherMock); 66 | 67 | 68 | /** @var MockObject $dispatcherMock */ 69 | $dispatcherMock 70 | ->expects($this->never()) 71 | ->method('dispatch'); 72 | 73 | // When 74 | $testDomain->record($event1); 75 | $testDomain->record($event2); 76 | $testDomain->record($event3); 77 | } 78 | 79 | public function testDispatch(): void 80 | { 81 | // Given 82 | $testDomain = new TestDomain(); 83 | 84 | /** @var EventDispatcherInterface&MockObject $dispatcherMock */ 85 | $dispatcherMock = $this->createMock(EventDispatcherInterface::class); 86 | 87 | $testDomain->setEventDispatcher($dispatcherMock); 88 | 89 | $event = $this->createDomainEvent('some-event'); 90 | 91 | /** @var MockObject $dispatcherMock */ 92 | $dispatcherMock 93 | ->expects($this->once()) 94 | ->method('dispatch') 95 | ->with($event); 96 | 97 | // When - Then 98 | $testDomain->dispatch($event); 99 | } 100 | 101 | /** 102 | * Helper method to create an event instance that implements the 103 | * DomainEvent interface. 104 | * 105 | * @param string $name The name of the event. 106 | */ 107 | private function createDomainEvent( 108 | string $name 109 | ): DomainEvent { 110 | return new class ($name) implements DomainEvent { 111 | public function __construct( 112 | private readonly string $name, 113 | ) { 114 | } 115 | 116 | public function getName(): string 117 | { 118 | return $this->name; 119 | } 120 | }; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.4.3](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.4.2...v1.4.3) (2025-02-25) 4 | 5 | 6 | ### Miscellaneous Chores 7 | 8 | * change CODEOWNER to ckappen ([3a681b3](https://github.com/geekcell/ddd-symfony-bundle/commit/3a681b3a55a51c5cb419b0914459e23254a34a71)) 9 | 10 | ## [1.4.2](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.4.1...v1.4.2) (2023-12-20) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * logic to throw first wrapped exception in messenger busses ([db7e2b5](https://github.com/geekcell/ddd-symfony-bundle/commit/db7e2b51366ea3fc0fb970d5955deb29ca604d3c)) 16 | 17 | 18 | ### Miscellaneous Chores 19 | 20 | * rename function parameter to query ([f88020f](https://github.com/geekcell/ddd-symfony-bundle/commit/f88020fd2a820b856252288fe3ad208fe0db17f1)) 21 | 22 | ## [1.4.1](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.4.0...v1.4.1) (2023-12-19) 23 | 24 | 25 | ### Miscellaneous Chores 26 | 27 | * fix phpstan, add phpstan/phpstan-beberlei-assert ([eab817a](https://github.com/geekcell/ddd-symfony-bundle/commit/eab817a56c8dd3b8b95fc244cbe8fb98ecde68ca)) 28 | 29 | ## [1.4.0](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.3.0...v1.4.0) (2023-12-12) 30 | 31 | 32 | ### Features 33 | 34 | * Add generic support / forwarding for Doctrine Paginator and Repository ([e1dee96](https://github.com/geekcell/ddd-symfony-bundle/commit/e1dee9648689421b1de80e5bc5956161f2ebe7ab)) 35 | 36 | ## [1.3.0](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.2.3...v1.3.0) (2023-04-27) 37 | 38 | 39 | ### Features 40 | 41 | * add `base-path` ([4673b6d](https://github.com/geekcell/ddd-symfony-bundle/commit/4673b6d515ec995828cbef9cde10e04c1aab7303)) 42 | 43 | ## [1.2.3](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.2.2...v1.2.3) (2023-03-31) 44 | 45 | 46 | ### Miscellaneous Chores 47 | 48 | * Update `geekcell/ddd` to `v1.1.1` ([350e231](https://github.com/geekcell/ddd-symfony-bundle/commit/350e23113c7ad2288529d6083591b6467200411a)) 49 | 50 | ## [1.2.2](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.2.1...v1.2.2) (2023-03-29) 51 | 52 | 53 | ### Miscellaneous Chores 54 | 55 | * release 1.2.2 ([c6bfaf5](https://github.com/geekcell/ddd-symfony-bundle/commit/c6bfaf5b3090b9c98601e1a901792386ca35eb0a)) 56 | 57 | ## [1.2.1](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.2.0...v1.2.1) (2023-03-28) 58 | 59 | 60 | ### Miscellaneous Chores 61 | 62 | * **ci:** Sonar ([#31](https://github.com/geekcell/ddd-symfony-bundle/issues/31)) ([99e1931](https://github.com/geekcell/ddd-symfony-bundle/commit/99e19313ad89c365b0245baa468852fe12ec5326)) 63 | 64 | ## [1.2.0](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.5...v1.2.0) (2023-03-28) 65 | 66 | 67 | ### Features 68 | 69 | * release ([#29](https://github.com/geekcell/ddd-symfony-bundle/issues/29)) ([6140643](https://github.com/geekcell/ddd-symfony-bundle/commit/614064397a8818f58c6bdc07c6d6a4a1b3ca5a6e)) 70 | 71 | ## [1.1.5](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.4...v1.1.5) (2023-03-13) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * Enable custom doctrine types to handle `null` values ([8f2d52c](https://github.com/geekcell/ddd-symfony-bundle/commit/8f2d52c05fb92220c8c74e3e74b97232c6b8633e)), closes [#26](https://github.com/geekcell/ddd-symfony-bundle/issues/26) 77 | 78 | 79 | ### Miscellaneous Chores 80 | 81 | * Apply fixes from PHP CS Fixer and PHPStan. ([4a9ba6b](https://github.com/geekcell/ddd-symfony-bundle/commit/4a9ba6b6f06d3ab256038551413d93e9b295d1ad)) 82 | 83 | ## [1.1.4](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.3...v1.1.4) (2023-01-20) 84 | 85 | 86 | ### Miscellaneous Chores 87 | 88 | * **deps:** Update `geekcell/ddd` to ^1.1.0 ([c48b4c0](https://github.com/geekcell/ddd-symfony-bundle/commit/c48b4c028ddf09491208c459df66e45443632d87)) 89 | 90 | ## [1.1.3](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.2...v1.1.3) (2023-01-17) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * Add additional check for scalar values. ([#9](https://github.com/geekcell/ddd-symfony-bundle/issues/9)) ([b6f0cb1](https://github.com/geekcell/ddd-symfony-bundle/commit/b6f0cb18dd2af04c13a7ffbe807ecf2048008199)) 96 | 97 | ## [1.1.2](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.1...v1.1.2) (2023-01-16) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * Rename file to match PSR-4 autoloading standard. ([2e771ef](https://github.com/geekcell/ddd-symfony-bundle/commit/2e771efcf5776ade3c513f170c5e1968180f7b62)) 103 | 104 | ## [1.1.1](https://github.com/geekcell/ddd-symfony-bundle/compare/v1.1.0...v1.1.1) (2023-01-16) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * rename bundle file to match PSR-4 requirements. ([6f37a54](https://github.com/geekcell/ddd-symfony-bundle/commit/6f37a54b847e9727398e450e9d24cef0a2758c33)) 110 | 111 | 112 | ### Miscellaneous Chores 113 | 114 | * Update geekcell/ddd dependency. ([769384a](https://github.com/geekcell/ddd-symfony-bundle/commit/769384a2695656c9534bc832812295f12199222e)) 115 | 116 | ## 1.1.0 (2023-01-16) 117 | 118 | 119 | ### ⚠ BREAKING CHANGES 120 | 121 | * Use `geekcell/ddd` as core library. 122 | 123 | ### Features 124 | 125 | * Add initial additions ([7cdc45b](https://github.com/geekcell/ddd-symfony-bundle/commit/7cdc45b2927b7db073293a67b5663e7916f4e94d)) 126 | 127 | 128 | ### Code Refactoring 129 | 130 | * Use `geekcell/ddd` as core library. ([521e586](https://github.com/geekcell/ddd-symfony-bundle/commit/521e586d6ac59e96200f5d25667d00cc2f49e555)), closes [#5](https://github.com/geekcell/ddd-symfony-bundle/issues/5) 131 | 132 | 133 | ### Miscellaneous Chores 134 | 135 | * Add CODEOWNERS file ([8dbfa52](https://github.com/geekcell/ddd-symfony-bundle/commit/8dbfa5263a83243bf78fee3db115fe645932725d)) 136 | -------------------------------------------------------------------------------- /tests/Unit/Infrastructure/Doctrine/PaginatorTest.php: -------------------------------------------------------------------------------- 1 | mockOrmPaginator($first, $max); 24 | 25 | $paginator = new DoctrinePaginator($ormPaginator); 26 | 27 | // When 28 | $result = $paginator->getCurrentPage(); 29 | 30 | // Then 31 | $this->assertEquals($currentPage, $result); 32 | } 33 | 34 | /** 35 | * @param ?Traversable $iterator 36 | */ 37 | private function mockOrmPaginator( 38 | int $firstResult, 39 | ?int $maxResults, 40 | ?int $count = null, 41 | ?Traversable $iterator = null 42 | ): OrmPaginator&MockObject { 43 | $ormPaginatorMock = $this->createMock(OrmPaginator::class); 44 | if ($count !== null) { 45 | $ormPaginatorMock 46 | ->expects($this->once()) 47 | ->method('count') 48 | ->willReturn($count); 49 | } 50 | 51 | if ($iterator !== null) { 52 | $ormPaginatorMock 53 | ->expects($this->once()) 54 | ->method('getIterator') 55 | ->willReturn($iterator); 56 | } 57 | 58 | if (!method_exists(OrmPaginator::class, 'getFirstResult')) { 59 | // doctrine/orm:^3.x 60 | $query = $this->createMock(Query::class); 61 | $ormPaginatorMock 62 | ->expects($this->once()) 63 | ->method('getQuery') 64 | ->willReturn($query); 65 | 66 | $query 67 | ->expects($this->once()) 68 | ->method('getFirstResult') 69 | ->willReturn($firstResult); 70 | 71 | $query 72 | ->expects($this->once()) 73 | ->method('getMaxResults') 74 | ->willReturn($maxResults); 75 | 76 | return $ormPaginatorMock; 77 | } 78 | 79 | // doctrine/orm:^2.x 80 | $ormPaginatorMock 81 | ->expects($this->once()) 82 | ->method('getQuery') 83 | ->willReturnSelf(); 84 | 85 | $ormPaginatorMock 86 | ->expects($this->once()) 87 | ->method('getFirstResult') 88 | ->willReturn($firstResult); 89 | 90 | $ormPaginatorMock 91 | ->expects($this->once()) 92 | ->method('getMaxResults') 93 | ->willReturn($maxResults); 94 | 95 | return $ormPaginatorMock; 96 | } 97 | 98 | #[DataProvider('provideTotalPagesData')] 99 | public function testGetTotalPages(int $first, int $max, int $count, int $total): void 100 | { 101 | // Given 102 | $ormPaginator = $this->mockOrmPaginator($first, $max, $count); 103 | 104 | $paginator = new DoctrinePaginator($ormPaginator); 105 | 106 | // When 107 | $result = $paginator->getTotalPages(); 108 | 109 | // Then 110 | $this->assertEquals($total, $result); 111 | } 112 | 113 | public function testGetItemsPerPage(): void 114 | { 115 | // Given 116 | $max = 100; 117 | 118 | $ormPaginator = $this->mockOrmPaginator(1, $max); 119 | 120 | $paginator = new DoctrinePaginator($ormPaginator); 121 | 122 | // When 123 | $result = $paginator->getItemsPerPage(); 124 | 125 | // Then 126 | $this->assertEquals($max, $result); 127 | } 128 | 129 | public function testGetTotalItems(): void 130 | { 131 | // Given 132 | $count = 100; 133 | $ormPaginator = $this->mockOrmPaginator(1, 1, $count); 134 | 135 | $paginator = new DoctrinePaginator($ormPaginator); 136 | 137 | // When 138 | $result = $paginator->getTotalItems(); 139 | 140 | // Then 141 | $this->assertEquals($count, $result); 142 | } 143 | 144 | public function testGetIterator(): void 145 | { 146 | // Given 147 | $iterator = new ArrayIterator(); 148 | 149 | $ormPaginator = $this->mockOrmPaginator(1, 1, iterator: $iterator); 150 | 151 | $paginator = new DoctrinePaginator($ormPaginator); 152 | 153 | // When 154 | $result = $paginator->getIterator(); 155 | 156 | // Then 157 | $this->assertEquals($iterator, $result); 158 | } 159 | 160 | /** 161 | * @return array> 162 | */ 163 | public static function provideCurrentPageData(): array 164 | { 165 | return [ 166 | [0, 1, 1], 167 | [2, 10, 1], 168 | [10, 10, 1], 169 | [11, 10, 2], 170 | [20, 10, 2], 171 | [21, 10, 3], 172 | ]; 173 | } 174 | 175 | /** 176 | * @return array> 177 | */ 178 | public static function provideTotalPagesData(): array 179 | { 180 | return [ 181 | [0, 1, 10, 10], 182 | [2, 10, 100, 10], 183 | [10, 10, 30, 3], 184 | [10, 10, 29, 3], 185 | [10, 10, 31, 4], 186 | [11, 10, 2, 1], 187 | [20, 10, 2, 1], 188 | ]; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Maker/AbstractBaseMakerCQRS.php: -------------------------------------------------------------------------------- 1 | getTarget()); 44 | } 45 | 46 | public function getNamespacePrefix(PathGenerator $pathGenerator): string 47 | { 48 | return $pathGenerator->namespacePrefix('Application\\' . $this->getClassSuffix() . '\\'); 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public function configureCommand(Command $command, InputConfiguration $inputConfig): void 55 | { 56 | $command 57 | ->addArgument( 58 | 'name', 59 | InputArgument::REQUIRED, 60 | 'The name of the ' . $this->getTarget() . ' class (e.g. Customer)', 61 | ) 62 | ->addOption( 63 | 'base-path', 64 | null, 65 | InputOption::VALUE_REQUIRED, 66 | 'Base path from which to generate model & config.', 67 | null 68 | ) 69 | ; 70 | } 71 | 72 | /** 73 | * @inheritDoc 74 | */ 75 | public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void 76 | { 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 83 | { 84 | if (null === $input->getOption('base-path')) { 85 | $basePath = $io->ask( 86 | 'Which base path should be used? Default is "' . PathGenerator::DEFAULT_BASE_PATH . '"', 87 | PathGenerator::DEFAULT_BASE_PATH, 88 | ); 89 | $input->setOption('base-path', $basePath); 90 | } 91 | } 92 | 93 | /** 94 | * @inheritDoc 95 | */ 96 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 97 | { 98 | $basePath = $input->getOption('base-path'); 99 | Assert::that($basePath)->string(); 100 | $pathGenerator = new PathGenerator($basePath); 101 | 102 | $argument = $input->getArgument('name'); 103 | Assert::that($argument)->string(); 104 | $entityClassNameDetails = $generator->createClassNameDetails( 105 | $argument, 106 | $this->getNamespacePrefix($pathGenerator), 107 | $this->getClassSuffix(), 108 | ); 109 | 110 | $this->generateEntity($entityClassNameDetails, $generator); 111 | $this->generateHandler($entityClassNameDetails, $generator, $pathGenerator); 112 | 113 | $this->writeSuccessMessage($io); 114 | } 115 | 116 | /** 117 | * @throws Exception 118 | */ 119 | private function generateEntity(ClassNameDetails $queryClassNameDetails, Generator $generator): void 120 | { 121 | $templateVars = [ 122 | 'use_statements' => new UseStatementGenerator($this->getEntityUseStatements()), 123 | ]; 124 | 125 | $templatePath = __DIR__.sprintf('/../Resources/skeleton/%s/%s.tpl.php', $this->getTarget(), $this->getClassSuffix()); 126 | $generator->generateClass( 127 | $queryClassNameDetails->getFullName(), 128 | $templatePath, 129 | $templateVars, 130 | ); 131 | 132 | $generator->writeChanges(); 133 | } 134 | 135 | /** 136 | * @throws Exception 137 | */ 138 | private function generateHandler( 139 | ClassNameDetails $queryClassNameDetails, 140 | Generator $generator, 141 | PathGenerator $pathGenerator 142 | ): void { 143 | $classNameDetails = $generator->createClassNameDetails( 144 | $queryClassNameDetails->getShortName(), 145 | $this->getNamespacePrefix($pathGenerator), 146 | 'Handler', 147 | ); 148 | 149 | $templateVars = [ 150 | 'use_statements' => new UseStatementGenerator($this->getEntityHandlerUseStatements()), 151 | 'query_class_name' => $queryClassNameDetails->getShortName() 152 | ]; 153 | 154 | $templatePath = __DIR__.sprintf('/../Resources/skeleton/%s/%sHandler.tpl.php', $this->getTarget(), $this->getClassSuffix()); 155 | $generator->generateClass( 156 | $classNameDetails->getFullName(), 157 | $templatePath, 158 | $templateVars, 159 | ); 160 | 161 | $generator->writeChanges(); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Maker/MakeController.php: -------------------------------------------------------------------------------- 1 | addArgument( 61 | 'name', 62 | InputArgument::REQUIRED, 63 | 'The name of the controller class (e.g. Customer)', 64 | ) 65 | ->addOption( 66 | 'include-query-bus', 67 | null, 68 | InputOption::VALUE_REQUIRED, 69 | 'Add a query bus dependency.', 70 | null 71 | ) 72 | ->addOption( 73 | 'include-command-bus', 74 | null, 75 | InputOption::VALUE_REQUIRED, 76 | 'Add a command bus dependency.', 77 | null 78 | ) 79 | ->addOption( 80 | 'base-path', 81 | null, 82 | InputOption::VALUE_REQUIRED, 83 | 'Base path from which to generate model & config.', 84 | null 85 | ) 86 | ; 87 | } 88 | 89 | /** 90 | * @inheritDoc 91 | */ 92 | public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void 93 | { 94 | } 95 | 96 | /** 97 | * @inheritDoc 98 | */ 99 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 100 | { 101 | if (null === $input->getOption('include-query-bus')) { 102 | $includeQueryBus = $io->confirm( 103 | 'Do you want to add a query bus dependency?', 104 | false, 105 | ); 106 | $input->setOption('include-query-bus', $includeQueryBus); 107 | } 108 | 109 | if (null === $input->getOption('include-command-bus')) { 110 | $includeCommandBus = $io->confirm( 111 | 'Do you want to add a command bus dependency?', 112 | false, 113 | ); 114 | $input->setOption('include-command-bus', $includeCommandBus); 115 | } 116 | 117 | if (null === $input->getOption('base-path')) { 118 | $basePath = $io->ask( 119 | 'Which base path should be used? Default is "' . PathGenerator::DEFAULT_BASE_PATH . '"', 120 | PathGenerator::DEFAULT_BASE_PATH, 121 | ); 122 | $input->setOption('base-path', $basePath); 123 | } 124 | } 125 | 126 | /** 127 | * @inheritDoc 128 | */ 129 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 130 | { 131 | $basePath = $input->getOption('base-path'); 132 | Assert::that($basePath)->string(); 133 | $pathGenerator = new PathGenerator($basePath); 134 | 135 | $name = $input->getArgument('name'); 136 | Assert::that($name)->string(); 137 | $classNameDetails = $generator->createClassNameDetails( 138 | $name, 139 | $pathGenerator->namespacePrefix(self::NAMESPACE_PREFIX), 140 | 'Controller', 141 | ); 142 | 143 | $classesToImport = [ 144 | AbstractController::class, 145 | Route::class, 146 | Response::class 147 | ]; 148 | 149 | $routeName = lcfirst((string) $name); 150 | $templateVars = [ 151 | 'route_name' => $routeName, 152 | 'route_name_snake' => u($routeName)->snake()->lower(), 153 | 'dependencies' => [] 154 | ]; 155 | 156 | if ($input->getOption('include-query-bus')) { 157 | $templateVars['dependencies'][] = 'private QueryBus $queryBus'; 158 | $classesToImport[] = QueryBus::class; 159 | } 160 | 161 | if ($input->getOption('include-command-bus')) { 162 | $templateVars['dependencies'][] = 'private CommandBus $commandBus'; 163 | $classesToImport[] = CommandBus::class; 164 | } 165 | 166 | $templateVars['use_statements'] = new UseStatementGenerator($classesToImport); 167 | 168 | $templatePath = __DIR__.'/../Resources/skeleton/controller/Controller.tpl.php'; 169 | $generator->generateClass( 170 | $classNameDetails->getFullName(), 171 | $templatePath, 172 | $templateVars, 173 | ); 174 | 175 | // ensure controller config has been created 176 | if (!$this->fileManager->fileExists(self::CONFIG_PATH)) { 177 | $templatePathConfig = __DIR__ . '/../Resources/skeleton/controller/RouteConfig.tpl.php'; 178 | $generator->generateFile( 179 | self::CONFIG_PATH, 180 | $templatePathConfig, 181 | [ 182 | 'path' => $pathGenerator->path('../../src/', 'Infrastructure/Http/Controller/'), 183 | 'namespace' => str_replace('\\' . $classNameDetails->getShortName(), '', $classNameDetails->getFullName()) 184 | ] 185 | ); 186 | } 187 | 188 | $generator->writeChanges(); 189 | 190 | $this->writeSuccessMessage($io); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony Bundle for DDD 2 | 3 | This Symfony bundle augments [geekcell/php-ddd](https://github.com/geekcell/php-ddd) with framework-specific implementations to enable seamless [domain driven design](https://martinfowler.com/tags/domain%20driven%20design.html) in a familiar environment. 4 | 5 | --- 6 | 7 | - [Installation](#installation) 8 | - [Generator Commands](#generator-commands) 9 | - [Building Blocks](#building-blocks) 10 | - [Model & Repository](#model--repository) 11 | - [AggregateRoot & Domain Events](#aggregateroot--domain-events) 12 | - [Command & Query](#command--query) 13 | - [Controller](#controller) 14 | - [Resource](#resource) 15 | 16 | ## Installation 17 | 18 | To use this bundle, require it in Composer 19 | 20 | ```bash 21 | composer require geekcell/ddd-bundle 22 | ``` 23 | 24 | ## Generator Commands 25 | 26 | This bundle adds several [MakerBundle](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) commands to generate commonly used components. 27 | 28 | In order to use them in your Symfony project, you need to require it with composer first 29 | 30 | ```bash 31 | composer require symfony/maker-bundle 32 | ``` 33 | 34 | ### Available Commands 35 | 36 | ```bash 37 | make:ddd:command Creates a new command class and handler 38 | make:ddd:controller Creates a new controller class 39 | make:ddd:model Creates a new domain model class 40 | make:ddd:query Creates a new query class and handler 41 | make:ddd:resource Creates a new API Platform resource 42 | ``` 43 | 44 | ## Building Blocks 45 | 46 | ### Model & Repository 47 | 48 | The **domain model** is a representation of the domain concepts and business logic within your project. The **repository** on the other hand is an abstraction layer that provides a way to access and manipulate domain objects without exposing the details of the underlying data persistence mechanism (such as a database or file system). 49 | 50 | Since Doctrine is the de-facto persistence layer for Symfony, this bundle also provides an (opinionated) implementation for a Doctrine-based repository. 51 | 52 | #### Generator Command(s) 53 | 54 | This command can be used to generate: 55 | 56 | - The domain model class. 57 | - A repository class for the model. 58 | - The model's identity class as value object (optional). 59 | - A Doctrine database entity configuration, either as annotation or separate config file (optional). 60 | - A custom Doctrine type for the model's identity class (optional). 61 | 62 | ```bash 63 | Description: 64 | Creates a new domain model class 65 | 66 | Usage: 67 | make:ddd:model [options] [--] [] 68 | 69 | Arguments: 70 | name The name of the model class (e.g. Customer) 71 | 72 | Options: 73 | --aggregate-root Marks the model as aggregate root 74 | --entity=ENTITY Use this model as Doctrine entity 75 | --with-identity=WITH-IDENTITY Whether an identity value object should be created 76 | --with-suffix Adds the suffix "Model" to the model class name 77 | ``` 78 | 79 | ### AggregateRoot & Domain Events 80 | 81 | Optionally, by inheriting from `AggregateRoot`, you can make a model class an **aggregate root**, which is used to encapsulate a group of related objects, along with the behavior and rules that apply to them. The aggregate root is usually responsible for managing the lifecycle of the objects within the aggregate, and for coordinating any interactions between them. 82 | 83 | The `AggregateRoot` base class comes with some useful functionality to record and dispatch **domain events**, which represent significant occurrences or state changes within the domain of a software system. 84 | 85 | #### Generator Command(s) 86 | 87 | N/A 88 | 89 | #### Example Usage 90 | 91 | ```php 92 | 93 | // src/Domain/Event/OrderPlacedEvent.php 94 | 95 | use GeekCell\Ddd\Contracts\Domain\Event as DomainEvent; 96 | 97 | readonly class OrderPlacedEvent implements DomainEvent 98 | { 99 | public function __construct( 100 | public Order $order, 101 | ) { 102 | } 103 | } 104 | 105 | // src/Domain/Model/Order.php 106 | 107 | use GeekCell\DddBundle\Domain\AggregateRoot; 108 | 109 | class Order extends AggregateRoot 110 | { 111 | public function save(): void 112 | { 113 | $this->record(new OrderPlacedEvent($this)); 114 | } 115 | 116 | // ... 117 | } 118 | 119 | // Actual usage ... 120 | 121 | $order = new Order( /* ... */ ); 122 | $order->save(); 123 | $order->commit(); // All recorded events will be dispatched and released 124 | ``` 125 | 126 | _Hint: If you want to dispatch an event directly, use `AggregateRoot::dispatch()` instead of `AggregateRoot::record()`._ 127 | 128 | If you cannot (or don't want to) extend from `AggregateRoot`, you can alternative use `DispatchableTrait` to add dispatching capabilities to any class. The former is however the recommended way. 129 | 130 | ### Command & Query 131 | 132 | You can use `CommandBus` and `QueryBus` as services to implement [CQRS](https://martinfowler.com/bliki/CQRS.html). Internally, both buses will use the [Symfony messenger](https://symfony.com/doc/current/messenger.html) to dispatch commands and queries. 133 | 134 | #### Generator Command(s) 135 | 136 | These commands can be used to generate: 137 | 138 | - A command and command handler class. 139 | - A query and query handler class. 140 | 141 | The query / command generated is just an empty class. The handler class is registered as a message handler for the configured [Symfony Messenger](https://symfony.com/doc/current/messenger.html). 142 | 143 | ```bash 144 | Description: 145 | Creates a new query|command class and handler 146 | 147 | Usage: 148 | make:ddd:query|command [] 149 | 150 | Arguments: 151 | name The name of the query|command class (e.g. Customer) 152 | ``` 153 | 154 | #### Example Usage 155 | 156 | ```php 157 | // src/Application/Query/TopRatedBookQuery.php 158 | 159 | use GeekCell\Ddd\Contracts\Application\Query; 160 | 161 | readonly class TopRatedBooksQuery implements Query 162 | { 163 | public function __construct( 164 | public string $category, 165 | public int $sinceDays, 166 | public int $limit = 10, 167 | ) { 168 | } 169 | 170 | // Getters etc. 171 | } 172 | 173 | // src/Application/Query/TopRatedBookQueryHandler.php 174 | 175 | use GeekCell\Ddd\Contracts\Application\QueryHandler; 176 | 177 | #[AsMessageHandler] 178 | class TopRatedBookQueryHandler implements QueryHandler 179 | { 180 | public function __construct( 181 | private readonly BookRepository $repository, 182 | ) { 183 | } 184 | 185 | public function __invoke(TopRatedBookQuery $query) 186 | { 187 | $books = $this->repository 188 | ->findTopRated($query->category, $query->sinceDays) 189 | ->paginate($query->limit); 190 | 191 | return $books; 192 | } 193 | } 194 | 195 | // src/Infrastructure/Http/Controller/BookController.php 196 | 197 | use GeekCell\Ddd\Contracts\Application\QueryBus; 198 | 199 | class BookController extends AbstractController 200 | { 201 | public function __construct( 202 | private readonly QueryBus $queryBus, 203 | ) { 204 | } 205 | 206 | #[Route('/books/top-rated')] 207 | public function getTopRated(Request $request) 208 | { 209 | $query = new TopRatedBooksQuery( /* extract from request */ ); 210 | $topRatedBooks = $this->queryBus->dispatch($query); 211 | 212 | // ... 213 | } 214 | } 215 | ``` 216 | 217 | ### Controller 218 | 219 | A standard Symfony controller, but augmented with command and query bus(es). 220 | 221 | #### Generator Command 222 | 223 | This command can be used to generate a controller with optional `QueryBus` and `CommandBus` dependencies. 224 | 225 | ```bash 226 | Description: 227 | Creates a new controller class 228 | 229 | Usage: 230 | make:ddd:controller [options] [--] [] 231 | 232 | Arguments: 233 | name The name of the model class (e.g. Customer) 234 | 235 | Options: 236 | --include-query-bus Add a query bus dependency 237 | --include-command-bus Add a command bus dependency 238 | ``` 239 | 240 | ### Resource 241 | 242 | An [API Platform](https://api-platform.com/) resource, but instead of using the standard approach of using a combined entity/resource approach, it is preferred to separate model (domain layer) and API Platform specific resource (infrastructure layer) 243 | 244 | #### Generator Command 245 | 246 | Minimum required API Platform version is [2.7](https://api-platform.com/docs/core/upgrade-guide/#api-platform-2730) for the [new metadata system](https://api-platform.com/docs/core/upgrade-guide/#apiresource-metadata). 247 | 248 | ```bash 249 | Description: 250 | Creates a new API Platform resource 251 | 252 | Usage: 253 | make:ddd:resource [options] [--] [] 254 | 255 | Arguments: 256 | name The name of the model class to create the resource for (e.g. Customer). Model must exist already. 257 | 258 | Options: 259 | --config Config flavor to create (attribute|xml). 260 | ``` 261 | -------------------------------------------------------------------------------- /src/Maker/MakeResource.php: -------------------------------------------------------------------------------- 1 | addArgument( 98 | 'name', 99 | InputArgument::REQUIRED, 100 | 'The name of the model class to create the resource for (e.g. Customer). Model must exist already.', 101 | ) 102 | ->addOption( 103 | 'config', 104 | null, 105 | InputOption::VALUE_REQUIRED, 106 | 'Config flavor to create (attribute|xml).', 107 | null 108 | ) 109 | ->addOption( 110 | 'base-path', 111 | null, 112 | InputOption::VALUE_REQUIRED, 113 | 'Base path from which to generate model & config.', 114 | null 115 | ) 116 | ; 117 | } 118 | 119 | /** 120 | * @inheritDoc 121 | */ 122 | public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void 123 | { 124 | } 125 | 126 | /** 127 | * @inheritDoc 128 | */ 129 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 130 | { 131 | // Check for bundle to make sure API Platform package is installed. 132 | // Then check if the new ApiResource class in the Metadata namespace exists. 133 | // -> Was only introduced in v2.7. 134 | if (!class_exists(self::API_PLATFORM_BUNDLE_CLASS) || !class_exists(self::API_PLATFORM_RESOURCE_CLASS)) { 135 | throw new RuntimeCommandException('This command requires Api Platform >2.7 to be installed.'); 136 | } 137 | 138 | if (false === $input->getOption('config')) { 139 | $configFlavor = $io->choice( 140 | 'Config flavor to create (attribute|xml). (%sModel)', 141 | [ 142 | self::CONFIG_FLAVOR_ATTRIBUTE => 'PHP attributes', 143 | self::CONFIG_PATH_XML => 'XML mapping', 144 | ], 145 | self::CONFIG_FLAVOR_ATTRIBUTE 146 | ); 147 | $input->setOption('config', $configFlavor); 148 | } 149 | 150 | if (null === $input->getOption('base-path')) { 151 | $basePath = $io->ask( 152 | 'Which base path should be used? Default is "' . PathGenerator::DEFAULT_BASE_PATH . '"', 153 | PathGenerator::DEFAULT_BASE_PATH, 154 | ); 155 | $input->setOption('base-path', $basePath); 156 | } 157 | } 158 | 159 | /** 160 | * @inheritDoc 161 | */ 162 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 163 | { 164 | $baseName = $input->getArgument('name'); 165 | Assert::that($baseName)->string(); 166 | $configFlavor = $input->getOption('config'); 167 | Assert::that($configFlavor)->string(); 168 | $basePath = $input->getOption('base-path'); 169 | Assert::that($basePath)->string(); 170 | $pathGenerator = new PathGenerator($basePath); 171 | 172 | if (!in_array($configFlavor, [self::CONFIG_FLAVOR_ATTRIBUTE, self::CONFIG_FLAVOR_XML], true)) { 173 | throw new RuntimeCommandException('Unknown config flavor: ' . $configFlavor); 174 | } 175 | 176 | $modelClassNameDetails = $generator->createClassNameDetails( 177 | $baseName, 178 | $pathGenerator->namespacePrefix('Domain\\Model\\'), 179 | ); 180 | 181 | if (!class_exists($modelClassNameDetails->getFullName())) { 182 | throw new RuntimeCommandException(sprintf('Could not find model %s!', $modelClassNameDetails->getFullName())); 183 | } 184 | 185 | $identityClassNameDetails = $this->ensureIdentity($modelClassNameDetails, $generator, $pathGenerator); 186 | 187 | $classNameDetails = $generator->createClassNameDetails( 188 | $baseName, 189 | $pathGenerator->namespacePrefix(self::NAMESPACE_PREFIX . 'Resource'), 190 | 'Resource', 191 | ); 192 | 193 | $this->ensureConfig($generator, $pathGenerator, $configFlavor); 194 | 195 | $providerClassNameDetails = $generator->createClassNameDetails( 196 | $baseName, 197 | $pathGenerator->namespacePrefix(self::NAMESPACE_PREFIX . 'State'), 198 | 'Provider', 199 | ); 200 | $this->generateProvider($providerClassNameDetails, $generator); 201 | 202 | $processorClassNameDetails = $generator->createClassNameDetails( 203 | $baseName, 204 | $pathGenerator->namespacePrefix(self::NAMESPACE_PREFIX . 'State'), 205 | 'Processor', 206 | ); 207 | $this->generateProcessor($processorClassNameDetails, $generator); 208 | 209 | $classesToImport = [$modelClassNameDetails->getFullName()]; 210 | if ($configFlavor === self::CONFIG_FLAVOR_ATTRIBUTE) { 211 | $classesToImport[] = self::API_PLATFORM_RESOURCE_CLASS; 212 | $classesToImport[] = self::API_PLATFORM_PROPERTY_CLASS; 213 | $classesToImport[] = $providerClassNameDetails->getFullName(); 214 | $classesToImport[] = $processorClassNameDetails->getFullName(); 215 | } 216 | 217 | $configureWithUuid = str_contains(strtolower($identityClassNameDetails->getShortName()), 'uuid'); 218 | $templateVars = [ 219 | 'use_statements' => new UseStatementGenerator($classesToImport), 220 | 'entity_class_name' => $modelClassNameDetails->getShortName(), 221 | 'provider_class_name' => $providerClassNameDetails->getShortName(), 222 | 'processor_class_name' => $processorClassNameDetails->getShortName(), 223 | 'configure_with_attributes' => $configFlavor === self::CONFIG_FLAVOR_ATTRIBUTE, 224 | 'configure_with_uuid' => $configureWithUuid, 225 | ]; 226 | 227 | $generator->generateClass( 228 | $classNameDetails->getFullName(), 229 | __DIR__.'/../Resources/skeleton/resource/Resource.tpl.php', 230 | $templateVars, 231 | ); 232 | 233 | if ($configFlavor === self::CONFIG_FLAVOR_XML) { 234 | $targetPathResourceConfig = $pathGenerator->path('src/', self::CONFIG_PATH_XML . '/' . $classNameDetails->getShortName() . '.xml'); 235 | $generator->generateFile( 236 | $targetPathResourceConfig, 237 | __DIR__.'/../Resources/skeleton/resource/ResourceXmlConfig.tpl.php', 238 | [ 239 | 'class_name' => $classNameDetails->getFullName(), 240 | 'entity_short_class_name' => $modelClassNameDetails->getShortName(), 241 | 'provider_class_name' => $providerClassNameDetails->getFullName(), 242 | 'processor_class_name' => $processorClassNameDetails->getFullName(), 243 | ] 244 | ); 245 | 246 | $targetPathPropertiesConfig = $pathGenerator->path('src/', self::CONFIG_PATH_XML . '/' . $classNameDetails->getShortName() . 'Properties.xml'); 247 | $generator->generateFile( 248 | $targetPathPropertiesConfig, 249 | __DIR__.'/../Resources/skeleton/resource/PropertiesXmlConfig.tpl.php', 250 | [ 251 | 'class_name' => $classNameDetails->getFullName(), 252 | 'identifier_field_name' => $configureWithUuid ? 'uuid' : 'id', 253 | ] 254 | ); 255 | } 256 | 257 | $generator->writeChanges(); 258 | 259 | $this->writeSuccessMessage($io); 260 | } 261 | 262 | /** 263 | * ensure custom resource path(s) are added to config 264 | */ 265 | private function ensureConfig(Generator $generator, PathGenerator $pathGenerator, string $configFlavor): void 266 | { 267 | $customResourcePath = $pathGenerator->path('%kernel.project_dir%/src', 'Infrastructure/ApiPlatform/Resource'); 268 | $customConfigPath = $pathGenerator->path('%kernel.project_dir%/src', self::CONFIG_PATH_XML); 269 | 270 | if (!$this->fileManager->fileExists(self::CONFIG_PATH)) { 271 | $generator->generateFile( 272 | self::CONFIG_PATH, 273 | __DIR__ . '/../Resources/skeleton/resource/ApiPlatformConfig.tpl.php', 274 | [ 275 | 'path' => $customResourcePath, 276 | ] 277 | ); 278 | 279 | $generator->writeChanges(); 280 | } 281 | 282 | $newYaml = $this->configUpdater->addCustomPath( 283 | $this->fileManager->getFileContents(self::CONFIG_PATH), 284 | $customResourcePath 285 | ); 286 | 287 | if ($configFlavor === self::CONFIG_FLAVOR_XML) { 288 | $newYaml = $this->configUpdater->addCustomPath($newYaml, $customConfigPath); 289 | } 290 | 291 | $generator->dumpFile(self::CONFIG_PATH, $newYaml); 292 | 293 | $generator->writeChanges(); 294 | } 295 | 296 | /** 297 | * @throws Exception 298 | */ 299 | private function generateProvider(ClassNameDetails $providerClassNameDetails, Generator $generator): void 300 | { 301 | $templateVars = [ 302 | 'use_statements' => new UseStatementGenerator([ 303 | self::API_PLATFORM_PROVIDER_INTERFACE, 304 | QueryBus::class, 305 | self::API_PLATFORM_OPERATION_CLASS 306 | ]), 307 | ]; 308 | 309 | $generator->generateClass( 310 | $providerClassNameDetails->getFullName(), 311 | __DIR__.'/../Resources/skeleton/resource/Provider.tpl.php', 312 | $templateVars, 313 | ); 314 | 315 | $generator->writeChanges(); 316 | } 317 | 318 | /** 319 | * @throws Exception 320 | */ 321 | private function generateProcessor(ClassNameDetails $processorClassNameDetails, Generator $generator): void 322 | { 323 | $templateVars = [ 324 | 'use_statements' => new UseStatementGenerator([ 325 | self::API_PLATFORM_PROCESSOR_INTERFACE, 326 | CommandBus::class, 327 | self::API_PLATFORM_OPERATION_CLASS 328 | ]), 329 | ]; 330 | 331 | $generator->generateClass( 332 | $processorClassNameDetails->getFullName(), 333 | __DIR__.'/../Resources/skeleton/resource/Processor.tpl.php', 334 | $templateVars, 335 | ); 336 | 337 | $generator->writeChanges(); 338 | } 339 | 340 | private function ensureIdentity(ClassNameDetails $modelClassNameDetails, Generator $generator, PathGenerator $pathGenerator): ClassNameDetails 341 | { 342 | $idEntity = $generator->createClassNameDetails( 343 | $modelClassNameDetails->getShortName(), 344 | $pathGenerator->namespacePrefix('Domain\\Model\\ValueObject\\Identity'), 345 | 'Id', 346 | ); 347 | 348 | if (class_exists($idEntity->getFullName())) { 349 | return $idEntity; 350 | } 351 | 352 | $uuidEntity = $generator->createClassNameDetails( 353 | $modelClassNameDetails->getShortName(), 354 | $pathGenerator->namespacePrefix('Domain\\Model\\ValueObject\\Identity'), 355 | 'Uuid', 356 | ); 357 | 358 | if (class_exists($uuidEntity->getFullName())) { 359 | return $uuidEntity; 360 | } 361 | 362 | throw new RuntimeCommandException(sprintf('Could not find model identity for %s. Checked for id class (%s) and uuid class (%s)!', $modelClassNameDetails->getFullName(), $idEntity->getFullName(), $uuidEntity->getFullName())); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/Maker/MakeModel.php: -------------------------------------------------------------------------------- 1 | > 46 | */ 47 | private array $classesToImport = []; 48 | 49 | /** 50 | * @var array 51 | */ 52 | private array $templateVariables = []; 53 | 54 | public function __construct( 55 | private readonly DoctrineConfigUpdater $doctrineUpdater, 56 | private readonly FileManager $fileManager, 57 | ) { 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public static function getCommandName(): string 64 | { 65 | return 'make:ddd:model'; 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public static function getCommandDescription(): string 72 | { 73 | return 'Creates a new domain model class'; 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function configureCommand(Command $command, InputConfiguration $inputConfig): void 80 | { 81 | $command 82 | ->addArgument( 83 | 'name', 84 | InputArgument::REQUIRED, 85 | 'The name of the model class (e.g. Customer)', 86 | ) 87 | ->addOption( 88 | 'aggregate-root', 89 | null, 90 | InputOption::VALUE_REQUIRED, 91 | 'Marks the model as aggregate root', 92 | null 93 | ) 94 | ->addOption( 95 | 'entity', 96 | null, 97 | InputOption::VALUE_REQUIRED, 98 | 'Use this model as Doctrine entity', 99 | null 100 | ) 101 | ->addOption( 102 | 'with-identity', 103 | null, 104 | InputOption::VALUE_REQUIRED, 105 | 'Whether an identity value object should be created', 106 | null 107 | ) 108 | ->addOption( 109 | 'with-suffix', 110 | null, 111 | InputOption::VALUE_REQUIRED, 112 | 'Adds the suffix "Model" to the model class name', 113 | null 114 | ) 115 | ->addOption( 116 | 'base-path', 117 | null, 118 | InputOption::VALUE_REQUIRED, 119 | 'Base path from which to generate model & config.', 120 | null 121 | ) 122 | ; 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void 129 | { 130 | if (!$input instanceof InputInterface || !$this->shouldGenerateEntity($input)) { 131 | return; 132 | } 133 | 134 | ORMDependencyBuilder::buildDependencies($dependencies); 135 | } 136 | 137 | /** 138 | * @inheritDoc 139 | */ 140 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 141 | { 142 | if (!$this->fileManager->fileExists(DOCTRINE_CONFIG_PATH)) { 143 | throw new RuntimeCommandException('The file "' . DOCTRINE_CONFIG_PATH . '" does not exist. This command requires that file to exist so that it can be updated.'); 144 | } 145 | 146 | $modelName = $input->getArgument('name'); 147 | Assert::that($modelName)->string(); 148 | 149 | $useSuffix = $input->getOption('with-suffix'); 150 | if (null === $useSuffix) { 151 | $useSuffix = $io->confirm( 152 | sprintf( 153 | 'Do you want to suffix the model class name? (%sModel)', 154 | $modelName, 155 | ), 156 | false, 157 | ); 158 | $input->setOption('with-suffix', $useSuffix); 159 | } 160 | 161 | if (null === $input->getOption('aggregate-root')) { 162 | $asAggregateRoot = $io->confirm( 163 | sprintf( 164 | 'Do you want create %s%s as aggregate root?', 165 | $modelName, 166 | $useSuffix ? 'Model' : '', 167 | ), 168 | ); 169 | $input->setOption('aggregate-root', $asAggregateRoot); 170 | } 171 | 172 | if (null === $input->getOption('with-identity')) { 173 | $withIdentity = $io->choice( 174 | sprintf( 175 | 'How do you want to identify %s%s?', 176 | $modelName, 177 | $useSuffix ? 'Model' : '', 178 | ), 179 | [ 180 | 'id' => sprintf( 181 | 'Numeric identity representation (%sId)', 182 | $modelName, 183 | ), 184 | 'uuid' => sprintf( 185 | 'UUID representation (%sUuid)', 186 | $modelName, 187 | ), 188 | 'n/a' => "I'll take care later myself", 189 | ], 190 | ); 191 | $input->setOption('with-identity', $withIdentity); 192 | } 193 | 194 | if (null === $input->getOption('entity')) { 195 | $asEntity = $io->choice( 196 | sprintf( 197 | 'Do you want %s%s to be a (Doctrine) database entity?', 198 | $modelName, 199 | $useSuffix ? 'Model' : '', 200 | ), 201 | [ 202 | 'attributes' => 'Yes, via PHP attributes', 203 | 'xml' => 'Yes, via XML mapping', 204 | 'n/a' => "No, I'll handle it separately", 205 | ], 206 | ); 207 | $input->setOption('entity', $asEntity); 208 | } 209 | 210 | if (null === $input->getOption('base-path')) { 211 | $basePath = $io->ask( 212 | 'Which base path should be used? Default is "' . PathGenerator::DEFAULT_BASE_PATH . '"', 213 | PathGenerator::DEFAULT_BASE_PATH, 214 | ); 215 | $input->setOption('base-path', $basePath); 216 | } 217 | } 218 | 219 | /** 220 | * @inheritDoc 221 | */ 222 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 223 | { 224 | $modelName = $input->getArgument('name'); 225 | Assert::that($modelName)->string(); 226 | $suffix = $input->getOption('with-suffix') ? 'Model' : ''; 227 | $basePath = $input->getOption('base-path'); 228 | Assert::that($basePath)->string(); 229 | $pathGenerator = new PathGenerator($basePath); 230 | 231 | $modelClassNameDetails = $generator->createClassNameDetails( 232 | $modelName, 233 | $pathGenerator->namespacePrefix('Domain\\Model\\'), 234 | $suffix, 235 | ); 236 | 237 | $this->templateVariables['class_name'] = $modelClassNameDetails->getShortName(); 238 | 239 | $identityClassNameDetails = $this->generateIdentity($modelName, $input, $io, $generator, $pathGenerator); 240 | $this->generateEntityMappings($modelClassNameDetails, $input, $io, $generator, $pathGenerator); 241 | $this->generateEntity($modelClassNameDetails, $input, $generator); 242 | $this->generateRepository($generator, $input, $pathGenerator, $modelClassNameDetails, $identityClassNameDetails); 243 | 244 | $this->writeSuccessMessage($io); 245 | } 246 | 247 | /** 248 | * Optionally, generate the identity value object for the model. 249 | */ 250 | private function generateIdentity( 251 | string $modelName, 252 | InputInterface $input, 253 | ConsoleStyle $io, 254 | Generator $generator, 255 | PathGenerator $pathGenerator 256 | ): ?ClassNameDetails { 257 | if (!$this->shouldGenerateIdentity($input)) { 258 | return null; 259 | } 260 | 261 | // 1. Generate the identity value object. 262 | 263 | $identityType = $input->getOption('with-identity'); 264 | Assert::that($identityType)->string(); 265 | $identityClassNameDetails = $generator->createClassNameDetails( 266 | $modelName, 267 | $pathGenerator->namespacePrefix('Domain\\Model\\ValueObject\\Identity\\'), 268 | ucfirst((string) $identityType), 269 | ); 270 | 271 | $extendsAlias = match ($identityType) { 272 | 'id' => 'AbstractId', 273 | 'uuid' => 'AbstractUuid', 274 | default => null, 275 | }; 276 | 277 | $baseClass = match ($identityType) { 278 | 'id' => [Id::class => $extendsAlias], 279 | 'uuid' => [Uuid::class => $extendsAlias], 280 | default => null, 281 | }; 282 | 283 | if (!$extendsAlias || !$baseClass) { 284 | throw new InvalidArgumentException(sprintf('Unknown identity type "%s"', $identityType)); 285 | } 286 | 287 | // @phpstan-ignore-next-line 288 | $useStatements = new UseStatementGenerator([$baseClass]); 289 | 290 | $generator->generateClass( 291 | $identityClassNameDetails->getFullName(), 292 | __DIR__.'/../Resources/skeleton/model/Identity.tpl.php', 293 | [ 294 | 'identity_class' => $identityClassNameDetails->getShortName(), 295 | 'extends_alias' => $extendsAlias, 296 | 'use_statements' => $useStatements, 297 | ], 298 | ); 299 | 300 | $this->classesToImport[] = $identityClassNameDetails->getFullName(); 301 | $this->templateVariables['identity_type'] = $identityType; 302 | $this->templateVariables['identity_class'] = $identityClassNameDetails->getShortName(); 303 | 304 | if (!$this->shouldGenerateEntity($input)) { 305 | return null; 306 | } 307 | 308 | // 2. Generate custom Doctrine mapping type for the identity. 309 | 310 | $mappingTypeClassNameDetails = $generator->createClassNameDetails( 311 | $modelName.ucfirst((string) $identityType), 312 | $pathGenerator->namespacePrefix('Infrastructure\\Doctrine\\DBAL\\Type\\'), 313 | 'Type', 314 | ); 315 | 316 | $baseTypeClass = match ($identityType) { 317 | 'id' => AbstractIdType::class, 318 | 'uuid' => AbstractUuidType::class, 319 | default => null, 320 | }; 321 | 322 | if (!$baseTypeClass) { 323 | throw new InvalidArgumentException(sprintf('Unknown identity type "%s"', $identityType)); 324 | } 325 | 326 | $useStatements = new UseStatementGenerator([ 327 | $identityClassNameDetails->getFullName(), 328 | $baseTypeClass 329 | ]); 330 | 331 | $typeName = u($identityClassNameDetails->getShortName())->snake()->toString(); 332 | $generator->generateClass( 333 | $mappingTypeClassNameDetails->getFullName(), 334 | __DIR__.'/../Resources/skeleton/model/DoctrineMappingType.tpl.php', 335 | [ 336 | 'type_name' => $typeName, 337 | 'type_class' => $mappingTypeClassNameDetails->getShortName(), 338 | 'extends_type_class' => sprintf('Abstract%sType', ucfirst((string) $identityType)), 339 | 'identity_class' => $identityClassNameDetails->getShortName(), 340 | 'use_statements' => $useStatements, 341 | ], 342 | ); 343 | 344 | $configPath = 'config/packages/doctrine.yaml'; 345 | if (!$this->fileManager->fileExists($configPath)) { 346 | $io->error(sprintf('Doctrine configuration at path "%s" does not exist.', $configPath)); 347 | return null; 348 | } 349 | 350 | // 2.1 Add the custom mapping type to the Doctrine configuration. 351 | 352 | $newYaml = $this->doctrineUpdater->addCustomDBALMappingType( 353 | $this->fileManager->getFileContents($configPath), 354 | $typeName, 355 | $mappingTypeClassNameDetails->getFullName(), 356 | ); 357 | $generator->dumpFile($configPath, $newYaml); 358 | 359 | $this->classesToImport[] = $mappingTypeClassNameDetails->getFullName(); 360 | $this->templateVariables['type_class'] = $mappingTypeClassNameDetails->getShortName(); 361 | $this->templateVariables['type_name'] = $typeName; 362 | 363 | // Write out the changes. 364 | $generator->writeChanges(); 365 | 366 | return $identityClassNameDetails; 367 | } 368 | 369 | /** 370 | * Optionally, generate entity mappings for the model. 371 | */ 372 | private function generateEntityMappings( 373 | ClassNameDetails $modelClassNameDetails, 374 | InputInterface $input, 375 | ConsoleStyle $io, 376 | Generator $generator, 377 | PathGenerator $pathGenerator 378 | ): void { 379 | if (!$this->shouldGenerateEntity($input)) { 380 | return; 381 | } 382 | 383 | $modelName = $modelClassNameDetails->getShortName(); 384 | 385 | if ($this->shouldGenerateEntityAttributes($input)) { 386 | try { 387 | $newYaml = $this->doctrineUpdater->updateORMDefaultEntityMapping( 388 | $this->fileManager->getFileContents(DOCTRINE_CONFIG_PATH), 389 | 'attribute', 390 | $pathGenerator->path('%kernel.project_dir%/src', 'Domain/Model'), 391 | ); 392 | $generator->dumpFile(DOCTRINE_CONFIG_PATH, $newYaml); 393 | $this->classesToImport[] = ['Doctrine\\ORM\\Mapping' => 'ORM']; 394 | $this->templateVariables['as_entity'] = true; 395 | } catch (YamlManipulationFailedException $e) { 396 | $io->error($e->getMessage()); 397 | $this->templateVariables['as_entity'] = false; 398 | } 399 | 400 | return; 401 | } 402 | 403 | if ($this->shouldGenerateEntityXml($input)) { 404 | $tableName = u($modelClassNameDetails->getShortName())->before('Model')->snake()->toString(); 405 | $hasIdentity = $this->shouldGenerateIdentity($input); 406 | if ($hasIdentity && !isset($this->templateVariables['type_name'])) { 407 | throw new LogicException( 408 | 'Cannot generate entity XML mapping without identity type (which should have been generated).' 409 | ); 410 | } 411 | 412 | $this->templateVariables['as_entity'] = false; 413 | 414 | try { 415 | $mappingsDirectory = $pathGenerator->path('/src', 'Infrastructure/Doctrine/ORM/Mapping'); 416 | $newYaml = $this->doctrineUpdater->updateORMDefaultEntityMapping( 417 | $this->fileManager->getFileContents(DOCTRINE_CONFIG_PATH), 418 | 'xml', 419 | '%kernel.project_dir%'.$mappingsDirectory, 420 | ); 421 | $generator->dumpFile(DOCTRINE_CONFIG_PATH, $newYaml); 422 | 423 | $targetPath = sprintf( 424 | '%s%s/%s.orm.xml', 425 | $this->fileManager->getRootDirectory(), 426 | $mappingsDirectory, 427 | $modelName 428 | ); 429 | 430 | $generator->generateFile( 431 | $targetPath, 432 | __DIR__.'/../Resources/skeleton/doctrine/Mapping.tpl.xml.php', 433 | [ 434 | 'model_class' => $modelClassNameDetails->getFullName(), 435 | 'has_identity' => $hasIdentity, 436 | 'type_name' => $hasIdentity ? $this->templateVariables['type_name'] : null, 437 | 'table_name' => $tableName, 438 | 'identity_column_name' => $hasIdentity ? $this->templateVariables['identity_type'] : null, 439 | ], 440 | ); 441 | } catch (YamlManipulationFailedException $e) { 442 | $io->error($e->getMessage()); 443 | } 444 | } 445 | 446 | // Write out the changes. 447 | $generator->writeChanges(); 448 | } 449 | 450 | /** 451 | * Generate model entity 452 | * 453 | * @throws Exception 454 | */ 455 | private function generateEntity( 456 | ClassNameDetails $modelClassNameDetails, 457 | InputInterface $input, 458 | Generator $generator 459 | ): void { 460 | if ($input->getOption('aggregate-root')) { 461 | $this->classesToImport[] = AggregateRoot::class; 462 | $this->templateVariables['extends_aggregate_root'] = true; 463 | } 464 | 465 | // @phpstan-ignore-next-line 466 | $this->templateVariables['use_statements'] = new UseStatementGenerator($this->classesToImport); 467 | 468 | $templatePath = __DIR__.'/../Resources/skeleton/model/Model.tpl.php'; 469 | $generator->generateClass( 470 | $modelClassNameDetails->getFullName(), 471 | $templatePath, 472 | $this->templateVariables, 473 | ); 474 | 475 | $generator->writeChanges(); 476 | } 477 | 478 | /** 479 | * Generate model repository 480 | * 481 | * @throws Exception 482 | */ 483 | private function generateRepository( 484 | Generator $generator, 485 | InputInterface $input, 486 | PathGenerator $pathGenerator, 487 | ClassNameDetails $modelClassNameDetails, 488 | ?ClassNameDetails $identityClassNameDetails, 489 | ): void { 490 | $name = $input->getArgument('name'); 491 | Assert::that($name)->string(); 492 | $interfaceNameDetails = $generator->createClassNameDetails( 493 | $name, 494 | $pathGenerator->namespacePrefix('Domain\\Repository\\'), 495 | 'Repository', 496 | ); 497 | 498 | $this->generateRepositoryInterface( 499 | $generator, 500 | $interfaceNameDetails, 501 | $modelClassNameDetails, 502 | $identityClassNameDetails, 503 | ); 504 | 505 | $implementationNameDetails = $generator->createClassNameDetails( 506 | $name, 507 | $pathGenerator->namespacePrefix('Infrastructure\\Doctrine\\ORM\\Repository\\'), 508 | 'Repository', 509 | ); 510 | 511 | $interfaceClassName = $interfaceNameDetails->getShortName() . 'Interface'; 512 | $useStatements = [ 513 | $modelClassNameDetails->getFullName(), 514 | ManagerRegistry::class, 515 | QueryBuilder::class, 516 | OrmRepository::class => 'OrmRepository', 517 | $interfaceNameDetails->getFullName() => $interfaceClassName 518 | ]; 519 | 520 | if ($identityClassNameDetails?->getFullName()) { 521 | $useStatements[] = $identityClassNameDetails->getFullName(); 522 | } 523 | 524 | $templateVars = [ 525 | 'use_statements' => new UseStatementGenerator($useStatements), 526 | 'interface_class_name' => $interfaceClassName, 527 | 'model_class_name' => $modelClassNameDetails->getShortName(), 528 | 'identity_class_name' => $identityClassNameDetails?->getShortName() 529 | ]; 530 | 531 | $templatePath = __DIR__.'/../Resources/skeleton/model/Repository.tpl.php'; 532 | $generator->generateClass( 533 | $implementationNameDetails->getFullName(), 534 | $templatePath, 535 | $templateVars, 536 | ); 537 | 538 | $generator->writeChanges(); 539 | } 540 | 541 | /** 542 | * Generate model repository 543 | * 544 | * @throws Exception 545 | */ 546 | private function generateRepositoryInterface( 547 | Generator $generator, 548 | ClassNameDetails $classNameDetails, 549 | ClassNameDetails $modelClassNameDetails, 550 | ?ClassNameDetails $identityClassNameDetails, 551 | ): void { 552 | $templateVars = [ 553 | 'use_statements' => new UseStatementGenerator(array_filter([ 554 | $modelClassNameDetails->getFullName(), 555 | $identityClassNameDetails?->getFullName(), 556 | Repository::class, 557 | ])), 558 | 'model_class_name' => $modelClassNameDetails->getShortName(), 559 | 'identity_class_name' => $identityClassNameDetails?->getShortName() 560 | ]; 561 | 562 | $templatePath = __DIR__.'/../Resources/skeleton/model/RepositoryInterface.tpl.php'; 563 | $generator->generateClass( 564 | $classNameDetails->getFullName(), 565 | $templatePath, 566 | $templateVars, 567 | ); 568 | 569 | $generator->writeChanges(); 570 | } 571 | 572 | // Helper methods 573 | /** 574 | * Returns whether the user wants to generate entity mappings as PHP attributes. 575 | */ 576 | private function shouldGenerateEntityAttributes(InputInterface $input): bool 577 | { 578 | return 'attributes' === $input->getOption('entity'); 579 | } 580 | 581 | /** 582 | * Returns whether the user wants to generate entity mappings as XML. 583 | */ 584 | private function shouldGenerateEntityXml(InputInterface $input): bool 585 | { 586 | return 'xml' === $input->getOption('entity'); 587 | } 588 | 589 | /** 590 | * Returns whether the user wants to generate entity mappings. 591 | */ 592 | private function shouldGenerateEntity(InputInterface $input): bool 593 | { 594 | if ($this->shouldGenerateEntityAttributes($input)) { 595 | return true; 596 | } 597 | return $this->shouldGenerateEntityXml($input); 598 | } 599 | 600 | /** 601 | * Returns whether the user wants to generate an identity value object for the model. 602 | */ 603 | private function shouldGenerateId(InputInterface $input): bool 604 | { 605 | return 'id' === $input->getOption('with-identity'); 606 | } 607 | 608 | /** 609 | * Returns whether the user wants to generate a UUID value object for the model. 610 | */ 611 | private function shouldGenerateUuid(InputInterface $input): bool 612 | { 613 | return 'uuid' === $input->getOption('with-identity'); 614 | } 615 | 616 | /** 617 | * Returns whether the user wants to generate an identity value object for the model. 618 | */ 619 | private function shouldGenerateIdentity(InputInterface $input): bool 620 | { 621 | if ($this->shouldGenerateId($input)) { 622 | return true; 623 | } 624 | return $this->shouldGenerateUuid($input); 625 | } 626 | } 627 | --------------------------------------------------------------------------------