├── .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 │ ├── dev │ │ ├── debug.php │ │ └── web_profiler.php │ ├── doctrine.php │ ├── framework.php │ ├── messenger.php │ ├── monolog.php │ ├── nelmio_cors.php │ ├── routing.php │ ├── security.php │ ├── test │ │ └── framework.php │ ├── twig.php │ └── validator.php ├── preload.php ├── routes │ ├── api_platform.php │ └── dev │ │ ├── framework.php │ │ └── web_profiler.php └── services │ ├── book_store.php │ ├── shared.php │ └── test │ ├── book_store.php │ └── shared.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 │ │ │ ├── DiscountBookCommand.php │ │ │ └── DiscountBookCommandHandler.php │ │ └── Query │ │ │ ├── FindBookQuery.php │ │ │ └── FindBookQueryHandler.php │ ├── Domain │ │ ├── Exception │ │ │ └── MissingBookException.php │ │ ├── Model │ │ │ └── Book.php │ │ ├── Repository │ │ │ └── BookRepositoryInterface.php │ │ └── ValueObject │ │ │ ├── BookId.php │ │ │ ├── BookName.php │ │ │ ├── Discount.php │ │ │ └── Price.php │ └── Infrastructure │ │ ├── ApiPlatform │ │ ├── Payload │ │ │ └── DiscountBookPayload.php │ │ ├── Resource │ │ │ └── BookResource.php │ │ └── State │ │ │ ├── Processor │ │ │ └── DiscountBookProcessor.php │ │ │ └── Provider │ │ │ └── BookItemProvider.php │ │ ├── Doctrine │ │ └── DoctrineBookRepository.php │ │ └── InMemory │ │ └── InMemoryBookRepository.php ├── Shared │ ├── Application │ │ ├── Command │ │ │ ├── CommandBusInterface.php │ │ │ ├── CommandHandlerInterface.php │ │ │ └── CommandInterface.php │ │ └── Query │ │ │ ├── QueryBusInterface.php │ │ │ ├── QueryHandlerInterface.php │ │ │ └── QueryInterface.php │ ├── Domain │ │ ├── Repository │ │ │ └── RepositoryInterface.php │ │ └── ValueObject │ │ │ └── AggregateRootId.php │ └── Infrastructure │ │ ├── Doctrine │ │ └── DoctrineRepository.php │ │ ├── InMemory │ │ └── InMemoryRepository.php │ │ └── Symfony │ │ ├── Kernel.php │ │ └── Messenger │ │ ├── MessengerCommandBus.php │ │ └── MessengerQueryBus.php └── Subscription │ └── Entity │ └── Subscription.php ├── symfony.lock ├── templates └── base.html.twig └── tests ├── BookStore ├── Acceptance │ ├── DiscountBookTest.php │ └── FindBookTest.php ├── DummyFactory │ └── DummyBookFactory.php ├── Functional │ ├── DiscountBookTest.php │ └── FindBookTest.php ├── Integration │ ├── Doctrine │ │ └── DoctrineBookRepositoryTest.php │ └── InMemory │ │ └── InMemoryBookRepositoryTest.php └── Unit │ └── BookTest.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 | ###> symfony/framework-bundle ### 2 | APP_ENV=dev 3 | APP_SECRET=6aae3d25d74cc223fc0bfdc801ce12e2 4 | ###< symfony/framework-bundle ### 5 | 6 | ###> doctrine/doctrine-bundle ### 7 | DATABASE_URL="postgresql://symfony:ChangeMe@database:5432/app?serverVersion=13&charset=utf8" 8 | ###< doctrine/doctrine-bundle ### 9 | 10 | ###> nelmio/cors-bundle ### 11 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' 12 | ###< nelmio/cors-bundle ### 13 | 14 | ###> symfony/messenger ### 15 | ###< symfony/messenger ### 16 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | APP_SECRET='$ecretf0rt3st' 2 | SYMFONY_DEPRECATIONS_HELPER=999999 3 | 4 | ###> doctrine/doctrine-bundle ### 5 | DATABASE_URL="postgresql://symfony:ChangeMe@database:5432/app_test?serverVersion=13&charset=utf8" 6 | ###< doctrine/doctrine-bundle ### 7 | -------------------------------------------------------------------------------- /.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 | 6 | jobs: 7 | php-cs-fixer: 8 | runs-on: ubuntu-latest 9 | name: PHP-CS-Fixer 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: '8.1' 18 | tools: php-cs-fixer, cs2pr 19 | 20 | - name: Run PHP-CS-Fixer 21 | run: php-cs-fixer fix --dry-run --format checkstyle | cs2pr 22 | 23 | psalm: 24 | runs-on: ubuntu-latest 25 | name: Psalm 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: '8.1' 34 | 35 | - name: Get composer cache directory 36 | id: composercache 37 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 38 | 39 | - name: Cache dependencies 40 | uses: actions/cache@v2 41 | with: 42 | path: ${{ steps.composercache.outputs.dir }} 43 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 44 | restore-keys: ${{ runner.os }}-composer- 45 | 46 | - name: Install dependencies 47 | run: composer install --prefer-dist 48 | 49 | - name: Run Psalm 50 | run: vendor/bin/psalm --show-info=true --output-format=github 51 | 52 | deptrac_bc: 53 | runs-on: ubuntu-latest 54 | name: Deptrac bounded contexts 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v2 58 | 59 | - name: Setup PHP 60 | uses: shivammathur/setup-php@v2 61 | with: 62 | php-version: '8.1' 63 | 64 | - name: Get composer cache directory 65 | id: composercache 66 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 67 | 68 | - name: Cache dependencies 69 | uses: actions/cache@v2 70 | with: 71 | path: ${{ steps.composercache.outputs.dir }} 72 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 73 | restore-keys: ${{ runner.os }}-composer- 74 | 75 | - name: Install dependencies 76 | run: composer install --prefer-dist 77 | 78 | - name: Run Deptrac 79 | run: vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --config-file deptrac_bc.yaml 80 | 81 | deptrac_hexa: 82 | runs-on: ubuntu-latest 83 | name: Deptrac hexagonal 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v2 87 | 88 | - name: Setup PHP 89 | uses: shivammathur/setup-php@v2 90 | with: 91 | php-version: '8.1' 92 | 93 | - name: Get composer cache directory 94 | id: composercache 95 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 96 | 97 | - name: Cache dependencies 98 | uses: actions/cache@v2 99 | with: 100 | path: ${{ steps.composercache.outputs.dir }} 101 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 102 | restore-keys: ${{ runner.os }}-composer- 103 | 104 | - name: Install dependencies 105 | run: composer install --prefer-dist 106 | 107 | - name: Run Deptrac 108 | run: vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --config-file deptrac_hexa.yaml 109 | 110 | phpunit: 111 | name: PHPUnit 112 | runs-on: ubuntu-latest 113 | 114 | services: 115 | database: 116 | image: postgres:13-alpine 117 | env: 118 | POSTGRES_USER: symfony 119 | POSTGRES_PASSWORD: ChangeMe 120 | options: >- 121 | --health-cmd pg_isready 122 | --health-interval 10s 123 | --health-timeout 5s 124 | --health-retries 5 125 | ports: 126 | - 5432:5432 127 | 128 | steps: 129 | - name: Checkout 130 | uses: actions/checkout@v2 131 | 132 | - name: Setup PHP 133 | uses: shivammathur/setup-php@v2 134 | with: 135 | php-version: '8.1' 136 | 137 | - name: Get composer cache directory 138 | id: composercache 139 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 140 | 141 | - name: Cache dependencies 142 | uses: actions/cache@v2 143 | with: 144 | path: ${{ steps.composercache.outputs.dir }} 145 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 146 | restore-keys: ${{ runner.os }}-composer- 147 | 148 | - name: Install dependencies 149 | run: composer install --prefer-dist 150 | 151 | - name: Run tests 152 | run: bin/phpunit 153 | env: 154 | DATABASE_URL: 'postgresql://symfony:ChangeMe@localhost:5432/app_test?serverVersion=13&charset=utf8' 155 | -------------------------------------------------------------------------------- /.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.1 8 | ARG CADDY_VERSION=2 9 | 10 | # "php" stage 11 | FROM php:${PHP_VERSION}-fpm-alpine AS symfony_php 12 | 13 | # persistent / runtime deps 14 | RUN apk add --no-cache \ 15 | acl \ 16 | fcgi \ 17 | file \ 18 | gettext \ 19 | git \ 20 | ; 21 | 22 | ARG APCU_VERSION=5.1.21 23 | RUN set -eux; \ 24 | apk add --no-cache --virtual .build-deps \ 25 | $PHPIZE_DEPS \ 26 | icu-dev \ 27 | libzip-dev \ 28 | zlib-dev \ 29 | ; \ 30 | \ 31 | docker-php-ext-configure zip; \ 32 | docker-php-ext-install -j$(nproc) \ 33 | intl \ 34 | zip \ 35 | ; \ 36 | pecl install \ 37 | apcu-${APCU_VERSION} \ 38 | ; \ 39 | pecl clear-cache; \ 40 | docker-php-ext-enable \ 41 | apcu \ 42 | opcache \ 43 | ; \ 44 | \ 45 | runDeps="$( \ 46 | scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ 47 | | tr ',' '\n' \ 48 | | sort -u \ 49 | | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \ 50 | )"; \ 51 | apk add --no-cache --virtual .phpexts-rundeps $runDeps; \ 52 | \ 53 | apk del .build-deps 54 | 55 | COPY docker/php/docker-healthcheck.sh /usr/local/bin/docker-healthcheck 56 | RUN chmod +x /usr/local/bin/docker-healthcheck 57 | 58 | HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD ["docker-healthcheck"] 59 | 60 | RUN ln -s $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini 61 | COPY docker/php/conf.d/symfony.prod.ini $PHP_INI_DIR/conf.d/symfony.ini 62 | 63 | COPY docker/php/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf 64 | 65 | COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint 66 | RUN chmod +x /usr/local/bin/docker-entrypoint 67 | 68 | VOLUME /var/run/php 69 | 70 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 71 | 72 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 73 | ENV COMPOSER_ALLOW_SUPERUSER=1 74 | 75 | ENV PATH="${PATH}:/root/.composer/vendor/bin" 76 | 77 | WORKDIR /srv/app 78 | 79 | # Allow to choose skeleton 80 | ARG SKELETON="symfony/skeleton" 81 | ENV SKELETON ${SKELETON} 82 | 83 | # Allow to use development versions of Symfony 84 | ARG STABILITY="stable" 85 | ENV STABILITY ${STABILITY} 86 | 87 | # Allow to select skeleton version 88 | ARG SYMFONY_VERSION="" 89 | ENV SYMFONY_VERSION ${SYMFONY_VERSION} 90 | 91 | # Download the Symfony skeleton and leverage Docker cache layers 92 | RUN composer create-project "${SKELETON} ${SYMFONY_VERSION}" . --stability=$STABILITY --prefer-dist --no-dev --no-progress --no-interaction; \ 93 | composer clear-cache 94 | 95 | ###> recipes ### 96 | ###> doctrine/doctrine-bundle ### 97 | RUN apk add --no-cache --virtual .pgsql-deps postgresql-dev; \ 98 | docker-php-ext-install -j$(nproc) pdo_pgsql; \ 99 | apk add --no-cache --virtual .pgsql-rundeps so:libpq.so.5; \ 100 | apk del .pgsql-deps 101 | ###< doctrine/doctrine-bundle ### 102 | ###< recipes ### 103 | 104 | COPY . . 105 | 106 | RUN set -eux; \ 107 | mkdir -p var/cache var/log; \ 108 | composer install --prefer-dist --no-dev --no-progress --no-scripts --no-interaction; \ 109 | composer dump-autoload --classmap-authoritative --no-dev; \ 110 | composer symfony:dump-env prod; \ 111 | composer run-script --no-dev post-install-cmd; \ 112 | chmod +x bin/console; sync 113 | VOLUME /srv/app/var 114 | 115 | ENTRYPOINT ["docker-entrypoint"] 116 | CMD ["php-fpm"] 117 | 118 | FROM caddy:${CADDY_VERSION}-builder-alpine AS symfony_caddy_builder 119 | 120 | RUN xcaddy build \ 121 | --with github.com/dunglas/mercure \ 122 | --with github.com/dunglas/mercure/caddy \ 123 | --with github.com/dunglas/vulcain \ 124 | --with github.com/dunglas/vulcain/caddy 125 | 126 | FROM caddy:${CADDY_VERSION} AS symfony_caddy 127 | 128 | WORKDIR /srv/app 129 | 130 | COPY --from=dunglas/mercure:v0.11 /srv/public /srv/mercure-assets/ 131 | COPY --from=symfony_caddy_builder /usr/bin/caddy /usr/bin/caddy 132 | COPY --from=symfony_php /srv/app/public public/ 133 | COPY docker/caddy/Caddyfile /etc/caddy/Caddyfile 134 | -------------------------------------------------------------------------------- /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) kill 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 | ## Reset database 78 | db-reset: db-create db-update 79 | 80 | .PHONY: db-create db-update db-reset 81 | 82 | ################################# 83 | Tests: 84 | 85 | ## Run codestyle static analysis 86 | php-cs-fixer: 87 | @$(EXEC) vendor/bin/php-cs-fixer fix --dry-run --diff 88 | 89 | ## Run psalm static analysis 90 | psalm: 91 | @$(EXEC) vendor/bin/psalm --show-info=true 92 | 93 | ## Run code depedencies static analysis 94 | deptrac: 95 | @echo "\n${YELLOW}Checking Bounded contexts...${RESET}" 96 | @$(EXEC) vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --cache-file .deptrac_bc.cache --config-file deptrac_bc.yaml 97 | 98 | @echo "\n${YELLOW}Checking Hexagonal layers...${RESET}" 99 | @$(EXEC) vendor/bin/deptrac analyze --fail-on-uncovered --report-uncovered --no-progress --cache-file .deptrac_hexa.cache --config-file deptrac_hexa.yaml 100 | 101 | ## Run phpunit tests 102 | phpunit: 103 | @$(EXEC) bin/phpunit 104 | 105 | ## Run either static analysis and tests 106 | ci: php-cs-fixer psalm deptrac phpunit 107 | 108 | .PHONY: php-cs-fixer psalm deptrac phpunit ci 109 | 110 | ################################# 111 | Tools: 112 | 113 | ## Fix PHP files to be compliant with coding standards 114 | fix-cs: 115 | @$(EXEC) vendor/bin/php-cs-fixer fix 116 | 117 | .PHONY: fix-cs 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workshop DDD x API Platform 2 | 3 | This is a demo project used for the DDD x API Platform Workshop by @chalasr & @mtarld from @coopTilleuls. 4 | 5 | ## Getting started 6 | 7 | 1. Run `git clone https://github.com/coopTilleuls/workshop-apip-ddd` to clone the project 8 | 1. Run `make install` to install the project 9 | 1. Run `make start` to up your containers 10 | 1. Visit https://localhost/api and play with your app! 11 | 12 | ## Exercises 13 | 14 | ### 1. The domain layer 15 | 16 | > First, checkout the exercise tag: `git checkout exercise1 -b exercise1` 17 | > 18 | > *You can compare your code to the solution using `git show exercise1-solved`* 19 | 20 |
21 | 1.1 - Adding a name 22 | 23 | - Let's create a `App\BookStore\ValueObject\BookName` value object 24 | - This `BookName` will have to hold a value (string) 25 | - That value length have to be less that 255 26 | - Now, create a `rename` method in `Book` to change the `BookName` 27 | - You'll have to update the `DummyBookFactory` to handle the name 28 | - Finally, update the `BookTest::testRename` to test your use case 29 | 30 |
31 | 32 |
33 | 1.2 - Applying a discount 34 | 35 | - Let's add a price to books 36 | - That price should always be greater than 0 37 | - The `Book` model must have a method that will apply a discount percentage on the price 38 | - You'll have to update the `DummyBookFactory` to handle the price creation 39 | - Finally, update the `BookTest::testApplyDiscount` to test your use case 40 | 41 |
42 | 43 |
44 | 1.3 - Integrating Doctrine 45 | 46 | - Let's add the proper `ORM\Entity`, `ORM\Embedded`, `ORM\Embeddable` and `ORM\Column` PHP attributes on value objects and models 47 | - Have a look at the `DoctrineRepository` class 48 | - Implement the missing methods in the `DoctrineBookRepository` 49 | - Finally, have a look at the `DoctrineBookRepositoryTest` and fill the missing test cases 50 | 51 |
52 | 53 | ### 2. The application layer 54 | 55 | > First, checkout the exercise tag: `git checkout exercise2 -b exercise2` 56 | > 57 | > *You can compare your code to the solution using `git show exercise2-solved`* 58 | 59 |
60 | 2.1 - Finding a book 61 | 62 | - Let's create a `App\BookStore\Application\Query\FindBookQuery` query 63 | - That query will just hold the book id 64 | - Now let's update the associated handler so that it uses the query and the repository to retrieve a book by its id 65 | - Finally, update the `FindBookTest` to test functionally what you've just coded (using the query bus) 66 | 67 |
68 | 69 |
70 | 2.2 - Applying a discount 71 | 72 | - Let's create a `DiscountBookCommand` and its handler that will apply a discount on a specific book 73 | - Then, update the `DiscountBookTest` to test functionally what you've just coded (using the command bus) 74 | 75 |
76 | 77 | ### 3. The infrastructure layer (and API Platform!!) 78 | 79 | > First, checkout the exercise tag: `git checkout exercise3 -b exercise3` 80 | > 81 | > *You can compare your code to the solution using `git show exercise3-solved`* 82 | 83 |
84 | 3.1 - Finding a book 85 | 86 | - Let's update the `BookResource` to specify the `BookItemProvider` in the `Get` operation 87 | - Fill in the `BookItemProvider` and dispatch the `FindBookQuery` to return (or not) a book based on the id 88 | - Update the `FindBookTest` acceptance test to really test the API 89 | 90 |
91 | 92 |
93 | 3.2 - Applying a discount 94 | 95 | - Let's update the `BookResource` to specify the proper provider and processor 96 | - You can now create the proper processor to apply a discount on a book (and return the updated book) 97 | - Update the `DiscountBookTest` acceptance test to really test the API 98 | 99 |
100 | 101 | ### 4. Bounded contexts 102 | 103 | > First, checkout the exercise tag: `git checkout exercise4 -b exercise4` 104 | > 105 | > *You can compare your code to the solution using `git show exercise4-solved`* 106 | 107 |
108 | 4 - Handling subscriptions 109 | 110 | - Let's update the `Subscription` entity to make it as an API resource (with `Post` and `Delete` operations only) 111 | - Update API Platform's configuration to handle resources in the `Subscription` bounded context 112 | - Update the `SubscriptionCrudTest` acceptance test 113 | 114 |
115 | 116 | ## Authors 117 | [Mathias Arlaud](https://github.com/mtarld) and [Robin Chalas](https://github.com/chalasr) 118 | 119 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.1", 13 | "ext-ctype": "*", 14 | "ext-iconv": "*", 15 | "api-platform/core": "^3.0", 16 | "doctrine/annotations": "^1.0", 17 | "doctrine/doctrine-bundle": "^2.5", 18 | "doctrine/orm": "^2.11", 19 | "nelmio/cors-bundle": "^2.2", 20 | "phpdocumentor/reflection-docblock": "^5.3", 21 | "phpstan/phpdoc-parser": "^1.2", 22 | "symfony/asset": "6.1.*", 23 | "symfony/console": "6.1.*", 24 | "symfony/dotenv": "6.1.*", 25 | "symfony/expression-language": "6.1.*", 26 | "symfony/flex": "^2", 27 | "symfony/framework-bundle": "6.1.*", 28 | "symfony/messenger": "6.1.*", 29 | "symfony/monolog-bundle": "^3.0", 30 | "symfony/property-access": "6.1.*", 31 | "symfony/property-info": "6.1.*", 32 | "symfony/proxy-manager-bridge": "6.1.*", 33 | "symfony/runtime": "6.1.*", 34 | "symfony/security-bundle": "6.1.*", 35 | "symfony/serializer": "6.1.*", 36 | "symfony/twig-bundle": "6.1.*", 37 | "symfony/uid": "6.1.*", 38 | "symfony/validator": "6.1.*", 39 | "symfony/yaml": "6.1.*", 40 | "webmozart/assert": "^1.10" 41 | }, 42 | "config": { 43 | "allow-plugins": { 44 | "composer/package-versions-deprecated": true, 45 | "symfony/flex": true, 46 | "symfony/runtime": true 47 | }, 48 | "optimize-autoloader": true, 49 | "preferred-install": { 50 | "*": "dist" 51 | }, 52 | "sort-packages": true 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "App\\": "src/" 57 | } 58 | }, 59 | "autoload-dev": { 60 | "psr-4": { 61 | "App\\Tests\\": "tests/" 62 | } 63 | }, 64 | "replace": { 65 | "symfony/polyfill-ctype": "*", 66 | "symfony/polyfill-iconv": "*", 67 | "symfony/polyfill-php72": "*", 68 | "symfony/polyfill-php73": "*", 69 | "symfony/polyfill-php74": "*", 70 | "symfony/polyfill-php80": "*" 71 | }, 72 | "scripts": { 73 | "auto-scripts": { 74 | "cache:clear": "symfony-cmd", 75 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 76 | }, 77 | "post-install-cmd": [ 78 | "@auto-scripts" 79 | ], 80 | "post-update-cmd": [ 81 | "@auto-scripts" 82 | ] 83 | }, 84 | "conflict": { 85 | "symfony/symfony": "*" 86 | }, 87 | "extra": { 88 | "symfony": { 89 | "allow-contrib": false, 90 | "require": "6.1.*", 91 | "docker": true 92 | } 93 | }, 94 | "require-dev": { 95 | "friendsofphp/php-cs-fixer": "^3.11", 96 | "justinrainbow/json-schema": "^5.2", 97 | "phpunit/phpunit": "^9.5", 98 | "qossmic/deptrac-shim": "^0.24.0", 99 | "symfony/browser-kit": "6.1.*", 100 | "symfony/css-selector": "6.1.*", 101 | "symfony/debug-bundle": "6.1.*", 102 | "symfony/http-client": "6.1.*", 103 | "symfony/phpunit-bridge": "^6.1", 104 | "symfony/stopwatch": "6.1.*", 105 | "symfony/web-profiler-bundle": "6.1.*", 106 | "vimeo/psalm": "^4.27" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /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 | ]; 16 | -------------------------------------------------------------------------------- /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/dev/debug.php: -------------------------------------------------------------------------------- 1 | extension('debug', [ 9 | 'dump_destination' => 'tcp://%env(VAR_DUMPER_SERVER)%', 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.php: -------------------------------------------------------------------------------- 1 | extension('web_profiler', [ 9 | 'toolbar' => true, 10 | 'intercept_redirects' => false, 11 | ]); 12 | }; 13 | -------------------------------------------------------------------------------- /config/packages/doctrine.php: -------------------------------------------------------------------------------- 1 | extension( 9 | 'doctrine', 10 | [ 11 | 'dbal' => [ 12 | 'url' => '%env(resolve:DATABASE_URL)%', 13 | ], 14 | 'orm' => [ 15 | 'auto_mapping' => true, 16 | 'auto_generate_proxy_classes' => true, 17 | 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', 18 | 'mappings' => [ 19 | 'BookStore' => [ 20 | 'is_bundle' => false, 21 | 'type' => 'attribute', 22 | 'dir' => '%kernel.project_dir%/src/BookStore/Domain', 23 | 'prefix' => 'App\BookStore\Domain', 24 | ], 25 | 'Shared' => [ 26 | 'is_bundle' => false, 27 | 'type' => 'attribute', 28 | 'dir' => '%kernel.project_dir%/src/Shared/Domain', 29 | 'prefix' => 'App\Shared\Domain', 30 | ], 31 | 'Subscription' => [ 32 | 'is_bundle' => false, 33 | 'type' => 'attribute', 34 | 'dir' => '%kernel.project_dir%/src/Subscription/Entity', 35 | 'prefix' => 'App\Subscription\Entity', 36 | ], 37 | ], 38 | ], 39 | ], 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /config/packages/framework.php: -------------------------------------------------------------------------------- 1 | extension( 9 | 'framework', 10 | [ 11 | 'secret' => '%env(APP_SECRET)%', 12 | 'http_method_override' => false, 13 | 'php_errors' => ['log' => 4096], 14 | ] 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /config/packages/messenger.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'messenger' => [ 10 | 'default_bus' => 'command.bus', 11 | 'buses' => [ 12 | 'command.bus' => [], 13 | 'query.bus' => [], 14 | ], 15 | 'transports' => [ 16 | 'sync' => 'sync://', 17 | ], 18 | ], 19 | ]); 20 | }; 21 | -------------------------------------------------------------------------------- /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' => ['utf8' => true], 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/security.php: -------------------------------------------------------------------------------- 1 | extension('security', [ 10 | 'enable_authenticator_manager' => true, 11 | 'password_hashers' => [ 12 | PasswordAuthenticatedUserInterface::class => 'auto', 13 | ], 14 | 'providers' => [ 15 | 'users_in_memory' => ['memory' => null], 16 | ], 17 | 'firewalls' => [ 18 | 'dev' => [ 19 | 'pattern' => '^/(_(profiler|wdt)|css|images|js)/', 20 | 'security' => false, 21 | ], 22 | 'main' => [ 23 | 'lazy' => true, 24 | 'provider' => 'users_in_memory', 25 | ], 26 | ], 27 | 'access_control' => null, 28 | ]); 29 | }; 30 | -------------------------------------------------------------------------------- /config/packages/test/framework.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'test' => true, 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/twig.php: -------------------------------------------------------------------------------- 1 | extension('twig', [ 9 | 'default_path' => '%kernel.project_dir%/templates', 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/packages/validator.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'validation' => [ 10 | 'email_validation_mode' => 'html5', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | import('.', 'api_platform') 9 | ->prefix('/api'); 10 | }; 11 | -------------------------------------------------------------------------------- /config/routes/dev/framework.php: -------------------------------------------------------------------------------- 1 | import('@FrameworkBundle/Resources/config/routing/errors.xml') 9 | ->prefix('/_error'); 10 | }; 11 | -------------------------------------------------------------------------------- /config/routes/dev/web_profiler.php: -------------------------------------------------------------------------------- 1 | import('@WebProfilerBundle/Resources/config/routing/wdt.xml') 9 | ->prefix('/_wdt'); 10 | 11 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.xml') 12 | ->prefix('/_profiler'); 13 | }; 14 | -------------------------------------------------------------------------------- /config/services/book_store.php: -------------------------------------------------------------------------------- 1 | services(); 11 | 12 | $services->defaults() 13 | ->autowire() 14 | ->autoconfigure(); 15 | 16 | $services->load('App\\BookStore\\', __DIR__.'/../../src/BookStore'); 17 | 18 | // repositories 19 | $services->set(BookRepositoryInterface::class) 20 | ->class(DoctrineBookRepository::class); 21 | }; 22 | -------------------------------------------------------------------------------- /config/services/shared.php: -------------------------------------------------------------------------------- 1 | services(); 9 | 10 | $services->defaults() 11 | ->autowire() 12 | ->autoconfigure(); 13 | 14 | $services->load('App\\Shared\\', __DIR__.'/../../src/Shared') 15 | ->exclude([__DIR__.'/../../src/Shared/Infrastructure/Kernel.php']); 16 | }; 17 | -------------------------------------------------------------------------------- /config/services/test/book_store.php: -------------------------------------------------------------------------------- 1 | services(); 12 | 13 | $services->defaults() 14 | ->autowire() 15 | ->autoconfigure(); 16 | 17 | // repositories 18 | $services->set(BookRepositoryInterface::class) 19 | ->class(InMemoryBookRepository::class); 20 | 21 | $services->set(InMemoryBookRepository::class) 22 | ->public(); 23 | 24 | $services->set(DoctrineBookRepository::class) 25 | ->public(); 26 | }; 27 | -------------------------------------------------------------------------------- /config/services/test/shared.php: -------------------------------------------------------------------------------- 1 | services(); 13 | 14 | $services->defaults() 15 | ->autowire() 16 | ->autoconfigure(); 17 | 18 | $services->set(QueryBusInterface::class) 19 | ->class(MessengerQueryBus::class) 20 | ->public(); 21 | 22 | $services->set(CommandBusInterface::class) 23 | ->class(MessengerCommandBus::class) 24 | ->public(); 25 | }; 26 | -------------------------------------------------------------------------------- /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 | 28 | ruleset: 29 | BookStore: [ Shared, Vendors ] 30 | Subscription: [ Shared, Vendors ] 31 | Shared: [ Vendors ] 32 | -------------------------------------------------------------------------------- /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 | 29 | - name: Attributes 30 | collectors: 31 | - { type: className, regex: ^Doctrine\\ORM\\Mapping } 32 | 33 | - name: Helpers 34 | collectors: 35 | - { type: className, regex: ^Symfony\\Component\\Uid\\ } 36 | - { type: className, regex: ^Webmozart\\Assert\\Assert } 37 | 38 | ruleset: 39 | Domain: 40 | - Helpers 41 | - Attributes 42 | 43 | Application: 44 | - Domain 45 | - Helpers 46 | - Attributes 47 | 48 | Infrastructure: 49 | - Domain 50 | - Application 51 | - Vendors 52 | - Helpers 53 | - Attributes 54 | -------------------------------------------------------------------------------- /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 | - "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:-13}-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 | - db-data:/var/lib/postgresql/data:rw 68 | ###< doctrine/doctrine-bundle ### 69 | 70 | volumes: 71 | php_socket: 72 | caddy_data: 73 | caddy_config: 74 | ###> symfony/mercure-bundle ### 75 | ###< symfony/mercure-bundle ### 76 | 77 | ###> doctrine/doctrine-bundle ### 78 | db-data: 79 | ###< doctrine/doctrine-bundle ### 80 | -------------------------------------------------------------------------------- /docker/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | # Debug 3 | {$DEBUG} 4 | # HTTP/3 support 5 | servers { 6 | protocol { 7 | experimental_http3 8 | } 9 | } 10 | } 11 | 12 | {$SERVER_NAME} 13 | 14 | log 15 | 16 | route { 17 | root * /srv/app/public 18 | mercure { 19 | # Transport to use (default to Bolt) 20 | transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} 21 | # Publisher JWT key 22 | publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} 23 | # Subscriber JWT key 24 | subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} 25 | # Allow anonymous subscribers (double-check that it's what you want) 26 | anonymous 27 | # Enable the subscription API (double-check that it's what you want) 28 | subscriptions 29 | # Extra directives 30 | {$MERCURE_EXTRA_DIRECTIVES} 31 | } 32 | vulcain 33 | push 34 | php_fastcgi unix//var/run/php/php-fpm.sock 35 | encode zstd gzip 36 | file_server 37 | } 38 | -------------------------------------------------------------------------------- /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 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coopTilleuls/workshop-apip-ddd/ee05454f343f2f9f156ba894fd79ce348d36cba5/public/.gitignore -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | bookRepository->ofId($command->id); 20 | if (null === $book) { 21 | throw new MissingBookException($command->id); 22 | } 23 | 24 | $book->applyDiscount($command->discount); 25 | 26 | $this->bookRepository->save($book); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/BookStore/Application/Query/FindBookQuery.php: -------------------------------------------------------------------------------- 1 | repository->ofId($query->id); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Exception/MissingBookException.php: -------------------------------------------------------------------------------- 1 | id = new BookId(); 27 | } 28 | 29 | public function id(): BookId 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function name(): BookName 35 | { 36 | return $this->name; 37 | } 38 | 39 | public function price(): Price 40 | { 41 | return $this->price; 42 | } 43 | 44 | public function rename(BookName $name): static 45 | { 46 | $this->name = $name; 47 | 48 | return $this; 49 | } 50 | 51 | public function applyDiscount(Discount $discount): static 52 | { 53 | $this->price = $this->price->applyDiscount($discount); 54 | 55 | return $this; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/BookStore/Domain/Repository/BookRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface BookRepositoryInterface extends RepositoryInterface 15 | { 16 | public function save(Book $book): void; 17 | 18 | public function remove(Book $book): void; 19 | 20 | public function ofId(BookId $id): ?Book; 21 | } 22 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/BookId.php: -------------------------------------------------------------------------------- 1 | value = $value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/Discount.php: -------------------------------------------------------------------------------- 1 | percentage = $percentage; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/BookStore/Domain/ValueObject/Price.php: -------------------------------------------------------------------------------- 1 | true])] 14 | public readonly int $amount; 15 | 16 | public function __construct(int $amount) 17 | { 18 | Assert::greaterThanEq($amount, 0); 19 | 20 | $this->amount = $amount; 21 | } 22 | 23 | public function applyDiscount(Discount $discount): static 24 | { 25 | $amount = (int) ($this->amount - ($this->amount * $discount->percentage / 100)); 26 | 27 | return new static($amount); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/Payload/DiscountBookPayload.php: -------------------------------------------------------------------------------- 1 | 'Apply a discount percentage on a Book resource.'], 27 | input: DiscountBookPayload::class, 28 | provider: BookItemProvider::class, 29 | processor: DiscountBookProcessor::class, 30 | ), 31 | ], 32 | )] 33 | final class BookResource 34 | { 35 | public function __construct( 36 | #[ApiProperty(identifier: true, readable: false, writable: false)] 37 | public ?AbstractUid $id = null, 38 | 39 | #[Assert\NotNull(groups: ['create'])] 40 | #[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])] 41 | public ?string $name = null, 42 | 43 | #[Assert\NotNull(groups: ['create'])] 44 | #[Assert\PositiveOrZero(groups: ['create', 'Default'])] 45 | public ?int $price = null, 46 | ) { 47 | } 48 | 49 | public static function fromModel(Book $book): static 50 | { 51 | return new self( 52 | $book->id()->value, 53 | $book->name()->value, 54 | $book->price()->amount, 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Processor/DiscountBookProcessor.php: -------------------------------------------------------------------------------- 1 | id), 42 | new Discount($data->discountPercentage), 43 | ); 44 | 45 | $this->commandBus->dispatch($command); 46 | 47 | /** @var Book $model */ 48 | $model = $this->queryBus->ask(new FindBookQuery($command->id)); 49 | 50 | return BookResource::fromModel($model); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/ApiPlatform/State/Provider/BookItemProvider.php: -------------------------------------------------------------------------------- 1 | queryBus->ask(new FindBookQuery(new BookId(Uuid::fromString($id)))); 33 | 34 | return null !== $model ? BookResource::fromModel($model) : null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/Doctrine/DoctrineBookRepository.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class DoctrineBookRepository extends DoctrineRepository implements BookRepositoryInterface 17 | { 18 | private const ENTITY_CLASS = Book::class; 19 | private const ALIAS = 'book'; 20 | 21 | public function __construct(EntityManagerInterface $em) 22 | { 23 | parent::__construct($em, self::ENTITY_CLASS, self::ALIAS); 24 | } 25 | 26 | public function save(Book $book): void 27 | { 28 | $this->em->persist($book); 29 | $this->em->flush(); 30 | } 31 | 32 | public function remove(Book $book): void 33 | { 34 | $this->em->remove($book); 35 | $this->em->flush(); 36 | } 37 | 38 | public function ofId(BookId $id): ?Book 39 | { 40 | return $this->em->find(self::ENTITY_CLASS, $id->value); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/BookStore/Infrastructure/InMemory/InMemoryBookRepository.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class InMemoryBookRepository extends InMemoryRepository implements BookRepositoryInterface 16 | { 17 | public function save(Book $book): void 18 | { 19 | $this->entities[(string) $book->id()] = $book; 20 | } 21 | 22 | public function remove(Book $book): void 23 | { 24 | unset($this->entities[(string) $book->id()]); 25 | } 26 | 27 | public function ofId(BookId $id): ?Book 28 | { 29 | return $this->entities[(string) $id] ?? null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Shared/Application/Command/CommandBusInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface RepositoryInterface extends \IteratorAggregate, \Countable 13 | { 14 | /** 15 | * @return \Iterator 16 | */ 17 | public function getIterator(): \Iterator; 18 | 19 | public function count(): int; 20 | } 21 | -------------------------------------------------------------------------------- /src/Shared/Domain/ValueObject/AggregateRootId.php: -------------------------------------------------------------------------------- 1 | value = $value ?? Uuid::v4(); 20 | } 21 | 22 | public function __toString(): string 23 | { 24 | return (string) $this->value; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Doctrine/DoctrineRepository.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | abstract class DoctrineRepository implements RepositoryInterface 17 | { 18 | private ?int $page = null; 19 | private ?int $itemsPerPage = null; 20 | 21 | private QueryBuilder $queryBuilder; 22 | 23 | public function __construct( 24 | protected EntityManagerInterface $em, 25 | string $entityClass, 26 | string $alias, 27 | ) { 28 | $this->queryBuilder = $this->em->createQueryBuilder() 29 | ->select($alias) 30 | ->from($entityClass, $alias); 31 | } 32 | 33 | public function getIterator(): \Iterator 34 | { 35 | /** @var array $result */ 36 | $result = $this->queryBuilder->getQuery()->getResult(); 37 | 38 | yield from $result; 39 | } 40 | 41 | public function count(): int 42 | { 43 | return (int) (clone $this->queryBuilder) 44 | ->select('count(1)') 45 | ->getQuery() 46 | ->getSingleScalarResult(); 47 | } 48 | 49 | protected function query(): QueryBuilder 50 | { 51 | return clone $this->queryBuilder; 52 | } 53 | 54 | protected function __clone() 55 | { 56 | $this->queryBuilder = clone $this->queryBuilder; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/InMemory/InMemoryRepository.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | abstract class InMemoryRepository implements RepositoryInterface 15 | { 16 | /** 17 | * @var array 18 | */ 19 | protected array $entities = []; 20 | 21 | public function getIterator(): \Iterator 22 | { 23 | yield from $this->entities; 24 | } 25 | 26 | public function count(): int 27 | { 28 | return count($this->entities); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Symfony/Kernel.php: -------------------------------------------------------------------------------- 1 | import(sprintf('%s/config/{packages}/*.php', $this->getProjectDir())); 22 | $container->import(sprintf('%s/config/{packages}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 23 | 24 | $container->import(sprintf('%s/config/{services}/*.php', $this->getProjectDir())); 25 | $container->import(sprintf('%s/config/{services}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 26 | } 27 | 28 | protected function configureRoutes(RoutingConfigurator $routes): void 29 | { 30 | $routes->import(sprintf('%s/config/{routes}/%s/*.php', $this->getProjectDir(), (string) $this->environment)); 31 | $routes->import(sprintf('%s/config/{routes}/*.php', $this->getProjectDir())); 32 | } 33 | 34 | protected function build(ContainerBuilder $container): void 35 | { 36 | $container->registerForAutoconfiguration(QueryHandlerInterface::class) 37 | ->addTag('messenger.message_handler', ['bus' => 'query.bus']); 38 | 39 | $container->registerForAutoconfiguration(CommandHandlerInterface::class) 40 | ->addTag('messenger.message_handler', ['bus' => 'command.bus']); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Symfony/Messenger/MessengerCommandBus.php: -------------------------------------------------------------------------------- 1 | messageBus = $commandBus; 20 | } 21 | 22 | public function dispatch(CommandInterface $command): mixed 23 | { 24 | try { 25 | return $this->handle($command); 26 | } catch (HandlerFailedException $e) { 27 | /** @var array{0: \Throwable} $exceptions */ 28 | $exceptions = $e->getNestedExceptions(); 29 | 30 | throw $exceptions[0]; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Shared/Infrastructure/Symfony/Messenger/MessengerQueryBus.php: -------------------------------------------------------------------------------- 1 | messageBus = $queryBus; 20 | } 21 | 22 | public function ask(QueryInterface $query): mixed 23 | { 24 | try { 25 | return $this->handle($query); 26 | } catch (HandlerFailedException $e) { 27 | /** @var array{0: \Throwable} $exceptions */ 28 | $exceptions = $e->getNestedExceptions(); 29 | 30 | throw $exceptions[0]; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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/DiscountBookTest.php: -------------------------------------------------------------------------------- 1 | get(BookRepositoryInterface::class); 21 | 22 | $book = DummyBookFactory::createBook(price: 1000); 23 | $bookRepository->save($book); 24 | 25 | $client->request('POST', sprintf('/api/books/%s/discount', (string) $book->id()), [ 26 | 'json' => [ 27 | 'discountPercentage' => 20, 28 | ], 29 | ]); 30 | 31 | static::assertResponseIsSuccessful(); 32 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 33 | static::assertJsonContains(['price' => 800]); 34 | 35 | static::assertEquals(new Price(800), $bookRepository->ofId($book->id())->price()); 36 | } 37 | 38 | public function testValidateDiscountAmount(): void 39 | { 40 | $client = static::createClient(); 41 | 42 | /** @var BookRepositoryInterface $bookRepository */ 43 | $bookRepository = static::getContainer()->get(BookRepositoryInterface::class); 44 | 45 | $book = DummyBookFactory::createBook(price: 1000); 46 | $bookRepository->save($book); 47 | 48 | $client->request('POST', sprintf('/api/books/%s/discount', (string) $book->id()), [ 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), $bookRepository->ofId($book->id())->price()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/BookStore/Acceptance/FindBookTest.php: -------------------------------------------------------------------------------- 1 | get(BookRepositoryInterface::class); 21 | 22 | $book = DummyBookFactory::createBook( 23 | name: 'name', 24 | price: 1000, 25 | ); 26 | $bookRepository->save($book); 27 | 28 | $client->request('GET', sprintf('/api/books/%s', (string) $book->id())); 29 | 30 | static::assertResponseIsSuccessful(); 31 | static::assertMatchesResourceItemJsonSchema(BookResource::class); 32 | 33 | static::assertJsonContains([ 34 | 'name' => 'name', 35 | 'price' => 1000, 36 | ]); 37 | } 38 | 39 | public function testCannotFindMissingBook(): void 40 | { 41 | $client = static::createClient(); 42 | $client->request('GET', sprintf('/api/books/%s', (string) new BookId())); 43 | 44 | static::assertResponseStatusCodeSame(404); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/BookStore/DummyFactory/DummyBookFactory.php: -------------------------------------------------------------------------------- 1 | get(BookRepositoryInterface::class); 30 | 31 | /** @var CommandBusInterface $commandBus */ 32 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 33 | 34 | $book = DummyBookFactory::createBook(price: $initialAmount); 35 | $bookRepository->save($book); 36 | 37 | $commandBus->dispatch(new DiscountBookCommand($book->id(), new Discount($discount))); 38 | 39 | static::assertEquals(new Price($expectedAmount), $bookRepository->ofId($book->id())->price()); 40 | } 41 | 42 | public function applyADiscountOnBookDataProvider(): iterable 43 | { 44 | yield [100, 0, 100]; 45 | yield [100, 20, 80]; 46 | yield [50, 30, 35]; 47 | yield [50, 100, 0]; 48 | } 49 | 50 | public function testCannotApplyDiscountOnMissingBook(): void 51 | { 52 | $this->expectException(MissingBookException::class); 53 | 54 | /** @var CommandBusInterface $commandBus */ 55 | $commandBus = static::getContainer()->get(CommandBusInterface::class); 56 | 57 | $book = DummyBookFactory::createBook(); 58 | $commandBus->dispatch(new DiscountBookCommand($book->id(), new Discount(20))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/BookStore/Functional/FindBookTest.php: -------------------------------------------------------------------------------- 1 | get(BookRepositoryInterface::class); 24 | 25 | /** @var QueryBusInterface $queryBus */ 26 | $queryBus = static::getContainer()->get(QueryBusInterface::class); 27 | 28 | $book = DummyBookFactory::createBook(); 29 | $bookRepository->save($book); 30 | 31 | static::assertSame($book, $queryBus->ask(new FindBookQuery($book->id()))); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/BookStore/Integration/Doctrine/DoctrineBookRepositoryTest.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::bootKernel(); 39 | static::$connection->executeStatement('TRUNCATE book'); 40 | } 41 | 42 | public function testSave(): void 43 | { 44 | /** @var DoctrineBookRepository $repository */ 45 | $repository = static::getContainer()->get(DoctrineBookRepository::class); 46 | 47 | static::assertEmpty($repository); 48 | 49 | $book = DummyBookFactory::createBook(); 50 | $repository->save($book); 51 | 52 | static::assertCount(1, $repository); 53 | } 54 | 55 | public function testRemove(): void 56 | { 57 | /** @var DoctrineBookRepository $repository */ 58 | $repository = static::getContainer()->get(DoctrineBookRepository::class); 59 | 60 | $book = DummyBookFactory::createBook(); 61 | $repository->save($book); 62 | 63 | static::assertCount(1, $repository); 64 | 65 | $repository->remove($book); 66 | static::assertEmpty($repository); 67 | } 68 | 69 | public function testOfId(): void 70 | { 71 | /** @var DoctrineBookRepository $repository */ 72 | $repository = static::getContainer()->get(DoctrineBookRepository::class); 73 | 74 | static::assertEmpty($repository); 75 | 76 | $book = DummyBookFactory::createBook(); 77 | $repository->save($book); 78 | 79 | static::assertSame($book, $repository->ofId($book->id())); 80 | } 81 | 82 | public function testIterator(): void 83 | { 84 | /** @var DoctrineBookRepository $repository */ 85 | $repository = static::getContainer()->get(DoctrineBookRepository::class); 86 | 87 | $books = [ 88 | DummyBookFactory::createBook(), 89 | DummyBookFactory::createBook(), 90 | DummyBookFactory::createBook(), 91 | ]; 92 | foreach ($books as $book) { 93 | $repository->save($book); 94 | } 95 | 96 | $i = 0; 97 | foreach ($repository as $book) { 98 | static::assertSame($books[$i], $book); 99 | ++$i; 100 | } 101 | } 102 | 103 | public function testCount(): void 104 | { 105 | /** @var DoctrineBookRepository $repository */ 106 | $repository = static::getContainer()->get(DoctrineBookRepository::class); 107 | 108 | $books = [ 109 | DummyBookFactory::createBook(), 110 | DummyBookFactory::createBook(), 111 | DummyBookFactory::createBook(), 112 | ]; 113 | foreach ($books as $book) { 114 | $repository->save($book); 115 | } 116 | 117 | static::assertCount(count($books), $repository); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/BookStore/Integration/InMemory/InMemoryBookRepositoryTest.php: -------------------------------------------------------------------------------- 1 | get(InMemoryBookRepository::class); 22 | 23 | static::assertEmpty($repository); 24 | 25 | $book = DummyBookFactory::createBook(); 26 | $repository->save($book); 27 | 28 | static::assertCount(1, $repository); 29 | } 30 | 31 | public function testRemove(): void 32 | { 33 | /** @var InMemoryBookRepository $repository */ 34 | $repository = static::getContainer()->get(InMemoryBookRepository::class); 35 | 36 | $book = DummyBookFactory::createBook(); 37 | $repository->save($book); 38 | 39 | static::assertCount(1, $repository); 40 | 41 | $repository->remove($book); 42 | static::assertEmpty($repository); 43 | } 44 | 45 | public function testOfId(): void 46 | { 47 | /** @var InMemoryBookRepository $repository */ 48 | $repository = static::getContainer()->get(InMemoryBookRepository::class); 49 | 50 | static::assertEmpty($repository); 51 | 52 | $book = DummyBookFactory::createBook(); 53 | $repository->save($book); 54 | 55 | static::assertSame($book, $repository->ofId($book->id())); 56 | } 57 | 58 | public function testIterator(): void 59 | { 60 | /** @var InMemoryBookRepository $repository */ 61 | $repository = static::getContainer()->get(InMemoryBookRepository::class); 62 | 63 | $books = [ 64 | DummyBookFactory::createBook(), 65 | DummyBookFactory::createBook(), 66 | DummyBookFactory::createBook(), 67 | ]; 68 | foreach ($books as $book) { 69 | $repository->save($book); 70 | } 71 | 72 | $i = 0; 73 | foreach ($repository as $book) { 74 | static::assertSame($books[$i], $book); 75 | ++$i; 76 | } 77 | } 78 | 79 | public function testCount(): void 80 | { 81 | /** @var InMemoryBookRepository $repository */ 82 | $repository = static::getContainer()->get(InMemoryBookRepository::class); 83 | 84 | $books = [ 85 | DummyBookFactory::createBook(), 86 | DummyBookFactory::createBook(), 87 | DummyBookFactory::createBook(), 88 | ]; 89 | foreach ($books as $book) { 90 | $repository->save($book); 91 | } 92 | 93 | static::assertCount(count($books), $repository); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/BookStore/Unit/BookTest.php: -------------------------------------------------------------------------------- 1 | rename(new BookName('new name')); 19 | 20 | static::assertEquals(new BookName('new name'), $book->name()); 21 | } 22 | 23 | /** 24 | * @dataProvider applyDiscountDataProvider 25 | */ 26 | public function testApplyDiscount(int $initialAmount, int $discount, int $expectedAmount): void 27 | { 28 | $book = DummyBookFactory::createBook(price: $initialAmount); 29 | $book->applyDiscount(new Discount($discount)); 30 | 31 | static::assertEquals(new Price($expectedAmount), $book->price()); 32 | } 33 | 34 | public function applyDiscountDataProvider(): iterable 35 | { 36 | yield [100, 0, 100]; 37 | yield [100, 20, 80]; 38 | yield [50, 30, 35]; 39 | yield [50, 100, 0]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Subscription/Acceptance/SubscriptionCrudTest.php: -------------------------------------------------------------------------------- 1 | get(Connection::class); 28 | 29 | (new Application(static::$kernel)) 30 | ->find('doctrine:database:create') 31 | ->run(new ArrayInput(['--if-not-exists' => true]), new NullOutput()); 32 | 33 | (new Application(static::$kernel)) 34 | ->find('doctrine:schema:update') 35 | ->run(new ArrayInput(['--force' => true]), new NullOutput()); 36 | } 37 | 38 | protected function setUp(): void 39 | { 40 | static::bootKernel(); 41 | static::$connection->executeStatement('TRUNCATE subscription'); 42 | } 43 | 44 | public function testCreateSubscription(): void 45 | { 46 | $client = static::createClient(); 47 | 48 | /** @var EntityManagerInterface $em */ 49 | $em = static::getContainer()->get(EntityManagerInterface::class); 50 | $repository = $em->getRepository(Subscription::class); 51 | 52 | static::assertSame(0, $repository->count([])); 53 | 54 | $response = $client->request('POST', '/api/subscriptions', [ 55 | 'json' => [ 56 | 'email' => 'foo@bar.com', 57 | ], 58 | ]); 59 | 60 | static::assertResponseIsSuccessful(); 61 | static::assertMatchesResourceItemJsonSchema(Subscription::class); 62 | 63 | static::assertJsonContains([ 64 | 'email' => 'foo@bar.com', 65 | ]); 66 | 67 | $id = Uuid::fromString(str_replace('/api/subscriptions/', '', $response->toArray()['@id'])); 68 | 69 | $subscription = $repository->find($id); 70 | 71 | static::assertNotNull($subscription); 72 | static::assertSame('foo@bar.com', $subscription->email); 73 | } 74 | 75 | public function testDeleteSubscription(): void 76 | { 77 | $client = static::createClient(); 78 | 79 | /** @var EntityManagerInterface $em */ 80 | $em = static::getContainer()->get(EntityManagerInterface::class); 81 | $repository = $em->getRepository(Subscription::class); 82 | 83 | $subscription = DummySubscriptionFactory::createSubscription(); 84 | 85 | $em->persist($subscription); 86 | $em->flush(); 87 | 88 | static::assertSame(1, $repository->count([])); 89 | 90 | $response = $client->request('DELETE', sprintf('/api/subscriptions/%s', (string) $subscription->id)); 91 | 92 | static::assertResponseIsSuccessful(); 93 | static::assertEmpty($response->getContent()); 94 | 95 | static::assertSame(0, $repository->count([])); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Subscription/DummySubscriptionFactory.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 13 | } 14 | --------------------------------------------------------------------------------