├── 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 | - '= $path ?>'
5 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/controller/RouteConfig.tpl.php:
--------------------------------------------------------------------------------
1 | controllers:
2 | resource:
3 | path: = $path ?>= "\n" ?>
4 | namespace: = $namespace ?>= "\n" ?>
5 | type: attribute
6 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/query/Query.tpl.php:
--------------------------------------------------------------------------------
1 | = "
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> implements Query
8 | {
9 | }
10 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/command/Command.tpl.php:
--------------------------------------------------------------------------------
1 | = "
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> implements Command
8 | {
9 | }
10 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/model/Identity.tpl.php:
--------------------------------------------------------------------------------
1 | = "
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $identity_class ?> extends = $extends_alias . "\n" ?>
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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | #[AsMessageHandler]
8 | class = $class_name ?> implements CommandHandler
9 | {
10 | public function __invoke(= $query_class_name ?> $query): void
11 | {
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/query/QueryHandler.tpl.php:
--------------------------------------------------------------------------------
1 | = "
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | #[AsMessageHandler]
8 | class = $class_name ?> implements QueryHandler
9 | {
10 | public function __invoke(= $query_class_name ?> $query): Collection
11 | {
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/Resources/skeleton/model/RepositoryInterface.tpl.php:
--------------------------------------------------------------------------------
1 | = "
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | interface = $class_name ?> extends Repository
8 | {
9 |
10 | public function findById(= $identity_class_name ?> $id): ?= $model_class_name ?>;
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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> 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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $type_class ?> extends = $extends_type_class . "\n" ?>
8 | {
9 | public const NAME = '= $type_name ?>';
10 |
11 | public function getName(): string
12 | {
13 | return self::NAME;
14 | }
15 |
16 | protected function getIdType(): string
17 | {
18 | return = $identity_class ?>::class;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/Integration/Fixtures/Domain/Event/UserStateChangedEvent.php:
--------------------------------------------------------------------------------
1 |
2 |
3 | namespace = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> 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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> extends AbstractController
8 | {
9 | 0): ?>
10 | public function __construct(= implode(', ', $dependencies ) ?>)
11 | {}
12 |
13 |
14 | #[Route('/= $route_name_snake ?>', name: '= $route_name_snake ?>')]
15 | public function = $route_name ?>(): 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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 | class = $class_name ?> extends OrmRepository implements = $interface_class_name ?>
8 | {
9 |
10 | public function findById(= $identity_class_name ?> $id): ?= $model_class_name ?>
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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 |
8 | #[ApiResource(
9 | provider: = $provider_class_name ?>::class,
10 | processor: = $processor_class_name ?>::class,
11 | )]
12 |
13 | final class = $class_name ?>= "\n" ?>
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 = $entity_class_name ?> model
29 | *
30 | * @param = $entity_class_name ?> $model
31 | *
32 | * @return static
33 | */
34 | public static function create(= $entity_class_name ?> $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 = $namespace; ?>;
4 |
5 | = $use_statements ?>
6 |
7 |
8 | #[ORM\Entity]
9 |
10 | class = $class_name ?> extends AggregateRoot= "\n" ?>
11 | {
12 |
13 | /**
14 | * @var = $identity_class . "\n" ?>
15 | */
16 |
17 | #[ORM\Id]
18 | #[ORM\Column(type: = $type_class ?>::NAME)]
19 |
20 | private = $identity_class ?> $= $identity_type ?>;
21 |
22 |
23 | public function __construct()
24 | {
25 |
26 | $this->id = new = $identity_class ?>(1);
27 |
28 | $this->uuid = = $identity_class ?>::random();
29 |
30 | }
31 |
32 |
33 | /**
34 | * Get = ucfirst($identity_type) . "\n" ?>
35 | *
36 | * @return = $identity_class . "\n" ?>
37 | */
38 | public function get= ucfirst($identity_type) ?>(): = $identity_class . "\n" ?>
39 | {
40 | return $this->= $identity_type ?>;
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 |
--------------------------------------------------------------------------------