├── .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 |
--------------------------------------------------------------------------------