├── .dockerignore ├── .editorconfig ├── .env ├── .env.test ├── .gitattributes ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .php-cs-fixer.dist.php ├── Dockerfile ├── Makefile ├── README.md ├── bin ├── console └── phpunit ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── api_platform.php │ ├── debug.php │ ├── doctrine.php │ ├── ecotone.php │ ├── framework.php │ ├── messenger.php │ ├── monolog.php │ ├── nelmio_cors.php │ ├── routing.php │ ├── security.php │ ├── twig.php │ ├── validator.php │ └── web_profiler.php ├── preload.php ├── routes │ ├── api_platform.php │ ├── framework.php │ └── web_profiler.php └── services │ ├── book_store.php │ ├── shared.php │ └── subscription.php ├── deptrac_bc.yaml ├── deptrac_hexa.yaml ├── docker-compose.override.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── docker ├── caddy │ └── Caddyfile └── php │ ├── conf.d │ ├── symfony.dev.ini │ └── symfony.prod.ini │ ├── docker-entrypoint.sh │ ├── docker-healthcheck.sh │ └── php-fpm.d │ └── zz-docker.conf ├── phpunit.xml.dist ├── psalm.xml ├── public ├── .gitignore └── index.php ├── src ├── BookStore │ ├── Application │ │ ├── Command │ │ │ └── AnonymizeBooksCommandHandler.php │ │ └── Query │ │ │ ├── FindBookEventsQueryHandler.php │ │ │ ├── FindBookQueryHandler.php │ │ │ ├── FindBooksQueryHandler.php │ │ │ └── FindCheapestBooksQueryHandler.php │ ├── Domain │ │ ├── Command │ │ │ ├── AnonymizeBooksCommand.php │ │ │ ├── CreateBookCommand.php │ │ │ ├── DeleteBookCommand.php │ │ │ ├── DiscountBookCommand.php │ │ │ └── UpdateBookCommand.php │ │ ├── Event │ │ │ ├── BookEvent.php │ │ │ ├── BookWasCreated.php │ │ │ ├── BookWasDeleted.php │ │ │ ├── BookWasDiscounted.php │ │ │ └── BookWasUpdated.php │ │ ├── Exception │ │ │ └── MissingBookException.php │ │ ├── Model │ │ │ └── Book.php │ │ ├── Query │ │ │ ├── FindBookEventsQuery.php │ │ │ ├── FindBookQuery.php │ │ │ ├── FindBooksQuery.php │ │ │ └── FindCheapestBooksQuery.php │ │ ├── Repository │ │ │ └── BookRepositoryInterface.php │ │ └── ValueObject │ │ │ ├── Author.php │ │ │ ├── BookContent.php │ │ │ ├── BookDescription.php │ │ │ ├── BookId.php │ │ │ ├── BookName.php │ │ │ ├── Discount.php │ │ │ └── Price.php │ └── Infrastructure │ │ ├── ApiPlatform │ │ ├── OpenApi │ │ │ └── AuthorFilter.php │ │ ├── Payload │ │ │ └── DiscountBookPayload.php │ │ ├── Resource │ │ │ └── BookResource.php │ │ └── State │ │ │ ├── Processor │ │ │ ├── AnonymizeBooksProcessor.php │ │ │ ├── CreateBookProcessor.php │ │ │ ├── DeleteBookProcessor.php │ │ │ ├── DiscountBookProcessor.php │ │ │ └── UpdateBookProcessor.php │ │ │ └── Provider │ │ │ ├── BookCollectionProvider.php │ │ │ ├── BookEventsProvider.php │ │ │ ├── BookItemProvider.php │ │ │ └── CheapestBooksProvider.php │ │ └── Ecotone │ │ ├── Projection │ │ ├── BookIdsGateway.php │ │ ├── BookIdsProjection.php │ │ ├── BookPriceGateway.php │ │ ├── BookPriceProjection.php │ │ ├── BooksByAuthorGateway.php │ │ └── BooksByAuthorProjection.php │ │ └── Repository │ │ ├── BookRepository.php │ │ └── EventSourcedBookRepository.php ├── Shared │ ├── Application │ │ ├── Command │ │ │ ├── CommandBusInterface.php │ │ │ └── CommandHandlerInterface.php │ │ └── Query │ │ │ ├── QueryBusInterface.php │ │ │ └── QueryHandlerInterface.php │ ├── Domain │ │ ├── Command │ │ │ └── CommandInterface.php │ │ ├── Query │ │ │ └── QueryInterface.php │ │ ├── Repository │ │ │ ├── CallbackPaginator.php │ │ │ └── PaginatorInterface.php │ │ └── ValueObject │ │ │ └── AggregateRootId.php │ └── Infrastructure │ │ ├── ApiPlatform │ │ └── State │ │ │ └── Paginator.php │ │ ├── Ecotone │ │ ├── CommandBus.php │ │ └── QueryBus.php │ │ └── Symfony │ │ └── Kernel.php └── Subscription │ └── Entity │ └── Subscription.php ├── symfony.lock ├── templates └── base.html.twig └── tests ├── BookStore ├── Acceptance │ ├── AnonymizeBooksTest.php │ ├── BookCrudTest.php │ ├── BookEventsTest.php │ ├── CheapestBooksTest.php │ └── DiscountBookTest.php ├── DummyFactory │ └── DummyBookFactory.php ├── Functional │ ├── AnonymizeBooksTest.php │ ├── CreateBookTest.php │ ├── DeleteBookTest.php │ ├── DiscountBookTest.php │ ├── FindBookTest.php │ ├── FindBooksTest.php │ ├── FindCheapestBooksTest.php │ └── UpdateBookTest.php └── Unit │ └── Ecotone │ └── BooksByAuthorProjectionTest.php ├── EcotoneConfiguration.php ├── Subscription ├── Acceptance │ └── SubscriptionCrudTest.php └── DummySubscriptionFactory.php └── bootstrap.php /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/*.md 3 | **/*.php~ 4 | **/._* 5 | **/.dockerignore 6 | **/.DS_Store 7 | **/.git/ 8 | **/.gitattributes 9 | **/.gitignore 10 | **/.gitmodules 11 | **/docker-compose.*.yaml 12 | **/docker-compose.*.yml 13 | **/docker-compose.yaml 14 | **/docker-compose.yml 15 | **/Dockerfile 16 | **/Thumbs.db 17 | .editorconfig 18 | .env.*.local 19 | .env.local 20 | .env.local.php 21 | .php_cs.cache 22 | bin/* 23 | !bin/console 24 | docker/db/data/ 25 | helm/ 26 | public/bundles/ 27 | var/ 28 | vendor/ 29 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.{js,html}] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [*.json] 23 | indent_style = space 24 | indent_size = 2 25 | 26 | [*.md] 27 | trim_trailing_whitespace = false 28 | 29 | [*.php] 30 | indent_style = space 31 | indent_size = 4 32 | 33 | [*.sh] 34 | indent_style = tab 35 | indent_size = 4 36 | 37 | [*.xml{,.dist}] 38 | indent_style = space 39 | indent_size = 4 40 | 41 | [*.{yaml,yml}] 42 | indent_style = space 43 | indent_size = 4 44 | trim_trailing_whitespace = false 45 | 46 | [api/helm/api/**.yaml] 47 | indent_style = space 48 | indent_size = 2 49 | 50 | [.github/workflows/*.yml] 51 | indent_style = space 52 | indent_size = 2 53 | 54 | [.gitmodules] 55 | indent_style = tab 56 | indent_size = 4 57 | 58 | [.php_cs{,.dist}] 59 | indent_style = space 60 | indent_size = 4 61 | 62 | [.travis.yml] 63 | indent_style = space 64 | indent_size = 2 65 | 66 | [composer.json] 67 | indent_style = space 68 | indent_size = 4 69 | 70 | [docker-compose{,.*}.{yaml,yml}] 71 | indent_style = space 72 | indent_size = 2 73 | 74 | [Dockerfile] 75 | indent_style = tab 76 | indent_size = 4 77 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=6aae3d25d74cc223fc0bfdc801ce12e2 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 25 | # 26 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4" 28 | DATABASE_URL="postgresql://symfony:!ChangeMe!@database:5432/app?serverVersion=15&charset=utf8" 29 | ###< doctrine/doctrine-bundle ### 30 | 31 | ###> nelmio/cors-bundle ### 32 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' 33 | ###< nelmio/cors-bundle ### 34 | 35 | ###> symfony/messenger ### 36 | # Choose one of the transports below 37 | # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages 38 | # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages 39 | # MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 40 | ###< symfony/messenger ### 41 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | APP_SECRET='$ecretf0rt3st' 2 | SYMFONY_DEPRECATIONS_HELPER=999999 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.conf text eol=lf 4 | *.html text eol=lf 5 | *.ini text eol=lf 6 | *.js text eol=lf 7 | *.json text eol=lf 8 | *.md text eol=lf 9 | *.php text eol=lf 10 | *.sh text eol=lf 11 | *.yaml text eol=lf 12 | *.yml text eol=lf 13 | bin/console text eol=lf 14 | 15 | *.ico binary 16 | *.png binary 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: ~ 5 | push: ~ 6 | 7 | jobs: 8 | php-cs-fixer: 9 | runs-on: ubuntu-latest 10 | name: PHP-CS-Fixer 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: '8.2' 19 | tools: php-cs-fixer, cs2pr 20 | 21 | - name: Run PHP-CS-Fixer 22 | run: php-cs-fixer fix --dry-run --format checkstyle | cs2pr 23 | 24 | psalm: 25 | runs-on: ubuntu-latest 26 | name: Psalm 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: '8.2' 35 | 36 | - name: Get composer cache directory 37 | id: composercache 38 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 39 | 40 | - name: Cache dependencies 41 | uses: actions/cache@v3 42 | with: 43 | path: ${{ steps.composercache.outputs.dir }} 44 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 45 | restore-keys: ${{ runner.os }}-composer- 46 | 47 | - name: Install dependencies 48 | run: composer install --prefer-dist 49 | 50 | - name: Run Psalm 51 | run: vendor/bin/psalm --show-info=true --output-format=github 52 | 53 | deptrac_bc: 54 | runs-on: ubuntu-latest 55 | name: Deptrac bounded contexts 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v3 59 | 60 | - name: Setup PHP 61 | uses: shivammathur/setup-php@v2 62 | with: 63 | php-version: '8.2' 64 | 65 | - name: Get composer cache directory 66 | id: composercache 67 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 68 | 69 | - name: Cache dependencies 70 | uses: actions/cache@v3 71 | with: 72 | path: ${{ steps.composercache.outputs.dir }} 73 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 74 | restore-keys: ${{ runner.os }}-composer- 75 | 76 | - name: Install dependencies 77 | run: composer install --prefer-dist 78 | 79 | - name: Run Deptrac 80 | run: vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --config-file deptrac_bc.yaml 81 | 82 | deptrac_hexa: 83 | runs-on: ubuntu-latest 84 | name: Deptrac hexagonal 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v3 88 | 89 | - name: Setup PHP 90 | uses: shivammathur/setup-php@v2 91 | with: 92 | php-version: '8.2' 93 | 94 | - name: Get composer cache directory 95 | id: composercache 96 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 97 | 98 | - name: Cache dependencies 99 | uses: actions/cache@v3 100 | with: 101 | path: ${{ steps.composercache.outputs.dir }} 102 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 103 | restore-keys: ${{ runner.os }}-composer- 104 | 105 | - name: Install dependencies 106 | run: composer install --prefer-dist 107 | 108 | - name: Run Deptrac 109 | run: vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --config-file deptrac_hexa.yaml 110 | 111 | phpunit: 112 | name: PHPUnit 113 | runs-on: ubuntu-latest 114 | 115 | services: 116 | database: 117 | image: postgres:13-alpine 118 | env: 119 | POSTGRES_USER: symfony 120 | POSTGRES_PASSWORD: '!ChangeMe!' 121 | options: >- 122 | --health-cmd pg_isready 123 | --health-interval 10s 124 | --health-timeout 5s 125 | --health-retries 5 126 | ports: 127 | - 5432:5432 128 | 129 | steps: 130 | - name: Checkout 131 | uses: actions/checkout@v3 132 | 133 | - name: Setup PHP 134 | uses: shivammathur/setup-php@v2 135 | with: 136 | php-version: '8.2' 137 | 138 | - name: Get composer cache directory 139 | id: composercache 140 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 141 | 142 | - name: Cache dependencies 143 | uses: actions/cache@v3 144 | with: 145 | path: ${{ steps.composercache.outputs.dir }} 146 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 147 | restore-keys: ${{ runner.os }}-composer- 148 | 149 | - name: Install dependencies 150 | run: composer install --prefer-dist 151 | 152 | - name: Run tests 153 | run: bin/phpunit 154 | env: 155 | DATABASE_URL: 'postgresql://symfony:!ChangeMe!@localhost:5432/app_test?serverVersion=15&charset=utf8' 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> symfony/phpunit-bridge ### 12 | .phpunit.result.cache 13 | /phpunit.xml 14 | ###< symfony/phpunit-bridge ### 15 | 16 | ###> friendsofphp/php-cs-fixer ### 17 | /.php-cs-fixer.php 18 | /.php-cs-fixer.cache 19 | ###< friendsofphp/php-cs-fixer ### 20 | 21 | ###> qossmic/deptrac-shim ### 22 | /.deptrac_bc.cache 23 | /.deptrac_hexa.cache 24 | ###< qossmic/deptrac-shim ### 25 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 7 | ->exclude('var') 8 | ; 9 | 10 | return (new PhpCsFixer\Config()) 11 | ->setRules([ 12 | '@Symfony' => true, 13 | 'trailing_comma_in_multiline' => true, 14 | 'ordered_imports' => [ 15 | 'imports_order' => ['class', 'function', 'const'], 16 | 'sort_algorithm' => 'alpha', 17 | ], 18 | 'declare_strict_types' => true, 19 | ]) 20 | ->setRiskyAllowed(true) 21 | ->setFinder($finder) 22 | ; 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # the different stages of this Dockerfile are meant to be built into separate images 2 | # https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage 3 | # https://docs.docker.com/compose/compose-file/#target 4 | 5 | 6 | # https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact 7 | ARG PHP_VERSION=8.2 8 | ARG CADDY_VERSION=2 9 | 10 | # "php" stage 11 | FROM php:${PHP_VERSION}-fpm-alpine AS symfony_php 12 | 13 | # php extensions installer: https://github.com/mlocati/docker-php-extension-installer 14 | COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ 15 | 16 | # persistent / runtime deps 17 | RUN apk add --no-cache \ 18 | acl \ 19 | fcgi \ 20 | file \ 21 | gettext \ 22 | git \ 23 | ; 24 | 25 | RUN set -eux; \ 26 | install-php-extensions \ 27 | intl \ 28 | zip \ 29 | apcu \ 30 | opcache \ 31 | ; 32 | 33 | ###> recipes ### 34 | ###> doctrine/doctrine-bundle ### 35 | RUN set -eux; \ 36 | install-php-extensions pdo_pgsql 37 | ###< doctrine/doctrine-bundle ### 38 | ###< recipes ### 39 | 40 | COPY docker/php/docker-healthcheck.sh /usr/local/bin/docker-healthcheck 41 | RUN chmod +x /usr/local/bin/docker-healthcheck 42 | 43 | HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD ["docker-healthcheck"] 44 | 45 | RUN ln -s $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini 46 | COPY docker/php/conf.d/symfony.prod.ini $PHP_INI_DIR/conf.d/symfony.ini 47 | 48 | COPY docker/php/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf 49 | 50 | COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint 51 | RUN chmod +x /usr/local/bin/docker-entrypoint 52 | 53 | VOLUME /var/run/php 54 | 55 | COPY --from=composer/composer:2-bin /composer /usr/bin/composer 56 | 57 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 58 | ENV COMPOSER_ALLOW_SUPERUSER=1 59 | 60 | ENV PATH="${PATH}:/root/.composer/vendor/bin" 61 | 62 | WORKDIR /srv/app 63 | 64 | # Allow to choose skeleton 65 | ARG SKELETON="symfony/skeleton" 66 | ENV SKELETON ${SKELETON} 67 | 68 | # Allow to use development versions of Symfony 69 | ARG STABILITY="stable" 70 | ENV STABILITY ${STABILITY} 71 | 72 | # Allow to select skeleton version 73 | ARG SYMFONY_VERSION="" 74 | ENV SYMFONY_VERSION ${SYMFONY_VERSION} 75 | 76 | # Download the Symfony skeleton and leverage Docker cache layers 77 | RUN composer create-project "${SKELETON} ${SYMFONY_VERSION}" . --stability=$STABILITY --prefer-dist --no-dev --no-progress --no-interaction; \ 78 | composer clear-cache 79 | 80 | COPY . . 81 | 82 | RUN set -eux; \ 83 | mkdir -p var/cache var/log; \ 84 | composer install --prefer-dist --no-dev --no-progress --no-scripts --no-interaction; \ 85 | composer dump-autoload --classmap-authoritative --no-dev; \ 86 | composer symfony:dump-env prod; \ 87 | composer run-script --no-dev post-install-cmd; \ 88 | chmod +x bin/console; sync 89 | VOLUME /srv/app/var 90 | 91 | ENTRYPOINT ["docker-entrypoint"] 92 | CMD ["php-fpm"] 93 | 94 | FROM caddy:${CADDY_VERSION}-builder-alpine AS symfony_caddy_builder 95 | 96 | RUN xcaddy build \ 97 | --with github.com/dunglas/mercure/caddy \ 98 | --with github.com/dunglas/vulcain/caddy 99 | 100 | FROM caddy:${CADDY_VERSION} AS symfony_caddy 101 | 102 | WORKDIR /srv/app 103 | 104 | COPY --from=symfony_caddy_builder /usr/bin/caddy /usr/bin/caddy 105 | COPY --from=symfony_php /srv/app/public public/ 106 | COPY docker/caddy/Caddyfile /etc/caddy/Caddyfile 107 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | DC = docker compose 4 | EXEC = $(DC) exec php 5 | COMPOSER = $(EXEC) composer 6 | 7 | ifndef CI_JOB_ID 8 | GREEN := $(shell tput -Txterm setaf 2) 9 | YELLOW := $(shell tput -Txterm setaf 3) 10 | RESET := $(shell tput -Txterm sgr0) 11 | TARGET_MAX_CHAR_NUM=30 12 | endif 13 | 14 | help: 15 | @echo "API Platform DDD ${GREEN}example${RESET}" 16 | @awk '/^[a-zA-Z\-\_0-9]+:/ { \ 17 | helpMessage = match(lastLine, /^## (.*)/); \ 18 | if (helpMessage) { \ 19 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 20 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 21 | printf " ${GREEN}%-$(TARGET_MAX_CHAR_NUM)s${RESET} %s\n", helpCommand, helpMessage; \ 22 | } \ 23 | isTopic = match(lastLine, /^###/); \ 24 | if (isTopic) { \ 25 | topic = substr($$1, 0, index($$1, ":")-1); \ 26 | printf "\n${YELLOW}%s${RESET}\n", topic; \ 27 | } \ 28 | } { lastLine = $$0 }' $(MAKEFILE_LIST) 29 | 30 | 31 | 32 | ################################# 33 | Project: 34 | 35 | ## Enter the application container 36 | php: 37 | @$(EXEC) sh 38 | 39 | ## Enter the database container 40 | database: 41 | @$(DC) exec database psql -Usymfony app 42 | 43 | ## Install the whole dev environment 44 | install: 45 | @$(DC) build 46 | @$(MAKE) start -s 47 | @$(MAKE) vendor -s 48 | @$(MAKE) db-reset -s 49 | 50 | ## Install composer dependencies 51 | vendor: 52 | @$(COMPOSER) install --optimize-autoloader 53 | 54 | ## Start the project 55 | start: 56 | @$(DC) up -d --remove-orphans --no-recreate 57 | 58 | ## Stop the project 59 | stop: 60 | @$(DC) stop 61 | @$(DC) rm -v --force 62 | 63 | .PHONY: php database install vendor start stop 64 | 65 | ################################# 66 | Database: 67 | 68 | ## Create/Recreate the database 69 | db-create: 70 | @$(EXEC) bin/console doctrine:database:drop --force --if-exists -nq 71 | @$(EXEC) bin/console doctrine:database:create -nq 72 | 73 | ## Update database schema 74 | db-update: 75 | @$(EXEC) bin/console doctrine:schema:update --force -nq 76 | 77 | db-projections: 78 | @$(EXEC) bin/console ecotone:es:initialize-projection bookList 79 | @$(EXEC) bin/console ecotone:es:initialize-projection bookPriceList 80 | @$(EXEC) bin/console ecotone:es:initialize-projection booksByAuthor 81 | 82 | ## Reset database 83 | db-reset: db-create db-update 84 | 85 | .PHONY: db-create db-update db-reset 86 | 87 | ################################# 88 | Tests: 89 | 90 | ## Run codestyle static analysis 91 | php-cs-fixer: 92 | @$(EXEC) vendor/bin/php-cs-fixer fix --dry-run --diff 93 | 94 | ## Run psalm static analysis 95 | psalm: 96 | @$(EXEC) vendor/bin/psalm --show-info=true 97 | 98 | ## Run code depedencies static analysis 99 | deptrac: 100 | @echo "\n${YELLOW}Checking Bounded contexts...${RESET}" 101 | @$(EXEC) vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --cache-file .deptrac_bc.cache --config-file deptrac_bc.yaml 102 | 103 | @echo "\n${YELLOW}Checking Hexagonal layers...${RESET}" 104 | @$(EXEC) vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --cache-file .deptrac_hexa.cache --config-file deptrac_hexa.yaml 105 | 106 | ## Run phpunit tests 107 | phpunit: 108 | @$(EXEC) bin/phpunit 109 | 110 | ## Run either static analysis and tests 111 | ci: php-cs-fixer psalm deptrac phpunit 112 | 113 | .PHONY: php-cs-fixer psalm deptrac phpunit ci 114 | 115 | ################################# 116 | Tools: 117 | 118 | ## Fix PHP files to be compliant with coding standards 119 | fix-cs: 120 | @$(EXEC) vendor/bin/php-cs-fixer fix 121 | 122 | .PHONY: fix-cs 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Sourcing with API Platform 3 and Ecotone 2 | 3 | This example project is based on the great work done in [Domain Driven Design and API Platform 3](https://github.com/mtarld/apip-ddd). 4 | 5 | The architecture follows: 6 | - Domain Driven Design and hexagonal architecture 7 | - CQRS 8 | - Event Sourcing 9 | 10 | It uses [API Platform](https://api-platform.com/) for exposing the API and [Ecotone](https://ecotone.tech/) for managing Event Sourcing. 11 | 12 | ## Getting Started 13 | If you want to try to use and tweak that example, you can follow these steps: 14 | 15 | 1. Run `git clone https://github.com/alanpoulain/apip-eventsourcing` to clone the project 16 | 2. Run `make install` to install the project 17 | 3. Run `make start` to up your containers 18 | 4. Visit https://localhost/api and play with your app! 19 | 20 | ## Contributing 21 | That implementation is pragmatic and far for being uncriticable. 22 | It's mainly a conceptual approach to use API Platform in order to defer operations to command and query buses. 23 | 24 | It could and should be improved, therefore feel free to submit issues and pull requests if something isn't relevant to your use cases or isn't clean enough. 25 | 26 | To ensure that the CI will succeed whenever contributing, make sure that either static analysis and tests are successful by running `make ci` 27 | 28 | ## Authors 29 | [Alan Poulain](https://github.com/alanpoulain) 30 | 31 | [Mathias Arlaud](https://github.com/mtarld) with the help of [Robin Chalas](https://github.com/chalasr) 32 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.2", 17 | "ext-ctype": "*", 18 | "ext-iconv": "*", 19 | "api-platform/core": "^3.1", 20 | "doctrine/annotations": "^2.0", 21 | "doctrine/doctrine-bundle": "^2.5", 22 | "doctrine/orm": "^2.11", 23 | "ecotone/jms-converter": "^1.74", 24 | "ecotone/pdo-event-sourcing": "^1.74", 25 | "ecotone/symfony-bundle": "^1.74", 26 | "nelmio/cors-bundle": "^2.2", 27 | "phpdocumentor/reflection-docblock": "^5.3", 28 | "phpstan/phpdoc-parser": "^1.2", 29 | "symfony/asset": "6.2.*", 30 | "symfony/console": "6.2.*", 31 | "symfony/dotenv": "6.2.*", 32 | "symfony/expression-language": "6.2.*", 33 | "symfony/flex": "^2", 34 | "symfony/framework-bundle": "6.2.*", 35 | "symfony/messenger": "6.2.*", 36 | "symfony/monolog-bundle": "^3.0", 37 | "symfony/property-access": "6.2.*", 38 | "symfony/property-info": "6.2.*", 39 | "symfony/proxy-manager-bridge": "6.2.*", 40 | "symfony/runtime": "6.2.*", 41 | "symfony/security-bundle": "6.2.*", 42 | "symfony/serializer": "6.2.*", 43 | "symfony/twig-bundle": "6.2.*", 44 | "symfony/uid": "6.2.*", 45 | "symfony/validator": "6.2.*", 46 | "symfony/yaml": "6.2.*", 47 | "webmozart/assert": "^1.10" 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "composer/package-versions-deprecated": true, 52 | "symfony/flex": true, 53 | "symfony/runtime": true 54 | }, 55 | "optimize-autoloader": true, 56 | "preferred-install": { 57 | "*": "dist" 58 | }, 59 | "sort-packages": true 60 | }, 61 | "autoload": { 62 | "psr-4": { 63 | "App\\": "src/" 64 | } 65 | }, 66 | "autoload-dev": { 67 | "psr-4": { 68 | "App\\Tests\\": "tests/" 69 | } 70 | }, 71 | "replace": { 72 | "symfony/polyfill-ctype": "*", 73 | "symfony/polyfill-iconv": "*", 74 | "symfony/polyfill-php72": "*", 75 | "symfony/polyfill-php73": "*", 76 | "symfony/polyfill-php74": "*", 77 | "symfony/polyfill-php80": "*" 78 | }, 79 | "scripts": { 80 | "auto-scripts": { 81 | "cache:clear": "symfony-cmd", 82 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 83 | }, 84 | "post-install-cmd": [ 85 | "@auto-scripts" 86 | ], 87 | "post-update-cmd": [ 88 | "@auto-scripts" 89 | ] 90 | }, 91 | "conflict": { 92 | "symfony/symfony": "*" 93 | }, 94 | "extra": { 95 | "symfony": { 96 | "allow-contrib": false, 97 | "require": "6.2.*", 98 | "docker": true, 99 | "endpoint": [ 100 | "https://raw.githubusercontent.com/schranz-php-recipes/symfony-recipes-php/flex/main/index.json", 101 | "https://raw.githubusercontent.com/schranz-php-recipes/symfony-recipes-php-contrib/flex/main/index.json", 102 | "flex://defaults" 103 | ] 104 | } 105 | }, 106 | "require-dev": { 107 | "friendsofphp/php-cs-fixer": "^3.15", 108 | "justinrainbow/json-schema": "^5.2", 109 | "phpunit/phpunit": "^9.5", 110 | "qossmic/deptrac-shim": "^1.0", 111 | "symfony/browser-kit": "6.2.*", 112 | "symfony/css-selector": "6.2.*", 113 | "symfony/debug-bundle": "6.2.*", 114 | "symfony/http-client": "6.2.*", 115 | "symfony/phpunit-bridge": "6.2.*", 116 | "symfony/stopwatch": "6.2.*", 117 | "symfony/web-profiler-bundle": "6.2.*", 118 | "vimeo/psalm": "^5.8" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 7 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 8 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 9 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 10 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 11 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 12 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 13 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 14 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 15 | Ecotone\SymfonyBundle\EcotoneSymfonyBundle::class => ['all' => true], 16 | ]; 17 | -------------------------------------------------------------------------------- /config/packages/api_platform.php: -------------------------------------------------------------------------------- 1 | extension('api_platform', [ 10 | 'mapping' => [ 11 | 'paths' => [ 12 | '%kernel.project_dir%/src/BookStore/Infrastructure/ApiPlatform/Resource/', 13 | '%kernel.project_dir%/src/Subscription/Entity/', 14 | ], 15 | ], 16 | 'patch_formats' => [ 17 | 'json' => ['application/merge-patch+json'], 18 | ], 19 | 'swagger' => [ 20 | 'versions' => [3], 21 | ], 22 | 'exception_to_status' => [ 23 | // TODO 24 | // We must trigger the API Platform validator before the data transforming. 25 | // Let's create an API Platform PR to update the AbstractItemNormalizer. 26 | // In that way, this exception won't be raised anymore as payload will be validated (see DiscountBookPayload). 27 | InvalidArgumentException::class => 422, 28 | ], 29 | ]); 30 | }; 31 | -------------------------------------------------------------------------------- /config/packages/debug.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $containerConfigurator->extension('debug', [ 10 | 'dump_destination' => 'tcp://%env(VAR_DUMPER_SERVER)%', 11 | ]); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/doctrine.php: -------------------------------------------------------------------------------- 1 | extension( 9 | 'doctrine', 10 | [ 11 | 'dbal' => [ 12 | 'url' => '%env(resolve:DATABASE_URL)%', 13 | ], 14 | 'orm' => [ 15 | 'auto_generate_proxy_classes' => true, 16 | 'enable_lazy_ghost_objects' => false, 17 | 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', 18 | 'auto_mapping' => true, 19 | 'mappings' => [ 20 | 'BookStore' => [ 21 | 'is_bundle' => false, 22 | 'type' => 'attribute', 23 | 'dir' => '%kernel.project_dir%/src/BookStore/Domain', 24 | 'prefix' => 'App\BookStore\Domain', 25 | ], 26 | 'Shared' => [ 27 | 'is_bundle' => false, 28 | 'type' => 'attribute', 29 | 'dir' => '%kernel.project_dir%/src/Shared/Domain', 30 | 'prefix' => 'App\Shared\Domain', 31 | ], 32 | 'Subscription' => [ 33 | 'is_bundle' => false, 34 | 'type' => 'attribute', 35 | 'dir' => '%kernel.project_dir%/src/Subscription/Entity', 36 | 'prefix' => 'App\Subscription\Entity', 37 | ], 38 | ], 39 | ], 40 | ] 41 | ); 42 | if ('test' === $containerConfigurator->env()) { 43 | $containerConfigurator->extension('doctrine', [ 44 | 'dbal' => [ 45 | 'dbname_suffix' => '_test%env(default::TEST_TOKEN)%', 46 | ], 47 | ]); 48 | } 49 | if ('prod' === $containerConfigurator->env()) { 50 | $containerConfigurator->extension('doctrine', [ 51 | 'orm' => [ 52 | 'auto_generate_proxy_classes' => false, 53 | 'proxy_dir' => '%kernel.build_dir%/doctrine/orm/Proxies', 54 | 'query_cache_driver' => [ 55 | 'type' => 'pool', 56 | 'pool' => 'doctrine.system_cache_pool', 57 | ], 58 | 'result_cache_driver' => [ 59 | 'type' => 'pool', 60 | 'pool' => 'doctrine.result_cache_pool', 61 | ], 62 | ], 63 | ]); 64 | $containerConfigurator->extension('framework', [ 65 | 'cache' => [ 66 | 'pools' => [ 67 | 'doctrine.result_cache_pool' => [ 68 | 'adapter' => 'cache.app', 69 | ], 70 | 'doctrine.system_cache_pool' => [ 71 | 'adapter' => 'cache.system', 72 | ], 73 | ], 74 | ], 75 | ]); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /config/packages/ecotone.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $containerConfigurator->extension('ecotone', [ 10 | 'namespaces' => ['App\Tests'], 11 | ]); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/framework.php: -------------------------------------------------------------------------------- 1 | extension( 9 | 'framework', 10 | [ 11 | 'secret' => '%env(APP_SECRET)%', 12 | 'http_method_override' => false, 13 | 'handle_all_throwables' => true, 14 | 'session' => [ 15 | 'handler_id' => null, 16 | 'cookie_secure' => 'auto', 17 | 'cookie_samesite' => 'lax', 18 | 'storage_factory_id' => 'session.storage.factory.native', 19 | ], 20 | 'php_errors' => [ 21 | 'log' => 4096, 22 | ], 23 | ] 24 | ); 25 | if ('test' === $containerConfigurator->env()) { 26 | $containerConfigurator->extension('framework', [ 27 | 'test' => true, 28 | 'session' => [ 29 | 'storage_factory_id' => 'session.storage.factory.mock_file', 30 | ], 31 | ]); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /config/packages/messenger.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 11 | 'messenger' => [ 12 | 'default_bus' => 'command.bus', 13 | 'buses' => [ 14 | 'command.bus' => [], 15 | 'query.bus' => [], 16 | ], 17 | 'transports' => [ 18 | 'sync' => 'sync://', 19 | ], 20 | 'routing' => [ 21 | QueryInterface::class => 'sync', 22 | CommandInterface::class => 'sync', 23 | ], 24 | ], 25 | ]); 26 | }; 27 | -------------------------------------------------------------------------------- /config/packages/monolog.php: -------------------------------------------------------------------------------- 1 | extension('monolog', [ 9 | 'channels' => ['deprecation'], 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.php: -------------------------------------------------------------------------------- 1 | extension( 9 | 'nelmio_cors', 10 | [ 11 | 'defaults' => [ 12 | 'origin_regex' => true, 13 | 'allow_origin' => [ 14 | '%env(CORS_ALLOW_ORIGIN)%', 15 | ], 16 | 'allow_methods' => ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'], 17 | 'allow_headers' => ['Content-Type', 'Authorization'], 18 | 'expose_headers' => ['Link'], 19 | 'max_age' => 3600, 20 | ], 21 | 'paths' => ['^/' => null], 22 | ] 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /config/packages/routing.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'router' => [ 10 | 'utf8' => true, 11 | ], 12 | ]); 13 | if ('prod' === $containerConfigurator->env()) { 14 | $containerConfigurator->extension('framework', [ 15 | 'router' => [ 16 | 'strict_requirements' => null, 17 | ], 18 | ]); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /config/packages/security.php: -------------------------------------------------------------------------------- 1 | extension('security', [ 10 | 'password_hashers' => [ 11 | PasswordAuthenticatedUserInterface::class => 'auto', 12 | ], 13 | 'providers' => [ 14 | 'users_in_memory' => [ 15 | 'memory' => null, 16 | ], 17 | ], 18 | 'firewalls' => [ 19 | 'dev' => [ 20 | 'pattern' => '^/(_(profiler|wdt)|css|images|js)/', 21 | 'security' => false, 22 | ], 23 | 'main' => [ 24 | 'lazy' => true, 25 | 'provider' => 'users_in_memory', 26 | ], 27 | ], 28 | 'access_control' => null, 29 | ]); 30 | if ('test' === $containerConfigurator->env()) { 31 | $containerConfigurator->extension('security', [ 32 | 'password_hashers' => [ 33 | PasswordAuthenticatedUserInterface::class => [ 34 | 'algorithm' => 'auto', 35 | 'cost' => 4, 36 | 'time_cost' => 3, 37 | 'memory_cost' => 10, 38 | ], 39 | ], 40 | ]); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /config/packages/twig.php: -------------------------------------------------------------------------------- 1 | extension('twig', [ 9 | 'default_path' => '%kernel.project_dir%/templates', 10 | ]); 11 | if ('test' === $containerConfigurator->env()) { 12 | $containerConfigurator->extension('twig', [ 13 | 'strict_variables' => true, 14 | ]); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /config/packages/validator.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'validation' => [ 10 | 'email_validation_mode' => 'html5', 11 | ], 12 | ]); 13 | if ('test' === $containerConfigurator->env()) { 14 | $containerConfigurator->extension('framework', [ 15 | 'validation' => [ 16 | 'not_compromised_password' => false, 17 | ], 18 | ]); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /config/packages/web_profiler.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $containerConfigurator->extension('web_profiler', [ 10 | 'toolbar' => true, 11 | 'intercept_redirects' => false, 12 | ]); 13 | $containerConfigurator->extension('framework', [ 14 | 'profiler' => [ 15 | 'only_exceptions' => false, 16 | 'collect_serializer_data' => true, 17 | ], 18 | ]); 19 | } 20 | if ('test' === $containerConfigurator->env()) { 21 | $containerConfigurator->extension('web_profiler', [ 22 | 'toolbar' => false, 23 | 'intercept_redirects' => false, 24 | ]); 25 | $containerConfigurator->extension('framework', [ 26 | 'profiler' => [ 27 | 'collect' => false, 28 | ], 29 | ]); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | import('.', 'api_platform') 9 | ->prefix('/api'); 10 | }; 11 | -------------------------------------------------------------------------------- /config/routes/framework.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $routingConfigurator->import('@FrameworkBundle/Resources/config/routing/errors.xml') 10 | ->prefix('/_error'); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /config/routes/web_profiler.php: -------------------------------------------------------------------------------- 1 | env()) { 9 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/wdt.xml') 10 | ->prefix('/_wdt'); 11 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.xml') 12 | ->prefix('/_profiler'); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /config/services/book_store.php: -------------------------------------------------------------------------------- 1 | services(); 19 | 20 | $services->defaults() 21 | ->autowire() 22 | ->autoconfigure(); 23 | 24 | $services->load('App\\BookStore\\', __DIR__.'/../../src/BookStore'); 25 | 26 | // providers 27 | $services->set(CheapestBooksProvider::class) 28 | ->autoconfigure(false) 29 | ->tag('api_platform.state_provider', ['priority' => 1]); 30 | 31 | $services->set(BookItemProvider::class) 32 | ->autoconfigure(false) 33 | ->tag('api_platform.state_provider', ['priority' => 0]); 34 | 35 | $services->set(BookCollectionProvider::class) 36 | ->autoconfigure(false) 37 | ->tag('api_platform.state_provider', ['priority' => 0]); 38 | 39 | // processors 40 | $services->set(AnonymizeBooksProcessor::class) 41 | ->autoconfigure(false) 42 | ->tag('api_platform.state_processor', ['priority' => 1]); 43 | 44 | $services->set(DiscountBookProcessor::class) 45 | ->autoconfigure(false) 46 | ->tag('api_platform.state_processor', ['priority' => 1]); 47 | 48 | $services->set(CreateBookProcessor::class) 49 | ->autoconfigure(false) 50 | ->tag('api_platform.state_processor', ['priority' => 0]); 51 | 52 | $services->set(UpdateBookProcessor::class) 53 | ->autoconfigure(false) 54 | ->tag('api_platform.state_processor', ['priority' => 0]); 55 | 56 | $services->set(DeleteBookProcessor::class) 57 | ->autoconfigure(false) 58 | ->tag('api_platform.state_processor', ['priority' => 0]); 59 | 60 | // repositories 61 | $services->set(BookRepositoryInterface::class) 62 | ->class(BookRepository::class); 63 | }; 64 | -------------------------------------------------------------------------------- /config/services/shared.php: -------------------------------------------------------------------------------- 1 | services(); 18 | 19 | $services->defaults() 20 | ->autowire() 21 | ->autoconfigure(); 22 | 23 | $services->load('App\\Shared\\', __DIR__.'/../../src/Shared') 24 | ->exclude([__DIR__.'/../../src/Shared/Infrastructure/Kernel.php']); 25 | 26 | $services->alias(CommandBusInterface::class, CommandBus::class); 27 | $services->alias(QueryBusInterface::class, QueryBus::class); 28 | 29 | $services->set(DbalConnectionFactory::class) 30 | ->factory([DbalConnection::class, 'create']) 31 | ->args([service(Connection::class)]); 32 | }; 33 | -------------------------------------------------------------------------------- /config/services/subscription.php: -------------------------------------------------------------------------------- 1 | services(); 9 | 10 | $services->defaults() 11 | ->autowire() 12 | ->autoconfigure(); 13 | 14 | $services->load('App\\Subscription\\', __DIR__.'/../../src/Subscription'); 15 | }; 16 | -------------------------------------------------------------------------------- /deptrac_bc.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - ./src 4 | 5 | layers: 6 | - name: BookStore 7 | collectors: 8 | - type: directory 9 | regex: src/BookStore/.* 10 | 11 | - name: Subscription 12 | collectors: 13 | - type: directory 14 | regex: src/Subscription/.* 15 | 16 | - name: Shared 17 | collectors: 18 | - type: directory 19 | regex: src/Shared/.* 20 | 21 | - name: Vendors 22 | collectors: 23 | - { type: className, regex: ^ApiPlatform\\ } 24 | - { type: className, regex: ^Symfony\\ } 25 | - { type: className, regex: ^Doctrine\\ } 26 | - { type: className, regex: ^Webmozart\\ } 27 | - { type: className, regex: ^Ecotone\\ } 28 | - { type: className, regex: ^Prooph\\ } 29 | 30 | ruleset: 31 | BookStore: [ Shared, Vendors ] 32 | Subscription: [ Shared, Vendors ] 33 | Shared: [ Vendors ] 34 | -------------------------------------------------------------------------------- /deptrac_hexa.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - ./src/BookStore 4 | - ./src/Shared 5 | 6 | layers: 7 | - name: Domain 8 | collectors: 9 | - type: directory 10 | regex: .+/Domain/.* 11 | 12 | - name: Application 13 | collectors: 14 | - type: directory 15 | regex: .+/Application/.* 16 | 17 | - name: Infrastructure 18 | collectors: 19 | - type: directory 20 | regex: .+/Infrastructure/.* 21 | 22 | - name: Vendors 23 | collectors: 24 | - { type: className, regex: ^ApiPlatform\\ } 25 | - { type: className, regex: ^Symfony\\(?!(Component\\Uid\\)) } 26 | - { type: className, regex: ^Doctrine\\(?!(ORM\\Mapping)) } 27 | - { type: className, regex: ^Webmozart\\(?!Assert\\Assert) } 28 | - { type: className, regex: ^Ecotone\\(?!Modelling\\(?:Attribute|WithAggregateVersioning)) } 29 | - { type: className, regex: ^Prooph\\ } 30 | 31 | - name: Attributes 32 | collectors: 33 | - { type: className, regex: ^Doctrine\\ORM\\Mapping } 34 | - { type: className, regex: ^Ecotone\\Modelling\\Attribute } 35 | 36 | - name: Helpers 37 | collectors: 38 | - { type: className, regex: ^Symfony\\Component\\Uid\\ } 39 | - { type: className, regex: ^Webmozart\\Assert\\Assert } 40 | - { type: className, regex: ^Ecotone\\Modelling\\WithAggregateVersioning } 41 | 42 | ruleset: 43 | Domain: 44 | - Helpers 45 | - Attributes 46 | 47 | Application: 48 | - Domain 49 | - Helpers 50 | - Attributes 51 | 52 | Infrastructure: 53 | - Domain 54 | - Application 55 | - Vendors 56 | - Helpers 57 | - Attributes 58 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | php: 5 | volumes: 6 | # The "cached" option has no effect on Linux but improves performance on Mac 7 | - ./:/srv/app:rw,cached 8 | - ./docker/php/conf.d/symfony.dev.ini:/usr/local/etc/php/conf.d/symfony.ini 9 | # If you develop on Mac you can remove the var/ directory from the bind-mount 10 | # for better performance by enabling the next line 11 | - /srv/app/var 12 | environment: 13 | APP_ENV: dev 14 | 15 | caddy: 16 | volumes: 17 | - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro 18 | - ./public:/srv/app/public:ro 19 | 20 | ###> symfony/mercure-bundle ### 21 | ###< symfony/mercure-bundle ### 22 | 23 | ###> doctrine/doctrine-bundle ### 24 | database: 25 | ports: 26 | - "5433:5432" 27 | ###< doctrine/doctrine-bundle ### 28 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | # Production environment override 4 | services: 5 | php: 6 | environment: 7 | APP_ENV: prod 8 | APP_SECRET: ${APP_SECRET} 9 | MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET} 10 | 11 | caddy: 12 | environment: 13 | MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} 14 | MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | php: 5 | build: 6 | context: . 7 | target: symfony_php 8 | args: 9 | SYMFONY_VERSION: ${SYMFONY_VERSION:-} 10 | SKELETON: ${SKELETON:-symfony/skeleton} 11 | STABILITY: ${STABILITY:-stable} 12 | restart: unless-stopped 13 | volumes: 14 | - php_socket:/var/run/php 15 | healthcheck: 16 | interval: 10s 17 | timeout: 3s 18 | retries: 3 19 | start_period: 30s 20 | environment: 21 | MERCURE_URL: ${CADDY_MERCURE_URL:-http://caddy/.well-known/mercure} 22 | MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure 23 | MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!} 24 | 25 | caddy: 26 | build: 27 | context: . 28 | target: symfony_caddy 29 | depends_on: 30 | - php 31 | environment: 32 | SERVER_NAME: ${SERVER_NAME:-localhost, caddy:80} 33 | MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!} 34 | MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!} 35 | restart: unless-stopped 36 | volumes: 37 | - php_socket:/var/run/php 38 | - caddy_data:/data 39 | - caddy_config:/config 40 | ports: 41 | # HTTP 42 | - target: 80 43 | published: ${HTTP_PORT:-80} 44 | protocol: tcp 45 | # HTTPS 46 | - target: 443 47 | published: ${HTTPS_PORT:-443} 48 | protocol: tcp 49 | # HTTP/3 50 | - target: 443 51 | published: ${HTTP3_PORT:-443} 52 | protocol: udp 53 | 54 | # Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service 55 | ###> symfony/mercure-bundle ### 56 | ###< symfony/mercure-bundle ### 57 | 58 | ###> doctrine/doctrine-bundle ### 59 | database: 60 | image: postgres:${POSTGRES_VERSION:-15}-alpine 61 | environment: 62 | POSTGRES_DB: ${POSTGRES_DB:-app} 63 | # You should definitely change the password in production 64 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} 65 | POSTGRES_USER: ${POSTGRES_USER:-symfony} 66 | volumes: 67 | - database_data:/var/lib/postgresql/data:rw 68 | # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! 69 | # - ./docker/db/data:/var/lib/postgresql/data:rw 70 | ###< doctrine/doctrine-bundle ### 71 | 72 | volumes: 73 | php_socket: 74 | caddy_data: 75 | caddy_config: 76 | ###> symfony/mercure-bundle ### 77 | ###< symfony/mercure-bundle ### 78 | 79 | ###> doctrine/doctrine-bundle ### 80 | database_data: 81 | ###< doctrine/doctrine-bundle ### 82 | -------------------------------------------------------------------------------- /docker/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | # Debug 3 | {$DEBUG} 4 | } 5 | 6 | {$SERVER_NAME} 7 | 8 | log 9 | 10 | route { 11 | root * /srv/app/public 12 | mercure { 13 | # Transport to use (default to Bolt) 14 | transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} 15 | # Publisher JWT key 16 | publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} 17 | # Subscriber JWT key 18 | subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} 19 | # Allow anonymous subscribers (double-check that it's what you want) 20 | anonymous 21 | # Enable the subscription API (double-check that it's what you want) 22 | subscriptions 23 | # Extra directives 24 | {$MERCURE_EXTRA_DIRECTIVES} 25 | } 26 | vulcain 27 | push 28 | php_fastcgi unix//var/run/php/php-fpm.sock 29 | encode zstd gzip 30 | file_server 31 | } 32 | -------------------------------------------------------------------------------- /docker/php/conf.d/symfony.dev.ini: -------------------------------------------------------------------------------- 1 | apc.enable_cli = 1 2 | date.timezone = UTC 3 | session.auto_start = Off 4 | short_open_tag = Off 5 | 6 | # http://symfony.com/doc/current/performance.html 7 | opcache.interned_strings_buffer = 16 8 | opcache.max_accelerated_files = 20000 9 | opcache.memory_consumption = 256 10 | realpath_cache_size = 4096K 11 | realpath_cache_ttl = 600 12 | -------------------------------------------------------------------------------- /docker/php/conf.d/symfony.prod.ini: -------------------------------------------------------------------------------- 1 | apc.enable_cli = 1 2 | date.timezone = UTC 3 | session.auto_start = Off 4 | short_open_tag = Off 5 | expose_php = Off 6 | 7 | # https://symfony.com/doc/current/performance.html 8 | opcache.interned_strings_buffer = 16 9 | opcache.max_accelerated_files = 20000 10 | opcache.memory_consumption = 256 11 | opcache.validate_timestamps = 0 12 | realpath_cache_size = 4096K 13 | realpath_cache_ttl = 600 14 | opcache.preload_user = www-data 15 | opcache.preload = /srv/app/config/preload.php 16 | -------------------------------------------------------------------------------- /docker/php/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # first arg is `-f` or `--some-option` 5 | if [ "${1#-}" != "$1" ]; then 6 | set -- php-fpm "$@" 7 | fi 8 | 9 | if [ "$1" = 'php-fpm' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then 10 | PHP_INI_RECOMMENDED="$PHP_INI_DIR/php.ini-production" 11 | if [ "$APP_ENV" != 'prod' ]; then 12 | PHP_INI_RECOMMENDED="$PHP_INI_DIR/php.ini-development" 13 | fi 14 | ln -sf "$PHP_INI_RECOMMENDED" "$PHP_INI_DIR/php.ini" 15 | 16 | mkdir -p var/cache var/log 17 | 18 | # The first time volumes are mounted, the project needs to be recreated 19 | if [ ! -f composer.json ]; then 20 | CREATION=1 21 | composer create-project "$SKELETON $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install 22 | 23 | cd tmp 24 | composer require "php:>=$PHP_VERSION" 25 | composer config --json extra.symfony.docker 'true' 26 | cp -Rp . .. 27 | cd - 28 | 29 | rm -Rf tmp/ 30 | fi 31 | 32 | if [ "$APP_ENV" != 'prod' ]; then 33 | rm -f .env.local.php 34 | composer install --prefer-dist --no-progress --no-interaction 35 | fi 36 | 37 | if grep -q ^DATABASE_URL= .env; then 38 | if [ "$CREATION" = "1" ]; then 39 | echo "To finish the installation please press Ctrl+C to stop Docker Compose and run: docker-compose up --build" 40 | sleep infinity 41 | fi 42 | 43 | echo "Waiting for db to be ready..." 44 | ATTEMPTS_LEFT_TO_REACH_DATABASE=60 45 | until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(bin/console dbal:run-sql "SELECT 1" 2>&1); do 46 | if [ $? -eq 255 ]; then 47 | # If the Doctrine command exits with 255, an unrecoverable error occurred 48 | ATTEMPTS_LEFT_TO_REACH_DATABASE=0 49 | break 50 | fi 51 | sleep 1 52 | ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) 53 | echo "Still waiting for db to be ready... Or maybe the db is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left" 54 | done 55 | 56 | if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then 57 | echo "The database is not up or not reachable:" 58 | echo "$DATABASE_ERROR" 59 | exit 1 60 | else 61 | echo "The db is now ready and reachable" 62 | fi 63 | 64 | if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then 65 | bin/console doctrine:migrations:migrate --no-interaction 66 | fi 67 | fi 68 | 69 | setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 70 | setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 71 | fi 72 | 73 | exec docker-php-entrypoint "$@" 74 | -------------------------------------------------------------------------------- /docker/php/docker-healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if env -i REQUEST_METHOD=GET SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping cgi-fcgi -bind -connect /var/run/php/php-fpm.sock; then 5 | exit 0 6 | fi 7 | 8 | exit 1 9 | -------------------------------------------------------------------------------- /docker/php/php-fpm.d/zz-docker.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | daemonize = no 3 | process_control_timeout = 20 4 | 5 | [www] 6 | listen = /var/run/php/php-fpm.sock 7 | listen.mode = 0666 8 | ping.path = /ping 9 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests/BookStore 23 | 24 | 25 | tests/Subscription 26 | 27 | 28 | 29 | 30 | 31 | src 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanpoulain/apip-eventsourcing/820e7a2f9fb818a2aed6630f6a7813787bc0655a/public/.gitignore -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | bookRepository->all(); 25 | 26 | foreach ($books as $book) { 27 | $this->commandBus->dispatch(new UpdateBookCommand( 28 | id: $book->id(), 29 | author: new Author($command->anonymizedName), 30 | )); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/BookStore/Application/Query/FindBookEventsQueryHandler.php: -------------------------------------------------------------------------------- 1 | repository->findEvents($query->id); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/BookStore/Application/Query/FindBookQueryHandler.php: -------------------------------------------------------------------------------- 1 | repository->ofId($query->id); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/BookStore/Application/Query/FindBooksQueryHandler.php: -------------------------------------------------------------------------------- 1 | author) { 22 | return $this->bookRepository->findByAuthor($query->author); 23 | } 24 | 25 | if (null !== $query->page && null !== $query->itemsPerPage) { 26 | return $this->bookRepository->paginator($query->page, $query->itemsPerPage); 27 | } 28 | 29 | return $this->bookRepository->all(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/BookStore/Application/Query/FindCheapestBooksQueryHandler.php: -------------------------------------------------------------------------------- 1 | bookRepository->findCheapest($query->size); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Command/AnonymizeBooksCommand.php: -------------------------------------------------------------------------------- 1 | id; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Event/BookWasDeleted.php: -------------------------------------------------------------------------------- 1 | id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Event/BookWasDiscounted.php: -------------------------------------------------------------------------------- 1 | id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Event/BookWasUpdated.php: -------------------------------------------------------------------------------- 1 | id; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Exception/MissingBookException.php: -------------------------------------------------------------------------------- 1 | name, 51 | description: $command->description, 52 | author: $command->author, 53 | content: $command->content, 54 | price: $command->price, 55 | ), 56 | ]; 57 | } 58 | 59 | #[CommandHandler] 60 | public function update(UpdateBookCommand $command): array 61 | { 62 | return [ 63 | new BookWasUpdated( 64 | id: $command->id, 65 | name: $command->name, 66 | description: $command->description, 67 | author: $command->author, 68 | content: $command->content, 69 | price: $command->price, 70 | ), 71 | ]; 72 | } 73 | 74 | #[CommandHandler] 75 | public function delete(DeleteBookCommand $command): array 76 | { 77 | return [new BookWasDeleted($command->id)]; 78 | } 79 | 80 | #[CommandHandler] 81 | public function discount(DiscountBookCommand $command): array 82 | { 83 | return [new BookWasDiscounted( 84 | id: $command->id, 85 | discount: $command->discount, 86 | )]; 87 | } 88 | 89 | #[EventSourcingHandler] 90 | public function applyBookWasCreated(BookWasCreated $event): void 91 | { 92 | $this->id = $event->id(); 93 | $this->name = $event->name; 94 | $this->description = $event->description; 95 | $this->author = $event->author; 96 | $this->content = $event->content; 97 | $this->price = $event->price; 98 | } 99 | 100 | #[EventSourcingHandler] 101 | public function applyBookWasUpdated(BookWasUpdated $event): void 102 | { 103 | $this->name = $event->name ?? $this->name; 104 | $this->description = $event->description ?? $this->description; 105 | $this->author = $event->author ?? $this->author; 106 | $this->content = $event->content ?? $this->content; 107 | $this->price = $event->price ?? $this->price; 108 | } 109 | 110 | #[EventSourcingHandler] 111 | public function applyBookWasDeleted(BookWasDeleted $event): void 112 | { 113 | $this->deleted = true; 114 | } 115 | 116 | #[EventSourcingHandler] 117 | public function applyBookWasDiscounted(BookWasDiscounted $event): void 118 | { 119 | $this->price = $this->price->applyDiscount($event->discount); 120 | } 121 | 122 | public function id(): BookId 123 | { 124 | return $this->id; 125 | } 126 | 127 | public function name(): BookName 128 | { 129 | return $this->name; 130 | } 131 | 132 | public function description(): BookDescription 133 | { 134 | return $this->description; 135 | } 136 | 137 | public function author(): Author 138 | { 139 | return $this->author; 140 | } 141 | 142 | public function content(): BookContent 143 | { 144 | return $this->content; 145 | } 146 | 147 | public function price(): Price 148 | { 149 | return $this->price; 150 | } 151 | 152 | public function deleted(): bool 153 | { 154 | return $this->deleted; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Query/FindBookEventsQuery.php: -------------------------------------------------------------------------------- 1 | */ 18 | public function findEvents(BookId $id): iterable; 19 | 20 | /** @return iterable */ 21 | public function all(): iterable; 22 | 23 | /** 24 | * @return PaginatorInterface 25 | */ 26 | public function paginator(int $page, int $itemsPerPage): PaginatorInterface; 27 | 28 | /** @return iterable */ 29 | public function findByAuthor(Author $author): iterable; 30 | 31 | /** @return iterable */ 32 | public function findCheapest(int $size): iterable; 33 | } 34 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/Author.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | 20 | public function isEqualTo(self $author): bool 21 | { 22 | return $author->value === $this->value; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/BookContent.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/BookDescription.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/BookId.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/Discount.php: -------------------------------------------------------------------------------- 1 | percentage = $percentage; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/Price.php: -------------------------------------------------------------------------------- 1 | amount = $amount; 18 | } 19 | 20 | public function applyDiscount(Discount $discount): static 21 | { 22 | $amount = (int) ($this->amount - ($this->amount * $discount->percentage / 100)); 23 | 24 | return new static($amount); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/OpenApi/AuthorFilter.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'property' => 'author', 17 | 'type' => Type::BUILTIN_TYPE_STRING, 18 | 'required' => false, 19 | ], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/Payload/DiscountBookPayload.php: -------------------------------------------------------------------------------- 1 | 'Find cheapest Book resources.'], 40 | paginationEnabled: false, 41 | provider: CheapestBooksProvider::class, 42 | ), 43 | new GetCollection( 44 | '/books/{id}/events.{_format}', 45 | openapiContext: ['summary' => 'Get events for this Book.'], 46 | output: BookEvent::class, 47 | provider: BookEventsProvider::class, 48 | ), 49 | 50 | // commands 51 | new Post( 52 | '/books/anonymize.{_format}', 53 | status: 202, 54 | openapiContext: ['summary' => 'Anonymize author of every Book resources.'], 55 | input: AnonymizeBooksCommand::class, 56 | output: false, 57 | processor: AnonymizeBooksProcessor::class, 58 | ), 59 | new Post( 60 | '/books/{id}/discount.{_format}', 61 | openapiContext: ['summary' => 'Apply a discount percentage on a Book resource.'], 62 | input: DiscountBookPayload::class, 63 | provider: BookItemProvider::class, 64 | processor: DiscountBookProcessor::class, 65 | ), 66 | 67 | // basic crud 68 | new GetCollection( 69 | filters: [AuthorFilter::class], 70 | provider: BookCollectionProvider::class, 71 | ), 72 | new Get( 73 | provider: BookItemProvider::class, 74 | ), 75 | new Post( 76 | validationContext: ['groups' => ['create']], 77 | processor: CreateBookProcessor::class, 78 | ), 79 | new Put( 80 | provider: BookItemProvider::class, 81 | processor: UpdateBookProcessor::class, 82 | extraProperties: ['standard_put' => true], 83 | ), 84 | new Patch( 85 | provider: BookItemProvider::class, 86 | processor: UpdateBookProcessor::class, 87 | ), 88 | new Delete( 89 | provider: BookItemProvider::class, 90 | processor: DeleteBookProcessor::class, 91 | ), 92 | ], 93 | )] 94 | final class BookResource 95 | { 96 | public function __construct( 97 | #[ApiProperty(identifier: true, readable: false, writable: false)] 98 | public ?AbstractUid $id = null, 99 | 100 | #[Assert\NotNull(groups: ['create'])] 101 | #[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])] 102 | public ?string $name = null, 103 | 104 | #[Assert\NotNull(groups: ['create'])] 105 | #[Assert\Length(min: 1, max: 1023, groups: ['create', 'Default'])] 106 | public ?string $description = null, 107 | 108 | #[Assert\NotNull(groups: ['create'])] 109 | #[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])] 110 | public ?string $author = null, 111 | 112 | #[Assert\NotNull(groups: ['create'])] 113 | #[Assert\Length(min: 1, max: 65535, groups: ['create', 'Default'])] 114 | public ?string $content = null, 115 | 116 | #[Assert\NotNull(groups: ['create'])] 117 | #[Assert\PositiveOrZero(groups: ['create', 'Default'])] 118 | public ?int $price = null, 119 | ) { 120 | } 121 | 122 | public static function fromModel(Book $book): static 123 | { 124 | return new self( 125 | Uuid::fromString($book->id()->value), 126 | $book->name()->value, 127 | $book->description()->value, 128 | $book->author()->value, 129 | $book->content()->value, 130 | $book->price()->amount, 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/AnonymizeBooksProcessor.php: -------------------------------------------------------------------------------- 1 | commandBus->dispatch($data); 25 | 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/CreateBookProcessor.php: -------------------------------------------------------------------------------- 1 | name); 36 | Assert::notNull($data->description); 37 | Assert::notNull($data->author); 38 | Assert::notNull($data->content); 39 | Assert::notNull($data->price); 40 | 41 | $command = new CreateBookCommand( 42 | new BookName($data->name), 43 | new BookDescription($data->description), 44 | new Author($data->author), 45 | new BookContent($data->content), 46 | new Price($data->price), 47 | ); 48 | 49 | /** @var string $id */ 50 | $id = $this->commandBus->dispatch($command); 51 | 52 | /** @var Book $model */ 53 | $model = $this->queryBus->ask(new FindBookQuery(new BookId($id))); 54 | 55 | return BookResource::fromModel($model); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/DeleteBookProcessor.php: -------------------------------------------------------------------------------- 1 | commandBus->dispatch(new DeleteBookCommand(new BookId((string) $data->id))); 27 | 28 | return null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/DiscountBookProcessor.php: -------------------------------------------------------------------------------- 1 | id), 37 | new Discount($data->discountPercentage), 38 | ); 39 | 40 | $this->commandBus->dispatch($command); 41 | 42 | /** @var Book $model */ 43 | $model = $this->queryBus->ask(new FindBookQuery($command->id)); 44 | 45 | return BookResource::fromModel($model); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/UpdateBookProcessor.php: -------------------------------------------------------------------------------- 1 | id; 38 | 39 | $command = new UpdateBookCommand( 40 | new BookId($id), 41 | null !== $data->name && $previous->name !== $data->name ? new BookName($data->name) : null, 42 | null !== $data->description && $previous->description !== $data->description ? new BookDescription($data->description) : null, 43 | null !== $data->author && $previous->author !== $data->author ? new Author($data->author) : null, 44 | null !== $data->content && $previous->content !== $data->content ? new BookContent($data->content) : null, 45 | null !== $data->price && $previous->price !== $data->price ? new Price($data->price) : null, 46 | ); 47 | 48 | $this->commandBus->dispatch($command); 49 | 50 | /** @var Book $model */ 51 | $model = $this->queryBus->ask(new FindBookQuery(new BookId($id))); 52 | 53 | return BookResource::fromModel($model); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Provider/BookCollectionProvider.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final readonly class BookCollectionProvider implements ProviderInterface 23 | { 24 | public function __construct( 25 | private QueryBusInterface $queryBus, 26 | private Pagination $pagination, 27 | ) { 28 | } 29 | 30 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): ApiPlatformPaginatorInterface|array 31 | { 32 | /** @var string|null $author */ 33 | $author = $context['filters']['author'] ?? null; 34 | $offset = $limit = null; 35 | 36 | if ($this->pagination->isEnabled($operation, $context)) { 37 | $offset = $this->pagination->getPage($context); 38 | $limit = $this->pagination->getLimit($operation, $context); 39 | } 40 | 41 | /** @var iterable|PaginatorInterface $books */ 42 | $books = $this->queryBus->ask(new FindBooksQuery(null !== $author ? new Author($author) : null, $offset, $limit)); 43 | 44 | $resources = []; 45 | foreach ($books as $book) { 46 | $resources[] = BookResource::fromModel($book); 47 | } 48 | 49 | if (null !== $offset && null !== $limit && $books instanceof PaginatorInterface) { 50 | return new Paginator( 51 | new \ArrayIterator($resources), 52 | (float) $books->getCurrentPage(), 53 | (float) $books->getItemsPerPage(), 54 | (float) $books->getLastPage(), 55 | (float) $books->getTotalItems(), 56 | ); 57 | } 58 | 59 | return $resources; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Provider/BookEventsProvider.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final readonly class BookEventsProvider implements ProviderInterface 21 | { 22 | public function __construct( 23 | private QueryBusInterface $queryBus, 24 | private Pagination $pagination, 25 | ) { 26 | } 27 | 28 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): PaginatorInterface|array 29 | { 30 | /** @var string $id */ 31 | $id = $uriVariables['id']; 32 | 33 | /** @var \Generator $bookEvents */ 34 | $bookEvents = $this->queryBus->ask(new FindBookEventsQuery(new BookId($id))); 35 | $bookEvents = iterator_to_array($bookEvents); 36 | 37 | if ($this->pagination->isEnabled($operation, $context)) { 38 | $offset = $this->pagination->getPage($context); 39 | $limit = $this->pagination->getLimit($operation, $context); 40 | 41 | return new ArrayPaginator($bookEvents, ($offset - 1) * $limit, $limit); 42 | } 43 | 44 | return $bookEvents; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Provider/BookItemProvider.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final readonly class BookItemProvider implements ProviderInterface 19 | { 20 | public function __construct( 21 | private QueryBusInterface $queryBus, 22 | ) { 23 | } 24 | 25 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?BookResource 26 | { 27 | /** @var string $id */ 28 | $id = $uriVariables['id']; 29 | 30 | /** @var Book|null $model */ 31 | $model = $this->queryBus->ask(new FindBookQuery(new BookId($id))); 32 | 33 | return null !== $model ? BookResource::fromModel($model) : null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Provider/CheapestBooksProvider.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final readonly class CheapestBooksProvider implements ProviderInterface 18 | { 19 | public function __construct(private QueryBusInterface $queryBus) 20 | { 21 | } 22 | 23 | /** 24 | * @return list 25 | */ 26 | public function provide(Operation $operation, array $uriVariables = [], array $context = []): array 27 | { 28 | /** @var iterable $books */ 29 | $books = $this->queryBus->ask(new FindCheapestBooksQuery()); 30 | 31 | $resources = []; 32 | foreach ($books as $book) { 33 | $resources[] = BookResource::fromModel($book); 34 | } 35 | 36 | return $resources; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BookIdsGateway.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | #[ProjectionStateGateway(BookIdsProjection::NAME)] 15 | public function getBookIds(): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BookIdsProjection.php: -------------------------------------------------------------------------------- 1 | $bookIdsState 21 | * 22 | * @return list 23 | */ 24 | #[EventHandler] 25 | public function addBook(BookWasCreated $event, #[ProjectionState] array $bookIdsState): array 26 | { 27 | $bookIdsState[] = (string) $event->id(); 28 | 29 | return $bookIdsState; 30 | } 31 | 32 | /** 33 | * @param array $bookIdsState 34 | * 35 | * @return list 36 | */ 37 | #[EventHandler] 38 | public function removeBook(BookWasDeleted $event, #[ProjectionState] array $bookIdsState): array 39 | { 40 | if (false !== $index = array_search((string) $event->id(), $bookIdsState, true)) { 41 | unset($bookIdsState[$index]); 42 | } 43 | 44 | return array_values($bookIdsState); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BookPriceGateway.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | #[ProjectionStateGateway(BookPriceProjection::NAME)] 15 | public function getBookPriceList(): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BookPriceProjection.php: -------------------------------------------------------------------------------- 1 | $bookPriceState 24 | * 25 | * @return array 26 | */ 27 | #[EventHandler] 28 | public function addBook(BookWasCreated $event, #[ProjectionState] array $bookPriceState): array 29 | { 30 | $bookPriceState[(string) $event->id()] = $event->price->amount; 31 | 32 | return $bookPriceState; 33 | } 34 | 35 | /** 36 | * @param array $bookPriceState 37 | * 38 | * @return array 39 | */ 40 | #[EventHandler] 41 | public function updateBook(BookWasUpdated $event, #[ProjectionState] array $bookPriceState): array 42 | { 43 | if (!$event->price) { 44 | return $bookPriceState; 45 | } 46 | 47 | $bookPriceState[(string) $event->id()] = $event->price->amount; 48 | 49 | return $bookPriceState; 50 | } 51 | 52 | /** 53 | * @param array $bookPriceState 54 | * 55 | * @return array 56 | */ 57 | #[EventHandler] 58 | public function discountBook(BookWasDiscounted $event, #[ProjectionState] array $bookPriceState): array 59 | { 60 | $price = new Price($bookPriceState[(string) $event->id()]); 61 | 62 | $bookPriceState[(string) $event->id()] = $price->applyDiscount($event->discount)->amount; 63 | 64 | return $bookPriceState; 65 | } 66 | 67 | /** 68 | * @param array $bookPriceState 69 | * 70 | * @return array 71 | */ 72 | #[EventHandler] 73 | public function removeBook(BookWasDeleted $event, #[ProjectionState] array $bookPriceState): array 74 | { 75 | unset($bookPriceState[(string) $event->id()]); 76 | 77 | return $bookPriceState; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BooksByAuthorGateway.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | #[ProjectionStateGateway(BooksByAuthorProjection::NAME)] 15 | public function getByAuthorBookIds(): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Projection/BooksByAuthorProjection.php: -------------------------------------------------------------------------------- 1 | > $booksByAuthorState 24 | * 25 | * @return array> 26 | */ 27 | #[EventHandler] 28 | public function addBook(BookWasCreated $event, #[ProjectionState] array $booksByAuthorState): array 29 | { 30 | return $this->addBookToAuthorBooks($event->id(), $event->author, $booksByAuthorState); 31 | } 32 | 33 | /** 34 | * @param array> $booksByAuthorState 35 | * 36 | * @return array> 37 | */ 38 | #[EventHandler] 39 | public function updateBook(BookWasUpdated $event, #[ProjectionState] array $booksByAuthorState): array 40 | { 41 | if (!$event->author) { 42 | return $booksByAuthorState; 43 | } 44 | 45 | $booksByAuthorState = $this->removeBookFromAuthorBooks($event->id(), $booksByAuthorState); 46 | 47 | return $this->addBookToAuthorBooks($event->id(), $event->author, $booksByAuthorState); 48 | } 49 | 50 | /** 51 | * @param array> $booksByAuthorState 52 | * 53 | * @return array> 54 | */ 55 | #[EventHandler] 56 | public function removeBook(BookWasDeleted $event, #[ProjectionState] array $booksByAuthorState): array 57 | { 58 | return $this->removeBookFromAuthorBooks($event->id(), $booksByAuthorState); 59 | } 60 | 61 | /** 62 | * @param array> $booksByAuthorState 63 | * 64 | * @return array> 65 | */ 66 | private function addBookToAuthorBooks(BookId $bookId, Author $author, array $booksByAuthorState): array 67 | { 68 | if (!isset($booksByAuthorState[$author->value])) { 69 | $booksByAuthorState[$author->value] = []; 70 | } 71 | 72 | if ($this->findBookAuthor($bookId, $booksByAuthorState)?->isEqualTo($author)) { 73 | return $booksByAuthorState; 74 | } 75 | 76 | $booksByAuthorState[$author->value][] = (string) $bookId; 77 | 78 | return $booksByAuthorState; 79 | } 80 | 81 | /** 82 | * @param array> $booksByAuthorState 83 | * 84 | * @return array> 85 | */ 86 | private function removeBookFromAuthorBooks(BookId $bookId, array $booksByAuthorState): array 87 | { 88 | $previousAuthor = $this->findBookAuthor($bookId, $booksByAuthorState); 89 | 90 | if (!$previousAuthor) { 91 | return $booksByAuthorState; 92 | } 93 | 94 | $previousBookIndex = array_search((string) $bookId, $booksByAuthorState[$previousAuthor->value], true); 95 | unset($booksByAuthorState[$previousAuthor->value][$previousBookIndex]); 96 | $booksByAuthorState[$previousAuthor->value] = array_values($booksByAuthorState[$previousAuthor->value]); 97 | 98 | return $booksByAuthorState; 99 | } 100 | 101 | /** 102 | * @param array> $booksByAuthorState 103 | */ 104 | private function findBookAuthor(BookId $bookId, array $booksByAuthorState): ?Author 105 | { 106 | foreach ($booksByAuthorState as $author => $books) { 107 | if (in_array((string) $bookId, $books, true)) { 108 | return new Author($author); 109 | } 110 | } 111 | 112 | return null; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Repository/BookRepository.php: -------------------------------------------------------------------------------- 1 | eventSourcedRepository->findBy($id)) { 38 | return null; 39 | } 40 | 41 | if ($eventSourcedBook->deleted()) { 42 | return null; 43 | } 44 | 45 | return $eventSourcedBook; 46 | } 47 | 48 | public function findEvents(BookId $id): iterable 49 | { 50 | $matcher = (new MetadataMatcher()) 51 | ->withMetadataMatch(LazyProophEventStore::AGGREGATE_ID, Operator::EQUALS(), (string) $id); 52 | 53 | $events = $this->eventStore->load(Book::class, 1, null, $matcher); 54 | 55 | foreach ($events as $event) { 56 | $bookEvent = $event->getPayload(); 57 | Assert::isInstanceOf($bookEvent, BookEvent::class); 58 | 59 | yield $bookEvent; 60 | } 61 | } 62 | 63 | public function all(): iterable 64 | { 65 | foreach ($this->bookIdsGateway->getBookIds() as $bookId) { 66 | if ($book = $this->ofId(new BookId($bookId))) { 67 | yield $book; 68 | } 69 | } 70 | } 71 | 72 | public function paginator(int $page, int $itemsPerPage): PaginatorInterface 73 | { 74 | $firstResult = ($page - 1) * $itemsPerPage; 75 | $maxResults = $itemsPerPage; 76 | 77 | return new CallbackPaginator( 78 | array_map(static fn (string $bookId) => new BookId($bookId), $this->bookIdsGateway->getBookIds()), 79 | $firstResult, 80 | $maxResults, 81 | fn (BookId $bookId) => $this->ofId($bookId) ?? throw new MissingBookException($bookId), 82 | ); 83 | } 84 | 85 | public function findByAuthor(Author $author): iterable 86 | { 87 | $byAuthorBookIds = $this->booksByAuthorGateway->getByAuthorBookIds(); 88 | 89 | foreach ($byAuthorBookIds[$author->value] ?? [] as $bookId) { 90 | if ($book = $this->ofId(new BookId($bookId))) { 91 | yield $book; 92 | } 93 | } 94 | } 95 | 96 | public function findCheapest(int $size): iterable 97 | { 98 | $bookPriceList = $this->bookPriceGateway->getBookPriceList(); 99 | 100 | asort($bookPriceList); 101 | 102 | return new CallbackPaginator( 103 | array_map(static fn (string $bookId) => new BookId($bookId), array_keys($bookPriceList)), 104 | 0, 105 | $size, 106 | fn (BookId $bookId) => $this->ofId($bookId) ?? throw new MissingBookException($bookId), 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Ecotone/Repository/EventSourcedBookRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class CallbackPaginator implements PaginatorInterface 14 | { 15 | /** @var V[] */ 16 | private readonly array $results; 17 | private readonly int $firstResult; 18 | private readonly int $maxResults; 19 | private readonly int $totalItems; 20 | /** @var callable(V): T */ 21 | private $callback; 22 | 23 | /** 24 | * @param V[] $results 25 | * @param callable(V): T $callback 26 | */ 27 | public function __construct(array $results, int $firstResult, int $maxResults, callable $callback) 28 | { 29 | $this->results = $results; 30 | $this->firstResult = $firstResult; 31 | $this->maxResults = $maxResults; 32 | $this->callback = $callback; 33 | $this->totalItems = \count($results); 34 | } 35 | 36 | public function getCurrentPage(): int 37 | { 38 | if (0 >= $this->maxResults) { 39 | return 1; 40 | } 41 | 42 | return (int) floor($this->firstResult / $this->maxResults) + 1; 43 | } 44 | 45 | public function getLastPage(): int 46 | { 47 | if (0 >= $this->maxResults) { 48 | return 1; 49 | } 50 | 51 | return (int) ceil($this->totalItems / $this->maxResults) ?: 1; 52 | } 53 | 54 | public function getItemsPerPage(): int 55 | { 56 | return $this->maxResults; 57 | } 58 | 59 | public function getTotalItems(): int 60 | { 61 | return $this->totalItems; 62 | } 63 | 64 | public function count(): int 65 | { 66 | return iterator_count($this->getIterator()); 67 | } 68 | 69 | public function getIterator(): \Traversable 70 | { 71 | foreach (array_slice($this->results, $this->firstResult, $this->maxResults) as $key => $result) { 72 | yield $key => ($this->callback)($result); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Shared/Domain/Repository/PaginatorInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface PaginatorInterface extends \IteratorAggregate, \Countable 13 | { 14 | public function getCurrentPage(): int; 15 | 16 | public function getItemsPerPage(): int; 17 | 18 | public function getLastPage(): int; 19 | 20 | public function getTotalItems(): int; 21 | } 22 | -------------------------------------------------------------------------------- /src/Shared/Domain/ValueObject/AggregateRootId.php: -------------------------------------------------------------------------------- 1 | value = $value ?? (string) Uuid::v4(); 16 | } 17 | 18 | public function __toString(): string 19 | { 20 | return $this->value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/ApiPlatform/State/Paginator.php: -------------------------------------------------------------------------------- 1 | 13 | * @implements \IteratorAggregate 14 | */ 15 | final readonly class Paginator implements PaginatorInterface, \IteratorAggregate 16 | { 17 | /** 18 | * @param \Traversable $items 19 | */ 20 | public function __construct( 21 | private \Traversable $items, 22 | private float $currentPage, 23 | private float $itemsPerPage, 24 | private float $lastPage, 25 | private float $totalItems, 26 | ) { 27 | } 28 | 29 | public function getCurrentPage(): float 30 | { 31 | return $this->currentPage; 32 | } 33 | 34 | public function getItemsPerPage(): float 35 | { 36 | return $this->itemsPerPage; 37 | } 38 | 39 | public function getLastPage(): float 40 | { 41 | return $this->lastPage; 42 | } 43 | 44 | public function getTotalItems(): float 45 | { 46 | return $this->totalItems; 47 | } 48 | 49 | public function count(): int 50 | { 51 | return iterator_count($this->getIterator()); 52 | } 53 | 54 | /** 55 | * @return \Traversable 56 | */ 57 | public function getIterator(): \Traversable 58 | { 59 | return $this->items; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Ecotone/CommandBus.php: -------------------------------------------------------------------------------- 1 | commandBus->send($command); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Ecotone/QueryBus.php: -------------------------------------------------------------------------------- 1 | queryBus->send($query); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Symfony/Kernel.php: -------------------------------------------------------------------------------- 1 | import(sprintf('%s/config/{packages}/*.php', $this->getProjectDir())); 19 | $container->import(sprintf('%s/config/{packages}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 20 | 21 | $container->import(sprintf('%s/config/{services}/*.php', $this->getProjectDir())); 22 | $container->import(sprintf('%s/config/{services}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 23 | } 24 | 25 | protected function configureRoutes(RoutingConfigurator $routes): void 26 | { 27 | $routes->import(sprintf('%s/config/{routes}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 28 | $routes->import(sprintf('%s/config/{routes}/*.php', $this->getProjectDir())); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Subscription/Entity/Subscription.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | 7 | {# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #} 8 | {% block stylesheets %} 9 | {{ encore_entry_link_tags('app') }} 10 | {% endblock %} 11 | 12 | {% block javascripts %} 13 | {{ encore_entry_script_tags('app') }} 14 | {% endblock %} 15 | 16 | 17 | {% block body %}{% endblock %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/AnonymizeBooksTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 22 | 23 | for ($i = 0; $i < 10; ++$i) { 24 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 25 | id: $bookId, 26 | author: sprintf('author_%d', $i), 27 | )]); 28 | } 29 | 30 | $response = $client->request('POST', '/api/books/anonymize', [ 31 | 'json' => [ 32 | 'anonymizedName' => 'anon.', 33 | ], 34 | ]); 35 | 36 | static::assertResponseStatusCodeSame(202); 37 | static::assertEmpty($response->getContent()); 38 | 39 | foreach (static::getContainer()->get(BookRepositoryInterface::class)->all() as $book) { 40 | self::assertEquals(new Author('anon.'), $book->author()); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/BookCrudTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 28 | 29 | for ($i = 0; $i < 100; ++$i) { 30 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 31 | } 32 | 33 | $client->request('GET', '/api/books'); 34 | 35 | static::assertResponseIsSuccessful(); 36 | static::assertMatchesResourceCollectionJsonSchema(BookResource::class); 37 | 38 | static::assertJsonContains([ 39 | 'hydra:totalItems' => 100, 40 | 'hydra:view' => [ 41 | 'hydra:first' => '/api/books?page=1', 42 | 'hydra:next' => '/api/books?page=2', 43 | 'hydra:last' => '/api/books?page=4', 44 | ], 45 | ]); 46 | } 47 | 48 | public function testFilterBooksByAuthor(): void 49 | { 50 | $client = static::createClient(); 51 | 52 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 53 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 54 | 55 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorOne')]); 56 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorOne')]); 57 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorTwo')]); 58 | 59 | $client->request('GET', '/api/books?author=authorOne'); 60 | 61 | static::assertResponseIsSuccessful(); 62 | static::assertMatchesResourceCollectionJsonSchema(BookResource::class); 63 | 64 | static::assertJsonContains([ 65 | 'hydra:member' => [ 66 | ['author' => 'authorOne'], 67 | ['author' => 'authorOne'], 68 | ], 69 | 'hydra:totalItems' => 2, 70 | ]); 71 | } 72 | 73 | public function testReturnBook(): void 74 | { 75 | $client = static::createClient(); 76 | 77 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 78 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 79 | 80 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 81 | 82 | $client->request('GET', sprintf('/api/books/%s', $bookId)); 83 | 84 | static::assertResponseIsSuccessful(); 85 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 86 | 87 | static::assertJsonContains([ 88 | 'name' => 'name', 89 | 'description' => 'description', 90 | 'author' => 'author', 91 | 'content' => 'content', 92 | 'price' => 1000, 93 | ]); 94 | } 95 | 96 | public function testCreateBook(): void 97 | { 98 | $client = static::createClient(); 99 | 100 | $response = $client->request('POST', '/api/books', [ 101 | 'json' => [ 102 | 'name' => 'name', 103 | 'description' => 'description', 104 | 'author' => 'author', 105 | 'content' => 'content', 106 | 'price' => 1000, 107 | ], 108 | ]); 109 | 110 | static::assertResponseIsSuccessful(); 111 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 112 | 113 | static::assertJsonContains([ 114 | 'name' => 'name', 115 | 'description' => 'description', 116 | 'author' => 'author', 117 | 'content' => 'content', 118 | 'price' => 1000, 119 | ]); 120 | 121 | $id = new BookId(str_replace('/api/books/', '', $response->toArray()['@id'])); 122 | 123 | /** @var Book $book */ 124 | $book = static::getContainer()->get(BookRepositoryInterface::class)->ofId($id); 125 | 126 | static::assertNotNull($book); 127 | static::assertEquals($id, $book->id()); 128 | static::assertEquals(new BookName('name'), $book->name()); 129 | static::assertEquals(new BookDescription('description'), $book->description()); 130 | static::assertEquals(new Author('author'), $book->author()); 131 | static::assertEquals(new BookContent('content'), $book->content()); 132 | static::assertEquals(new Price(1000), $book->price()); 133 | } 134 | 135 | public function testCannotCreateBookWithoutValidPayload(): void 136 | { 137 | $client = static::createClient(); 138 | 139 | $client->request('POST', '/api/books', [ 140 | 'json' => [ 141 | 'name' => '', 142 | 'description' => '', 143 | 'author' => '', 144 | 'content' => '', 145 | 'price' => -100, 146 | ], 147 | ]); 148 | 149 | static::assertResponseIsUnprocessable(); 150 | static::assertJsonContains([ 151 | 'violations' => [ 152 | ['propertyPath' => 'name', 'message' => 'This value is too short. It should have 1 character or more.'], 153 | ['propertyPath' => 'description', 'message' => 'This value is too short. It should have 1 character or more.'], 154 | ['propertyPath' => 'author', 'message' => 'This value is too short. It should have 1 character or more.'], 155 | ['propertyPath' => 'content', 'message' => 'This value is too short. It should have 1 character or more.'], 156 | ['propertyPath' => 'price', 'message' => 'This value should be either positive or zero.'], 157 | ], 158 | ]); 159 | 160 | $client->request('POST', '/api/books', [ 161 | 'json' => [], 162 | ]); 163 | 164 | static::assertResponseIsUnprocessable(); 165 | static::assertJsonContains([ 166 | 'violations' => [ 167 | ['propertyPath' => 'name', 'message' => 'This value should not be null.'], 168 | ['propertyPath' => 'description', 'message' => 'This value should not be null.'], 169 | ['propertyPath' => 'author', 'message' => 'This value should not be null.'], 170 | ['propertyPath' => 'content', 'message' => 'This value should not be null.'], 171 | ['propertyPath' => 'price', 'message' => 'This value should not be null.'], 172 | ], 173 | ]); 174 | } 175 | 176 | public function testUpdateBook(): void 177 | { 178 | $client = static::createClient(); 179 | 180 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 181 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 182 | 183 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 184 | 185 | $client->request('PUT', sprintf('/api/books/%s', $bookId), [ 186 | 'json' => [ 187 | 'name' => 'newName', 188 | 'description' => 'newDescription', 189 | 'author' => 'newAuthor', 190 | 'content' => 'newContent', 191 | 'price' => 2000, 192 | ], 193 | ]); 194 | 195 | static::assertResponseIsSuccessful(); 196 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 197 | 198 | static::assertJsonContains([ 199 | 'name' => 'newName', 200 | 'description' => 'newDescription', 201 | 'author' => 'newAuthor', 202 | 'content' => 'newContent', 203 | 'price' => 2000, 204 | ]); 205 | 206 | $updatedBook = static::getContainer()->get(BookRepositoryInterface::class)->ofId($bookId); 207 | 208 | static::assertNotNull($updatedBook); 209 | static::assertEquals(new BookName('newName'), $updatedBook->name()); 210 | static::assertEquals(new BookDescription('newDescription'), $updatedBook->description()); 211 | static::assertEquals(new Author('newAuthor'), $updatedBook->author()); 212 | static::assertEquals(new BookContent('newContent'), $updatedBook->content()); 213 | static::assertEquals(new Price(2000), $updatedBook->price()); 214 | } 215 | 216 | public function testPartiallyUpdateBook(): void 217 | { 218 | $client = static::createClient(); 219 | 220 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 221 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 222 | 223 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 224 | 225 | $client->request('PATCH', sprintf('/api/books/%s', $bookId), [ 226 | 'headers' => [ 227 | 'Content-Type' => 'application/merge-patch+json', 228 | ], 229 | 'json' => [ 230 | 'name' => 'newName', 231 | ], 232 | ]); 233 | 234 | static::assertResponseIsSuccessful(); 235 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 236 | 237 | static::assertJsonContains([ 238 | 'name' => 'newName', 239 | ]); 240 | 241 | $updatedBook = static::getContainer()->get(BookRepositoryInterface::class)->ofId($bookId); 242 | 243 | static::assertNotNull($updatedBook); 244 | static::assertEquals(new BookName('newName'), $updatedBook->name()); 245 | static::assertEquals(new BookDescription('description'), $updatedBook->description()); 246 | } 247 | 248 | public function testDeleteBook(): void 249 | { 250 | $client = static::createClient(); 251 | 252 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 253 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 254 | 255 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 256 | 257 | $response = $client->request('DELETE', sprintf('/api/books/%s', $bookId)); 258 | 259 | static::assertResponseIsSuccessful(); 260 | static::assertEmpty($response->getContent()); 261 | 262 | static::assertNull(static::getContainer()->get(BookRepositoryInterface::class)->ofId($bookId)); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/BookEventsTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 24 | 25 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [ 26 | DummyBookFactory::createBookWasCreatedEvent(id: $bookId), 27 | new BookWasUpdated( 28 | id: $bookId, 29 | description: new BookDescription('newDescription'), 30 | ), 31 | new BookWasDiscounted( 32 | id: $bookId, 33 | discount: new Discount(15), 34 | ), 35 | ]); 36 | 37 | $client->request('GET', sprintf('/api/books/%s/events', $bookId)); 38 | 39 | static::assertResponseIsSuccessful(); 40 | static::assertJsonContains([ 41 | 'hydra:member' => [ 42 | [ 43 | '@type' => 'BookWasCreated', 44 | 'name' => ['value' => 'name'], 45 | 'description' => ['value' => 'description'], 46 | 'author' => ['value' => 'author'], 47 | 'content' => ['value' => 'content'], 48 | 'price' => ['amount' => 1000], 49 | ], 50 | [ 51 | '@type' => 'BookWasUpdated', 52 | 'description' => ['value' => 'newDescription'], 53 | ], 54 | [ 55 | '@type' => 'BookWasDiscounted', 56 | 'discount' => ['percentage' => 15], 57 | ], 58 | ], 59 | 'hydra:totalItems' => 3, 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/CheapestBooksTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 21 | 22 | for ($i = 0; $i < 20; ++$i) { 23 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 24 | id: $bookId, 25 | price: $i, 26 | )]); 27 | } 28 | 29 | $response = $client->request('GET', '/api/books/cheapest'); 30 | 31 | static::assertResponseIsSuccessful(); 32 | static::assertMatchesResourceCollectionJsonSchema(BookResource::class); 33 | 34 | static::assertSame(10, $response->toArray()['hydra:totalItems']); 35 | 36 | $prices = []; 37 | for ($i = 0; $i < 10; ++$i) { 38 | $prices[] = ['price' => $i]; 39 | } 40 | 41 | static::assertJsonContains(['hydra:member' => $prices]); 42 | } 43 | 44 | public function testReturnBooksSortedByPrice(): void 45 | { 46 | $client = static::createClient(); 47 | 48 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 49 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 50 | 51 | $prices = [2000, 1000, 3000]; 52 | foreach ($prices as $price) { 53 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 54 | id: $bookId, 55 | price: $price, 56 | )]); 57 | } 58 | 59 | $response = $client->request('GET', '/api/books/cheapest'); 60 | 61 | static::assertResponseIsSuccessful(); 62 | static::assertMatchesResourceCollectionJsonSchema(BookResource::class); 63 | 64 | $responsePrices = array_map(fn (array $bookData): int => $bookData['price'], $response->toArray()['hydra:member']); 65 | static::assertSame([1000, 2000, 3000], $responsePrices); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/DiscountBookTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 23 | 24 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 25 | 26 | $client->request('POST', sprintf('/api/books/%s/discount', $bookId), [ 27 | 'json' => [ 28 | 'discountPercentage' => 20, 29 | ], 30 | ]); 31 | 32 | static::assertResponseIsSuccessful(); 33 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 34 | static::assertJsonContains(['price' => 800]); 35 | 36 | static::assertEquals(new Price(800), static::getContainer()->get(BookRepositoryInterface::class)->ofId($bookId)->price()); 37 | } 38 | 39 | public function testValidateDiscountAmount(): void 40 | { 41 | $client = static::createClient(); 42 | 43 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 44 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 45 | 46 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 47 | 48 | $client->request('POST', sprintf('/api/books/%s/discount', $bookId), [ 49 | 'json' => [ 50 | 'discountPercentage' => 200, 51 | ], 52 | ]); 53 | 54 | static::assertResponseIsUnprocessable(); 55 | static::assertJsonContains([ 56 | 'violations' => [ 57 | ['propertyPath' => 'discountPercentage', 'message' => 'This value should be between 0 and 100.'], 58 | ], 59 | ]); 60 | 61 | static::assertEquals(new Price(1000), static::getContainer()->get(BookRepositoryInterface::class)->ofId($bookId)->price()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/BookStore/DummyFactory/DummyBookFactory.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 22 | 23 | /** @var CommandBusInterface $commandBus */ 24 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 25 | 26 | for ($i = 0; $i < 10; ++$i) { 27 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 28 | id: $bookId, 29 | author: sprintf('author_%d', $i), 30 | )]); 31 | } 32 | 33 | $commandBus->dispatch(new AnonymizeBooksCommand('anon.')); 34 | 35 | foreach (static::getContainer()->get(BookRepositoryInterface::class)->all() as $book) { 36 | self::assertEquals(new Author('anon.'), $book->author()); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/CreateBookTest.php: -------------------------------------------------------------------------------- 1 | get(BookRepositoryInterface::class); 24 | 25 | /** @var CommandBusInterface $commandBus */ 26 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 27 | 28 | $commandBus->dispatch(new CreateBookCommand( 29 | new BookName('name'), 30 | new BookDescription('description'), 31 | new Author('author'), 32 | new BookContent('content'), 33 | new Price(1000), 34 | )); 35 | 36 | $books = iterator_to_array($bookRepository->all()); 37 | static::assertCount(1, $books); 38 | 39 | /** @var Book $book */ 40 | $book = array_values($books)[0]; 41 | 42 | static::assertEquals(new BookName('name'), $book->name()); 43 | static::assertEquals(new BookDescription('description'), $book->description()); 44 | static::assertEquals(new Author('author'), $book->author()); 45 | static::assertEquals(new BookContent('content'), $book->content()); 46 | static::assertEquals(new Price(1000), $book->price()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/DeleteBookTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 21 | 22 | /** @var BookRepositoryInterface $bookRepository */ 23 | $bookRepository = static::getContainer()->get(BookRepositoryInterface::class); 24 | 25 | /** @var CommandBusInterface $commandBus */ 26 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 27 | 28 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 29 | 30 | $books = iterator_to_array($bookRepository->all()); 31 | static::assertCount(1, $books); 32 | 33 | $commandBus->dispatch(new DeleteBookCommand($bookId)); 34 | 35 | $books = iterator_to_array($bookRepository->all()); 36 | static::assertEmpty($books); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/DiscountBookTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 26 | 27 | /** @var BookRepositoryInterface $bookRepository */ 28 | $bookRepository = static::getContainer()->get(BookRepositoryInterface::class); 29 | 30 | /** @var CommandBusInterface $commandBus */ 31 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 32 | 33 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 34 | id: $bookId, 35 | price: $initialAmount, 36 | )]); 37 | 38 | $commandBus->dispatch(new DiscountBookCommand($bookId, new Discount($discount))); 39 | 40 | static::assertEquals(new Price($expectedAmount), $bookRepository->ofId($bookId)->price()); 41 | } 42 | 43 | public function applyADiscountOnBookDataProvider(): iterable 44 | { 45 | yield [100, 0, 100]; 46 | yield [100, 20, 80]; 47 | yield [50, 30, 35]; 48 | yield [50, 100, 0]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/FindBookTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 20 | 21 | /** @var QueryBusInterface $queryBus */ 22 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 23 | 24 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 25 | 26 | static::assertEquals($bookId, $queryBus->ask(new FindBookQuery($bookId))->id()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/FindBooksTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 23 | 24 | /** @var QueryBusInterface $queryBus */ 25 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 26 | 27 | $initialBookEvents = [ 28 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 29 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 30 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 31 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 32 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 33 | ]; 34 | 35 | foreach ($initialBookEvents as $bookId => $bookEvent) { 36 | $eventSourcedBookRepository->save(new BookId($bookId), 0, [$bookEvent]); 37 | } 38 | 39 | /** @var Book[] $books */ 40 | $books = iterator_to_array($queryBus->ask(new FindBooksQuery())); 41 | 42 | static::assertCount(count($initialBookEvents), $books); 43 | foreach ($books as $book) { 44 | static::assertArrayHasKey((string) $book->id(), $initialBookEvents); 45 | } 46 | } 47 | 48 | public function testFilterBooksByAuthor(): void 49 | { 50 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 51 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 52 | 53 | /** @var BookRepositoryInterface $bookRepository */ 54 | $bookRepository = static::getContainer()->get(BookRepositoryInterface::class); 55 | 56 | /** @var QueryBusInterface $queryBus */ 57 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 58 | 59 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 60 | id: $bookId, 61 | author: 'authorOne', 62 | )]); 63 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 64 | id: $bookId, 65 | author: 'authorOne', 66 | )]); 67 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 68 | id: $bookId, 69 | author: 'authorTwo', 70 | )]); 71 | 72 | static::assertCount(3, iterator_to_array($bookRepository->all())); 73 | 74 | /** @var Book[] $books */ 75 | $books = iterator_to_array($queryBus->ask(new FindBooksQuery(author: new Author('authorOne')))); 76 | 77 | static::assertCount(2, $books); 78 | foreach ($books as $book) { 79 | static::assertEquals(new Author('authorOne'), $book->author()); 80 | } 81 | } 82 | 83 | public function testReturnPaginatedBooks(): void 84 | { 85 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 86 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 87 | 88 | /** @var QueryBusInterface $queryBus */ 89 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 90 | 91 | $initialBookEvents = [ 92 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 93 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 94 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 95 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 96 | $bookId = (string) new BookId() => DummyBookFactory::createBookWasCreatedEvent(id: new BookId($bookId)), 97 | ]; 98 | 99 | foreach ($initialBookEvents as $bookId => $bookEvent) { 100 | $eventSourcedBookRepository->save(new BookId($bookId), 0, [$bookEvent]); 101 | } 102 | 103 | /** @var Book[] $books */ 104 | $books = iterator_to_array($queryBus->ask(new FindBooksQuery(page: 2, itemsPerPage: 2))); 105 | 106 | static::assertCount(2, $books); 107 | $i = 0; 108 | foreach ($books as $book) { 109 | static::assertEquals(array_values($initialBookEvents)[$i + 2]->id(), $book->id()); 110 | ++$i; 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/FindCheapestBooksTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 22 | 23 | /** @var QueryBusInterface $queryBus */ 24 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 25 | 26 | for ($i = 0; $i < 5; ++$i) { 27 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 28 | } 29 | 30 | $cheapestBooks = $queryBus->ask(new FindCheapestBooksQuery(3)); 31 | 32 | static::assertCount(3, $cheapestBooks); 33 | } 34 | 35 | public function testReturnBooksSortedByPrice(): void 36 | { 37 | /** @var EventSourcedBookRepository $eventSourcedBookRepository */ 38 | $eventSourcedBookRepository = static::getContainer()->get(EventSourcedBookRepository::class); 39 | 40 | /** @var QueryBusInterface $queryBus */ 41 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 42 | 43 | $prices = [2000, 1000, 3000]; 44 | foreach ($prices as $price) { 45 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent( 46 | id: $bookId, 47 | price: $price, 48 | )]); 49 | } 50 | 51 | /** @var Book[] $cheapestBooks */ 52 | $cheapestBooks = $queryBus->ask(new FindCheapestBooksQuery(3)); 53 | 54 | $sortedPrices = [1000, 2000, 3000]; 55 | 56 | $i = 0; 57 | foreach ($cheapestBooks as $book) { 58 | static::assertEquals(new Price($sortedPrices[$i]), $book->price()); 59 | ++$i; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/UpdateBookTest.php: -------------------------------------------------------------------------------- 1 | get(EventSourcedBookRepository::class); 26 | 27 | /** @var BookRepositoryInterface $bookRepository */ 28 | $bookRepository = static::getContainer()->get(BookRepositoryInterface::class); 29 | 30 | /** @var CommandBusInterface $commandBus */ 31 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 32 | 33 | $eventSourcedBookRepository->save($bookId = new BookId(), 0, [DummyBookFactory::createBookWasCreatedEvent(id: $bookId)]); 34 | 35 | $commandBus->dispatch(new UpdateBookCommand( 36 | $bookId, 37 | name: new BookName('newName'), 38 | content: new BookContent('newContent'), 39 | price: new Price(2000), 40 | )); 41 | 42 | $book = $bookRepository->ofId($bookId); 43 | 44 | static::assertEquals(new BookName('newName'), $book->name()); 45 | static::assertEquals(new BookDescription('description'), $book->description()); 46 | static::assertEquals(new Author('author'), $book->author()); 47 | static::assertEquals(new BookContent('newContent'), $book->content()); 48 | static::assertEquals(new Price(2000), $book->price()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/BookStore/Unit/Ecotone/BooksByAuthorProjectionTest.php: -------------------------------------------------------------------------------- 1 | booksByAuthorProjection()->addBook( 22 | DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorOne'), 23 | [], 24 | ); 25 | 26 | static::assertSame([ 27 | 'authorOne' => [(string) $bookId], 28 | ], $booksByAuthorState); 29 | } 30 | 31 | public function testAddBookToAuthorBooksWithExistingAuthor(): void 32 | { 33 | $bookId = new BookId(); 34 | 35 | $booksByAuthorState = $this->booksByAuthorProjection()->addBook( 36 | DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorOne'), 37 | ['authorOne' => ['680d9111-fe46-49fd-8021-3d91c9fd3dca'], 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 38 | ); 39 | 40 | static::assertSame([ 41 | 'authorOne' => ['680d9111-fe46-49fd-8021-3d91c9fd3dca', (string) $bookId], 42 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 43 | ], $booksByAuthorState); 44 | } 45 | 46 | public function testAddBookToAuthorBooksWithExistingBook(): void 47 | { 48 | $bookId = new BookId(); 49 | 50 | $booksByAuthorState = $this->booksByAuthorProjection()->addBook( 51 | DummyBookFactory::createBookWasCreatedEvent(id: $bookId, author: 'authorOne'), 52 | ['authorOne' => [(string) $bookId], 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 53 | ); 54 | 55 | static::assertSame([ 56 | 'authorOne' => [(string) $bookId], 57 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 58 | ], $booksByAuthorState); 59 | } 60 | 61 | public function testRemoveBookToAuthorBooksWithoutExistingAuthor(): void 62 | { 63 | $bookId = new BookId(); 64 | 65 | $booksByAuthorState = $this->booksByAuthorProjection()->removeBook( 66 | new BookWasDeleted(id: $bookId), 67 | ['authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 68 | ); 69 | 70 | static::assertSame([ 71 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 72 | ], $booksByAuthorState); 73 | } 74 | 75 | public function testRemoveBookToAuthorBooksWithExistingAuthor(): void 76 | { 77 | $bookId = new BookId(); 78 | 79 | $booksByAuthorState = $this->booksByAuthorProjection()->removeBook( 80 | new BookWasDeleted(id: $bookId), 81 | ['authorOne' => [(string) $bookId], 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 82 | ); 83 | 84 | static::assertSame([ 85 | 'authorOne' => [], 86 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 87 | ], $booksByAuthorState); 88 | } 89 | 90 | public function testUpdateBookToAuthorBooksWithoutExistingAuthor(): void 91 | { 92 | $bookId = new BookId(); 93 | 94 | $booksByAuthorState = $this->booksByAuthorProjection()->updateBook( 95 | new BookWasUpdated(id: $bookId, author: new Author('authorThree')), 96 | ['authorOne' => ['680d9111-fe46-49fd-8021-3d91c9fd3dca'], 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 97 | ); 98 | 99 | static::assertSame([ 100 | 'authorOne' => ['680d9111-fe46-49fd-8021-3d91c9fd3dca'], 101 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 102 | 'authorThree' => [(string) $bookId], 103 | ], $booksByAuthorState); 104 | } 105 | 106 | public function testUpdateBookToAuthorBooksWithExistingAuthor(): void 107 | { 108 | $bookId = new BookId(); 109 | 110 | $booksByAuthorState = $this->booksByAuthorProjection()->updateBook( 111 | new BookWasUpdated(id: $bookId, author: new Author('authorThree')), 112 | ['authorOne' => [(string) $bookId, '680d9111-fe46-49fd-8021-3d91c9fd3dca'], 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76']], 113 | ); 114 | 115 | static::assertSame([ 116 | 'authorOne' => ['680d9111-fe46-49fd-8021-3d91c9fd3dca'], 117 | 'authorTwo' => ['3cf6162d-8c05-4d80-9a39-ff1490f56c76'], 118 | 'authorThree' => [(string) $bookId], 119 | ], $booksByAuthorState); 120 | } 121 | 122 | private function booksByAuthorProjection(): BooksByAuthorProjection 123 | { 124 | return new BooksByAuthorProjection(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/EcotoneConfiguration.php: -------------------------------------------------------------------------------- 1 | get(Connection::class); 26 | 27 | (new Application(static::$kernel)) 28 | ->find('doctrine:database:create') 29 | ->run(new ArrayInput(['--if-not-exists' => true]), new NullOutput()); 30 | 31 | (new Application(static::$kernel)) 32 | ->find('doctrine:schema:update') 33 | ->run(new ArrayInput(['--force' => true]), new NullOutput()); 34 | } 35 | 36 | protected function setUp(): void 37 | { 38 | static::$connection->executeStatement('TRUNCATE subscription'); 39 | } 40 | 41 | public function testCreateSubscription(): void 42 | { 43 | $client = static::createClient(); 44 | 45 | /** @var EntityManagerInterface $em */ 46 | $em = static::getContainer()->get(EntityManagerInterface::class); 47 | $repository = $em->getRepository(Subscription::class); 48 | 49 | static::assertSame(0, $repository->count([])); 50 | 51 | $response = $client->request('POST', '/api/subscriptions', [ 52 | 'json' => [ 53 | 'email' => 'foo@bar.com', 54 | ], 55 | ]); 56 | 57 | static::assertResponseIsSuccessful(); 58 | static::assertMatchesResourceItemJsonSchema(Subscription::class); 59 | 60 | static::assertJsonContains([ 61 | 'email' => 'foo@bar.com', 62 | ]); 63 | 64 | $id = Uuid::fromString(str_replace('/api/subscriptions/', '', $response->toArray()['@id'])); 65 | 66 | $subscription = $repository->find($id); 67 | 68 | static::assertNotNull($subscription); 69 | static::assertSame('foo@bar.com', $subscription->email); 70 | } 71 | 72 | public function testDeleteSubscription(): void 73 | { 74 | $client = static::createClient(); 75 | 76 | /** @var EntityManagerInterface $em */ 77 | $em = static::getContainer()->get(EntityManagerInterface::class); 78 | $repository = $em->getRepository(Subscription::class); 79 | 80 | $subscription = DummySubscriptionFactory::createSubscription(); 81 | 82 | $em->persist($subscription); 83 | $em->flush(); 84 | 85 | static::assertSame(1, $repository->count([])); 86 | 87 | $response = $client->request('DELETE', sprintf('/api/subscriptions/%s', (string) $subscription->id)); 88 | 89 | static::assertResponseIsSuccessful(); 90 | static::assertEmpty($response->getContent()); 91 | 92 | static::assertSame(0, $repository->count([])); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Subscription/DummySubscriptionFactory.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 13 | } 14 | 15 | if ($_SERVER['APP_DEBUG']) { 16 | umask(0000); 17 | } 18 | --------------------------------------------------------------------------------