├── .gitignore ├── LICENSE ├── README.md ├── TODO.md ├── build-prod.sh ├── build.sh ├── composer.json ├── docs ├── api │ ├── aggregates │ │ ├── functional_core.md │ │ └── img │ │ │ ├── Aggregate_Lifecycle.png │ │ │ ├── Aggregate_Lifecycle_light.xml │ │ │ ├── Aggregate_Stream.png │ │ │ ├── Event_Engine_Functional_Core.xml │ │ │ └── event_engine_functional_core.png │ ├── bookdown.json │ ├── descriptions │ │ ├── descriptions.md │ │ └── img │ │ │ ├── desc.gif │ │ │ └── order_stream.png │ ├── discolight │ │ └── discolight.md │ ├── document-store │ │ ├── bookdown.json │ │ └── overview.md │ ├── projections │ │ ├── aggregate_projector.md │ │ ├── bookdown.json │ │ └── custom_projections.md │ ├── set-up │ │ ├── bookdown.json │ │ ├── di.md │ │ ├── installation.md │ │ └── production_optimization.md │ └── state │ │ ├── img │ │ └── VO │ │ │ ├── immutable_record.gif │ │ │ ├── immutable_record_array_prop.gif │ │ │ ├── immutable_record_file_template.png │ │ │ ├── vo_bool.gif │ │ │ ├── vo_collection.gif │ │ │ ├── vo_datetime.gif │ │ │ ├── vo_float.gif │ │ │ ├── vo_int.gif │ │ │ ├── vo_string.gif │ │ │ └── vo_uuid.gif │ │ └── immutable_state.md ├── bookdown.json ├── bookdown.prod.json ├── front.html ├── front.php ├── img │ ├── Choose_Flavour.png │ ├── Choose_Flavour_no_h.png │ ├── Event_Engine_intro.png │ ├── cqrs_messagebox_cockpit.png │ ├── cqrs_messagebox_swagger.png │ ├── prooph-logo.png │ ├── prooph_features.png │ └── tutorial_screen.png ├── intro │ └── about.md ├── news │ ├── 2019-04-05_new_tutorial.md │ └── bookdown.json └── tutorial │ ├── bonus_I.md │ ├── bonus_II.md │ ├── bonus_III.md │ ├── bonus_IV.md │ ├── bookdown.json │ ├── img │ ├── Time_Travel.gif │ ├── double_check_in_detected.png │ ├── double_check_out_detected.png │ ├── inspect_aggregate.gif │ ├── inspectio_buildings_intro.png │ ├── monitoring.png │ └── notify_security.png │ ├── intro.md │ ├── part_I.md │ ├── part_II.md │ ├── part_III.md │ ├── part_IV.md │ ├── part_V.md │ ├── part_VI.md │ └── part_VII.md ├── examples ├── ContextProvider │ ├── AddItemContextProvider.php │ ├── Api │ │ ├── Aggregate.php │ │ ├── Command.php │ │ ├── Event.php │ │ ├── Message.php │ │ └── Payload.php │ ├── Item.php │ ├── ItemId.php │ ├── ItemPrice.php │ ├── Policy │ │ └── FreeShipping.php │ ├── Price │ │ ├── Amount.php │ │ └── Currency.php │ ├── PriceFinder.php │ ├── ShoppingCart.php │ └── ShoppingCart │ │ ├── AddItemContext.php │ │ ├── ShoppingCartId.php │ │ └── State.php └── ValueObject │ ├── Age.php │ ├── FriendsList.php │ ├── GivenName.php │ ├── GivenNameList.php │ ├── Person.php │ ├── RegistrationDate.php │ ├── UserId.php │ ├── Version.php │ └── WriteAccess.php └── template ├── body.php ├── head.php ├── helper └── forkOnGithub.php ├── main.php ├── meta.php ├── nav.php ├── navfooter.php ├── navheader.php ├── partial ├── sideNav.php └── topNav.php ├── style.php └── toc.php /.gitignore: -------------------------------------------------------------------------------- 1 | nbproject 2 | ._* 3 | .~lock.* 4 | .buildpath 5 | .DS_Store 6 | .idea 7 | .php_cs.cache 8 | .project 9 | .settings 10 | vendor 11 | composer.lock 12 | docs/html 13 | !docs/html/.gitkeep -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, prooph software GmbH 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # event-engine-docs 2 | Docs for Event Engine 3 | 4 | **Please note: Docs are work in progress. They are copied from Event Machine and might contain outdated or wrong information until fully migrated!** 5 | 6 | Documentation is [in the docs tree](docs/), and can be compiled using [bookdown](http://bookdown.io) and [Docker](https://www.docker.com/). 7 | 8 | ```bash 9 | docker run --rm -it -v $(pwd):/app prooph/composer:7.2 10 | docker run -it --rm -e CSS_BOOTSWATCH=flatly -e CSS_PRISM=ghcolors -v $(pwd):/app sandrokeil/bookdown:develop docs/bookdown.json 11 | docker run -it --rm -v $(pwd):/app prooph/php:7.2-cli php docs/front.php 12 | docker run -it --rm -p 8080:8080 -v $(pwd):/app php:7.2-cli php -S 0.0.0.0:8080 -t /app/docs/html 13 | ``` 14 | 15 | Or use the shorthand: 16 | 17 | ```bash 18 | . build.sh 19 | ``` 20 | 21 | ## Powered by prooph software 22 | 23 | [![prooph software](https://github.com/codeliner/php-ddd-cargo-sample/blob/master/docs/assets/prooph-software-logo.png)](http://prooph.de) 24 | 25 | Event Engine is maintained by the [prooph software team](http://prooph-software.de/). The source code of Event Engine 26 | is open sourced along with an API documentation and a [Getting Started Tutorial](#). Prooph software offers commercial support and workshops 27 | for Event Engine as well as for the [prooph components](http://getprooph.org/). 28 | 29 | If you are interested in this offer or need project support please [get in touch](http://prooph.de) 30 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODOs 2 | 3 | - [ ] Change examples namespace to `EventEngine` 4 | - [ ] Link new skeleton 5 | - [ ] Frontpage: composer create-project -> new skeleton 6 | -------------------------------------------------------------------------------- /build-prod.sh: -------------------------------------------------------------------------------- 1 | docker run -it --rm -e CSS_BOOTSWATCH=flatly -e CSS_PRISM=ghcolors -v $(pwd):/app sandrokeil/bookdown:develop docs/bookdown.prod.json 2 | docker run -it --rm -v $(pwd):/app prooph/php:7.2-cli php docs/front.php 3 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | docker run -it --rm -e CSS_BOOTSWATCH=flatly -e CSS_PRISM=ghcolors -v $(pwd):/app sandrokeil/bookdown:develop docs/bookdown.json 2 | docker run -it --rm -v $(pwd):/app prooph/php:7.2-cli php docs/front.php 3 | docker run -it --rm -p 8080:8080 -v $(pwd):/app php:7.2-cli php -S 0.0.0.0:8080 -t /app/docs/html 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "event-engine/docs", 3 | "description": "Opinionated CQRS / Event Sourcing Framework", 4 | "homepage": "https://event-engine.github.io/", 5 | "license": "BSD-3-Clause", 6 | "authors": [ 7 | { 8 | "name": "Alexander Miertsch", 9 | "email": "contact@prooph.de", 10 | "homepage": "http://www.prooph.de" 11 | }, 12 | { 13 | "name": "Sandro Keil", 14 | "email": "contact@prooph.de", 15 | "homepage": "http://prooph-software.com/" 16 | } 17 | ], 18 | "repositories": [ 19 | { 20 | "type": "composer", 21 | "url": "https://packagist.org" 22 | }, 23 | { 24 | "packagist": false 25 | }, 26 | { 27 | "type": "vcs", 28 | "url": "https://github.com/event-engine/php-engine" 29 | } 30 | ], 31 | "require": { 32 | "php": "^7.2", 33 | "bookdown/bookdown": "1.x-dev", 34 | "webuni/commonmark-table-extension": "^0.6.1", 35 | "webuni/commonmark-attributes-extension": "^0.5.0" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "ProophExample\\": "examples" 40 | } 41 | }, 42 | "prefer-stable": true 43 | } 44 | -------------------------------------------------------------------------------- /docs/api/aggregates/img/Aggregate_Lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/aggregates/img/Aggregate_Lifecycle.png -------------------------------------------------------------------------------- /docs/api/aggregates/img/Aggregate_Lifecycle_light.xml: -------------------------------------------------------------------------------- 1 | 7V1Zc6M4EP41edwUIBD4MefsVM3WpjZbtTNPW9jINjWAWJATZ379SlxGQtgyFo4nQ/IQaMTV/XWrL5ErcBdvP2V+uv4DByi6soxgewXuryzLBDOb/mGUt5LiOrOSsMrCoBq0IzyHP1BFNCrqJgxQzg0kGEckTHniAicJWhCO5mcZfuWHLXHE3zX1V6hDeF74UZf6TxiQdUn1LHdH/x2Fq3V9ZxNW7zf3F99XGd4k1f2uLLAsfsrDsV9fq3rRfO0H+LVFAg9X4C7DmJRb8fYORYy3NdvK8x57jjbPnaGEqJxQP/eLH21Q/cjFg5G3mhnF6yB2gnEFbl/XIUHPqb9gR1+p+CltTeKI7pl0MycZ/o7ucISz4mzwcP9oP9J73y7DKGrRHx8fwQOk9O4jV2/xgjKCti1S9QqfEI4Ryd7okOooNEB5SgU3x6nY+7oT3qyS3bolN1gN8yu4rJor71hGNyqu9XDQey8ONnQNHDQ9g+cglHDQGIeDtS7s4yA9g+o+Osw9P09Lg7AMt4zjXXbezdhvc6TWb9BlsGHc0R9GxwmpjJRpVfutceCW/bKbR+EqobQFFQTK9EhGEIwlEYzpSCTTEE8RjWlJRAMjeovbOd1YkeIdSwJjSmGSa77A/za4HAAgYr9tUnOuHzMJJvM8LfYNBdLzGqdpmKzu/Izd8ZlkyI/b4/hrc09J37h8UCiSOy80z3aUXoU2Byh0AOfQgSoIpBOHtVgICIQKCIzQkj145M9R9ITzkISYAybDX0jnuy/CgDgMAvaSzYCb6oLNgcDP182b67A8guGRwNuwZfi2NeBbNvsNR3ONyy6AnlGeMwZ3DxCKYcrO/ejcg0TqQKRsMyeIwS5FWUi5wGRckJ52+4dgWhjMGmNS2LqzuWGowRbBQbDVaTgtm0eWJ0GWJQGWp8Nuyqa04cD6TFDcxcJNEEzIGQM5AL4jcswJOT8vcpyZ837IAYfd6A8qOUwfMySM546hR5CuddAEyJwSLXK0Jznqk6PzfnJ0z2DJ/0Ixfpls+Ri23Pbe0QtQSCo1qUOjHZUZB2JPuPDQfDkkNJxjQnAsCQ0JTmUiqJEVb1csSXwd5wsfXa9QgjI/+jd/i+c4yum+n+VdBAQ+8pYiAvalXjQI3BRCUWCo5cBcHQI/GInuJF9bBnbgt7zgzQ0dYMJ0K82wHJ+uqVPW4sUeXlBhXIQETdfI6KE8JKswQUMyN4fvIjutcwdB4yiOyD71SnCCBChXpI5y9KZXZEaWTx+fUSNc3pttpsq2CYSy3MxMg0pYskioZ/ZaRmh7w+o+9L1RElSb94vIz/NwIc5TnKWZGwgg2CdLfqqydLAfBVztqcv8Fndlid2alqHIJ+ELX7GSMby6wxMOC43vSY90zFiON9kCVWftxNa90Ey4kJiYI362QqRzoUL+zWurQUIW4hxpJR2ZlfyC/aCVSZ7MgVJGTeYSycwB0GENHO0T5LDkbll0UEPIWcoENcyG1qiOdgRLf0+EbOMeagCamIADVhdoQIo0UwfU9AZur2uUyCoDQz26qpagXjkY7jr2wnlIGfsMVS+tSWBhWgOywqss/tNRd9VcmGIQFPMHQ1GhmjueUKcDdbZlXTtquLOd03FXz9QXiDv1TNeEPC3IU8136bB3YIr1Ro31hCw4GBrrATFFJiY79cV6YKxY73PyQsXczQz5aRoxkSw3yYJ5uvne5NEUGfKRocsbD8OVTFtjxYZAFhsKIjlkKY6qViiaabQNyVcmjGtr5lX739hJ14ZpV/utAkshNCqD7O1re+dbeQXG/pIgntJXPFHr1CpVv7bvJa3U4tp2XJQpEwwQFBtQVS2ZEGFCMXDsMWQUQ/5ba1jKBuT9jwss0WC6ArLLKw62kvDSoQ88m4e+NVPFrEw/jsLxB8MsqFdR1FZWdL1GAm2d9+t9LmF8/VzaQC5LyFwUyJ02xDkrfm0YgLfkzsyeLPnPb8nrsoeqUjiGrVcpztW73E54c43L5FBpVqmBfkho7VlzAJVC68BBXmC/c2gtLk+SphINp6tppo7WAvtgIHUcUDbzOJQA4s8sQNmUnTlTdsZxZqp5QR35GVvWj6RxHRAHnpaRKbB2MOnH93J000YD2qQa72CEFTQHO6X8H5sMXccoz4tlqid3x41gws65wtJWWKP6nu6fWft4lQNoXHvmrCd80efhXbY354pWR9Wbc4VMUqeVSJM7JzYGO65e96y+vFbQ7vN7BoDW40ALgCJoG5/qw4HWGBqYQ5O70FiBuesKgXbFz15lcuUxlmrMcux429Ub+DsKZaFfQ4kuRGFci3cDbLH+M1RjHEOoXo+lMd5+RJ9bYxxeg0/XGOvja0xficRzm9SbeCk1JbvgZNmkeJoVr04TalO8/iyLlgj5lLTLkR+3OPfCovEjZulqopEj5s4XdaQZm7FiZkehJD411AwPHMRmqaHhriX6A4rViyEW6mBb88CGmvswT32yWEtKBziO/SSYGmnUrUYnojpfG42jsOj0aN+xmRaG+459xvz49EM9SV+KFRHSD+LCKXWfTjAiijmzIUZEJRf7636rDfCCaNbUneVjbTUKpkXjZzTYnrhUyeyKfKxF4/CIL4VMTt7R5tmzhOXPQztUXHEdNVCLuQfYZ6hhmaTUybtJUyTz5P7Gst6Q9jLayes7aERcyBe9LO98Xh88roO0YrSG0uo+nrb656AB22k+1kDam+Y7rbraTv9dWAdpt41YU7PcSNXVzvPyHaQnZ9ughlj24j6XotBFdwpl0BtMX2A5YWbgzA7befIJvXlSUCzD1uSBWsLkYcvW3oz3nRZ3+u5g9b26vLqGpm/XOaDJGO9Zkwnsrlzrls+TxDp9hvBcYpV9bH80sR5RGJjEeoxYhY/j2CY8n1AVfPhJqFp0Vda7r0msdHf3j19Kp3j333XAw/8= -------------------------------------------------------------------------------- /docs/api/aggregates/img/Aggregate_Stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/aggregates/img/Aggregate_Stream.png -------------------------------------------------------------------------------- /docs/api/aggregates/img/Event_Engine_Functional_Core.xml: -------------------------------------------------------------------------------- 1 | 7VnRcps6EP0aPzYDCLD9GDtO25nbuZlJZ/rYkUHGmgjEFXLs9OvvCiSwANekIW47if1gOFqt0J7DaiVP0DI9fBQ4337hMWETz4kPE3Qz8TwXzX34UchThUyDeQUkgsbaqAHu6Q+iQUejOxqTwjKUnDNJcxuMeJaRSFoYFoLvbbMNZ/aoOU5IB7iPMOui32gstxU686YN/onQZGtGdkM9vzWOHhLBd5keb+KhTfmpmlNsfOmJFlsc8/0RhFYTtBScy+oqPSwJU7E1Yav63Z5orZ9bkEwO6TCrOjxitiPmicvnkk8mFtABwg43i/2WSnKf40i17IF5wLYyZXDnwiUu8oqLDT0Q8L8opOAPZMkZF6UrtJyrb91iQosA2VDGjix10ADnmdT6cD19f2SHFuqrBmc0yQCLYOIEGhfdSOjgPBIhyeEI0pH5SHhKpHgCE9060yRpEbtBdbtvFBEak+2RGmoQaxUmteeGCbjQZPQT41+UmLD8DCImUN/fTIwb2MzMnQ4zAephpgZfwozXw0zIpA6BRVH4346bhg9FGaxrMIDZH5rGMo2ZuDVgnTcaKEz0bzna2gC3uyySlGdYPeWSC2IsYCbrTi/RIGrofKfsneqmkFgSRoqiQSAPfyCbjdIP5FFBauN+dzRNdxKvGTHeTpvXz1iFzcAtkUOCzNXllhxwwkFIi5wICqQpLRn0zkDe+ZfhBUlptQhnvvebte85raw064rf93rEj4IRxB+8WPxuaIv/hKhXj6T0t8oSmv1U0ecUBHGVP5NAxjPS4lpDHfoUSxSqhGvdkNI4ZqcScFkFqHR74zxDMiMoBLWyo4u6CnH70qM3gkDCSwnkMzzJv++6eI4u/MDSRRhcUBfTS+ni09evd2B9K3BK9lw8vB2JCA4LLlVrZNltDMWg2ZWtmT7JBK8kmfn5EthE9R+8JuyOF1TPf82l5KlNXZsByfM+os4yDCF3nMXqVlFhqpP0kKiN8BX+AeXU1a4oPV3y1UYWS153p9JH0hgFgeueZ8lEacPI4VrtzmHSJIv15U3EcFHQqIw9FrILH5F4aps4tKp7TvV2khcSW6cHXVYGxl0QBi/so33m0EeGHuGO0zJVGkm0KsFpi86C70REdKfjM4C2n/Y+12k5AlYSIjuOSmnUsx6mlpdvngYuA19gF6MOeTxnofgXb3IZmI+0DoShrZC6krhE5eCiv3UdiDFshXF5QnPRMs8ma9ZDVl9WCsfgasCx1Z/JFViT76OsDsOZ8r2gxVTPOVbQw1QwAlN9O/m/gqlUZ/aLUjV3bKqQPx30UqHp1Rhk9e2q34usSxRZ01ZxhMLw16qs2fyMoxGrrAE7p3e5vIpcfGdusTxtV0BD5dJ2NGs7Gk8uxvPrF+W54DzfTtQfHmkOuT+Txdupy8co7ZBviSIIe+rw0Ouq+xfqcLht/oauVNX8149W/wM= -------------------------------------------------------------------------------- /docs/api/aggregates/img/event_engine_functional_core.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/aggregates/img/event_engine_functional_core.png -------------------------------------------------------------------------------- /docs/api/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Docs", 3 | "content": [ 4 | {"set_up": "set-up/bookdown.json"}, 5 | {"descriptions": "descriptions/descriptions.md"}, 6 | {"functional_core": "aggregates/functional_core.md"}, 7 | {"immutable_state": "state/immutable_state.md"}, 8 | {"document_store": "document-store/bookdown.json"}, 9 | {"projections": "projections/bookdown.json"}, 10 | {"discolight": "discolight/discolight.md"} 11 | ], 12 | "tocDepth": 3, 13 | "numbering": false 14 | } -------------------------------------------------------------------------------- /docs/api/descriptions/img/desc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/descriptions/img/desc.gif -------------------------------------------------------------------------------- /docs/api/descriptions/img/order_stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/descriptions/img/order_stream.png -------------------------------------------------------------------------------- /docs/api/discolight/discolight.md: -------------------------------------------------------------------------------- 1 | # Discolight 2 | 3 | Event Engine was initially designed as a workshop framework, which is still noticeable in its design. *Discolight* is one of the nice concepts carried over from workshops 4 | into production grade code. 5 | 6 | {.alert .alert-light} 7 | *Credits*: Discolight is inspired by [bitExpert/disco](https://github.com/bitExpert/disco) but removes the need for annotations. 8 | 9 | {.alert .alert-info} 10 | Discolight is a very small package. It emphasis "Hand-written service containers" similar to what Matthias Noback suggests in this [blog post](https://matthiasnoback.nl/2019/03/hand-written-service-containers/). 11 | 12 | ## Installation 13 | 14 | ```bash 15 | composer require event-engine/discolight 16 | ``` 17 | 18 | ## Service Factory 19 | 20 | If you walked your way through the tutorial, you already know about Discolight. The [skeleton app](https://github.com/event-engine/php-engine-skeleton) 21 | comes preconfigured with it. 22 | 23 | You're asked to provide a `ServiceFactory`, that contains a public factory method for each dependency. 24 | Such a [class](https://github.com/event-engine/php-engine-skeleton/blob/master/src/ServiceFactory.php) is included in the skeleton. 25 | 26 | {.alert .alert-light} 27 | *Note:* The skeleton organizes factory methods in module specific traits (`src/Domain/DomainServices.php`, `src/Persistence/PersistenceServices.php`, ...) to keep dependencies manageable. 28 | But that's only a suggestion. Each service trait becomes part of the main service factory at runtime. You could also put all methods in one class or organize the traits differently. 29 | 30 | 31 | 32 | ## Service Ids 33 | 34 | The service factory does not need to implement a specific interface. Instead, Discolight scans it and treats **all public methods** of the class as service factory methods. 35 | The return type of a factory method becomes the **service id**. 36 | 37 | Let's look at the [method](https://github.com/event-engine/php-engine-skeleton/blob/master/src/Persistence/PersistenceServices.php#L47) which provides the service `EventEngine\Persistence\MultiModelStore`: 38 | 39 | ```php 40 | public function multiModelStore(): MultiModelStore 41 | { 42 | return $this->makeSingleton(MultiModelStore::class, function () { 43 | return new ComposedMultiModelStore( 44 | $this->transactionalConnection(), 45 | $this->eventEngineEventStore(), 46 | $this->documentStore() 47 | ); 48 | }); 49 | } 50 | ``` 51 | 52 | A lot of stuff going on here, so we'll look at it step by step. 53 | 54 | ```php 55 | public function multiModelStore(): MultiModelStore 56 | ``` 57 | 58 | It's a `public` method of the `ServiceFactory`, therefor `EventEngine\Persistence\MultiModelStore` becomes the **service id**. 59 | This means that you can do the following to get the multi model store from Discolight: 60 | 61 | ```php 62 | $store = $discolight->get(MultiModelStore::class); 63 | ``` 64 | 65 | ## Singleton Service 66 | 67 | In most cases we want to get the same instance of a service from the container no matter how often we request it. This is called a `Singleton`. 68 | Discolight is dead simple. It does not know anything about singletons. Instead we use a pattern called [memoization](https://en.wikipedia.org/wiki/Memoization) 69 | to cache the instance of a service in memory and return it from cache on subsequent calls. 70 | 71 | The `ServiceFactory` is userland implementation. No interface implementation required. To add memoization to your service factory use the provided 72 | trait `EventEngine\Discolight\ServiceRegistry` like it is done in the skeleton service factory. 73 | 74 | ```php 75 | final class ServiceFactory 76 | { 77 | use ServiceRegistry; 78 | /* use service traits ... */ 79 | ``` 80 | 81 | Now you can store service instances in memory: 82 | 83 | ```php 84 | public function multiModelStore(): MultiModelStore 85 | { 86 | return $this->makeSingleton(MultiModelStore::class, function () { 87 | //... 88 | }); 89 | } 90 | ``` 91 | 92 | You might recognize that we use `MultiModelStore::class` again as service id for the registry. The second argument of `makeSingleton` is a closure which acts 93 | as a **factory function** for the service. When `MultiModelStore::class` is not in the cache, the factory function is called otherwise the service is returned from the registry. 94 | 95 | ## Injecting Dependencies 96 | 97 | Often one service depends on other services. The multi model store requires a `TransactionalConnection` an `EventStore` and a `DocumentStore` 98 | and because all services are provided by the same `ServiceFactory` we can simply get those services by calling the appropriate methods. 99 | 100 | {.alert .alert-light} 101 | By default a closure is bound to its parent scope (the service factory instance in this case). Hence, insight the closure we have 102 | access to all methods of the service factory no matter if they are declared public, protected or private. 103 | 104 | ```php 105 | public function multiModelStore(): MultiModelStore 106 | { 107 | return $this->makeSingleton(MultiModelStore::class, function () { 108 | return new ComposedMultiModelStore( 109 | $this->transactionalConnection(), 110 | $this->eventEngineEventStore(), 111 | $this->documentStore() 112 | ); 113 | }); 114 | } 115 | ``` 116 | 117 | 118 | The multi model store interface is service id and return type at the same time. Therefor, PHP's type system ensures at runtime that a valid store is returned. 119 | Internally, we built a `ComposedMultiModelStore`. If we want to switch the store 120 | we can return another implementation. 121 | 122 | ## Configuration 123 | 124 | Another thing that is out of scope for Discolight is application configuration. Remember: *providing a working `ServiceFactory` is your task*. When services 125 | need configuration then pass it to the ServiceFactory. The skeleton uses environmental variables mapped to config params in 126 | [config/autoload/global.php](https://github.com/event-engine/php-engine-skeleton/blob/master/config/autoload/global.php#L14). 127 | 128 | The configuration array is then passed to the `ServiceFactory` in the constructor and wrapped with an `ArrayReader`: 129 | 130 | ```php 131 | final class ServiceFactory 132 | { 133 | use ServiceRegistry; 134 | 135 | //... 136 | 137 | public function __construct(array $appConfig) 138 | { 139 | $this->config = new ArrayReader($appConfig); 140 | } 141 | ``` 142 | 143 | This way we have access to the configuration when building our services. We can see this in action in the [factory method](https://github.com/event-engine/php-engine-skeleton/blob/master/src/Persistence/PersistenceServices.php#L25) of the `\PDO` connection: 144 | 145 | ```php 146 | public function pdoConnection(): \PDO 147 | { 148 | return $this->makeSingleton(\PDO::class, function () { 149 | $this->assertMandatoryConfigExists('pdo.dsn'); 150 | $this->assertMandatoryConfigExists('pdo.user'); 151 | $this->assertMandatoryConfigExists('pdo.pwd'); 152 | return new \PDO( 153 | $this->config()->stringValue('pdo.dsn'), 154 | $this->config()->stringValue('pdo.user'), 155 | $this->config()->stringValue('pdo.pwd') 156 | ); 157 | }); 158 | } 159 | ``` 160 | 161 | `$this->assertMandatoryConfigExists(/*...*/)` is a helper function of the `ServiceFactory` marked as private. It is ignored by Discolight but we can use 162 | it within factory methods. 163 | 164 | ```php 165 | private function assertMandatoryConfigExists(string $path): void 166 | { 167 | if(null === $this->config->mixedValue($path)) { 168 | throw new \RuntimeException("Missing application config for $path"); 169 | } 170 | } 171 | ``` 172 | 173 | ## Service Alias 174 | 175 | In some cases using a full qualified class name (FQCN) of an interface or class as service id is not suitable. In such a case you can configure an **alias** 176 | like shown in the example: 177 | 178 | 179 | ```php 180 | $serviceFactory = new \MyService\ServiceFactory($config); 181 | 182 | $container = new \EventEngine\Discolight\Discolight( 183 | $serviceFactory, 184 | [PostgresEventStore::class => 'prooph.event_store'] 185 | ); 186 | ``` 187 | 188 | You pass a map of **service id => alias name** as second argument to Discolight. 189 | 190 | ## Production Optimization 191 | 192 | Discolight uses `\Reflection` to scan the ServiceFactory class and find out about public factory methods and their return types. 193 | It's a myth that reflection is slow. However, rescanning the ServiceFactory on every request in a production environment just does not make sense. 194 | Code does not change, so doing it once and remember the result is the better option. 195 | 196 | ```php 197 | 198 | $serviceMapCache = null; 199 | 200 | if(getenv('APP_ENV') === 'prod' && file_exists('data/ee.cache.php')) { 201 | //Read cache from file 202 | $serviceMapCache = require 'data/ee.cache.php'; 203 | } 204 | 205 | $serviceFactory = new \MyService\ServiceFactory($config); 206 | 207 | $discolight = new \EventEngine\Discolight\Discolight( 208 | $serviceFactory, 209 | [PostgresEventStore::class => 'prooph.event_store'], 210 | $serviceMapCache // <-- Pass cache as third argument. If it's NULL a rescan is triggered 211 | ); 212 | 213 | if(!$serviceMapCache && getenv('APP_ENV') === 'prod') { 214 | // ServiceFactoryMap is an array 215 | // var_export turns that array in a string parsable by PHP 216 | // The cache file itself is a PHP script that returns the array 217 | file_put_contents( 218 | 'data/ee.cache.php', 219 | "getServiceFactoryMap(), true) . ';' 220 | ); 221 | } 222 | 223 | ``` -------------------------------------------------------------------------------- /docs/api/document-store/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Document Store", 3 | "content": [ 4 | {"set_up": "overview.md"} 5 | ], 6 | "tocDepth": 1 7 | } -------------------------------------------------------------------------------- /docs/api/document-store/overview.md: -------------------------------------------------------------------------------- 1 | # Document Store 2 | 3 | {.alert .alert-warning} 4 | Work in Progress 5 | -------------------------------------------------------------------------------- /docs/api/projections/aggregate_projector.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/projections/aggregate_projector.md -------------------------------------------------------------------------------- /docs/api/projections/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Projections", 3 | "content": [ 4 | {"set_up": "custom_projections.md"} 5 | ], 6 | "tocDepth": 1 7 | } -------------------------------------------------------------------------------- /docs/api/projections/custom_projections.md: -------------------------------------------------------------------------------- 1 | # Custom Projections 2 | 3 | {.alert .alert-warning} 4 | Work in Progress 5 | -------------------------------------------------------------------------------- /docs/api/set-up/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Set Up", 3 | "content": [ 4 | {"installation": "installation.md"}, 5 | {"di": "di.md"}, 6 | {"production_optimization": "production_optimization.md"} 7 | ], 8 | "tocDepth": 1, 9 | "numbering": false 10 | } -------------------------------------------------------------------------------- /docs/api/set-up/di.md: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | {.alert .alert-info} 4 | When initializing Event Engine - `EventEngine::initialize()` or `EventEngine::fromCachedConfig()` - you have to pass a couple 5 | of mandatory and optional dependencies. This page serves as an overview and provides links to the docs of each dependency. 6 | 7 | ## Schema 8 | 9 | {.alert .alert-warning} 10 | **Mandatory Dependency** 11 | 12 | 13 | An `EventEngine\Schema\Schema` is either required in the Event Engine constructor or as the first argument of `EventEngine::fromCachedConfig()`. 14 | The latter method is used when initializing Event Engine from a cached config. [Production Optimization](/api/set-up/production_optimization.html) contains 15 | further information. 16 | 17 | @TODO Link to schema docs 18 | 19 | {.alert .alert-info} 20 | [event-engine/php-json-schema](#){: class="alert-link"} (@TODO add link) provides a JSON Schema implementation of `EventEngine\Schema\Schema`. 21 | 22 | {.alert .alert-light} 23 | A Schema implementation is the only dependency required in the constructor. Event Engine needs it to validate message schema defined in the **description phase**. 24 | All other dependencies are first required when initializing Event Engine. `EventEngine::fromCachedConfig()` skips **description phase** and **initialize phase**, therefor 25 | it requires all dependencies along with the cached config. 26 | 27 | ## Flavour 28 | 29 | {.alert .alert-warning} 30 | **Mandatory Dependency** 31 | 32 | A Flavour is the gateway between Event Engine and your code. Three different Flavours are available and you can implement your own `EventEngine\Runtime\Flavour` if needed. 33 | Learn more about [Flavours](#) (@TODO add link). 34 | 35 | ## Event Store 36 | 37 | {.alert .alert-warning} 38 | **Mandatory Dependency** 39 | 40 | Event Engine inspects the event store dependency. If you provide a plain `EventEngine\EventStore\EventStore`, the **MultiModeStore** mode gets disabled. 41 | If you pass a `EventEngine\Persistence\MultiModelStore` instead, Event Engine makes use of it automatically. 42 | 43 | {.alert .alert-light} 44 | You can pass a `MultiModelStore` as `EventStore` because the MultiModelStore is a composition of the event store and document store. 45 | 46 | - [Event Store details](#) (@TODO add link) 47 | - [Multi Model Store details](#) (@TODO add link) 48 | 49 | ## LogEngine 50 | 51 | {.alert .alert-warning} 52 | **Mandatory Dependency** 53 | 54 | To be able to provide rich logging capabilities, Event Engine requires a `EventEngine\Logger\LogEngine`. The LogEngine is responsible for translating 55 | high level logging information into the format required by the low level logger. A PSR-3 compatible low level logger is included in the [logging package](#) (@TODO add link). 56 | 57 | ## PSR-11 Container 58 | 59 | {.alert .alert-warning} 60 | **Mandatory Dependency** 61 | 62 | Whenever a component in the stack requires further dependencies you can configure a **service id** in the appropriate [Description](/api/descriptions/) and Event Engine will use that service id to pull 63 | the component from the [PSR-11 container](https://github.com/php-fig/container) when needed. Typical components that have dependencies are: *Resolvers, ContextProviders and Projectors*. 64 | 65 | {.alert .alert-success} 66 | A specific container implementation is not required! Anyway, Event Engine wants to keep things simple and straightforward. Therefor, it provides a lightweight container implementation called 67 | **Discolight**. Make sure to [try it out](/api/discolight.html). Maybe it's an eye opener ;). 68 | 69 | ## Document Store 70 | 71 | {.alert .alert-light} 72 | **Optional Dependency** 73 | 74 | The `EventEngine\DocumentStore\DocumentStore` is only required if you **a) do not use a MultiModelStore** and **b) use the built-in aggregate projector** instead. 75 | 76 | Learn more about the [Document Store](/api/document-store/) and the [Aggregate Projector](/api/projections/aggregate_projector.html). 77 | 78 | ## Event Queue 79 | 80 | {.alert .alert-light} 81 | **Optional Dependency** 82 | 83 | Newly recorded events are dispatched automatically by Event Engine within the same PHP process. If you wish to publish them on a message queue instead, provide an implementation of 84 | `EventEngine\Messaging\MessageProducer` and Event Engine will forward all recorded events to it. 85 | 86 | Details about event publishing can be found [here](#) (@TODO add link). 87 | -------------------------------------------------------------------------------- /docs/api/set-up/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | {.alert .alert-info} 4 | Event Engine is not a full stack framework. Instead you integrate it in any PHP framework that supports [PHP Standards Recommendations](https://www.php-fig.org/psr/){: class="alert-link"}. 5 | 6 | ## Skeleton 7 | 8 | The easiest way to get started is by using the [skeleton](https://github.com/event-engine/php-engine-skeleton). 9 | It ships with a preconfigured Event Engine, a recommended project structure, ready-to-use docker containers and [Zend Strategility](https://github.com/zendframework/zend-stratigility) to handle HTTP requests. 10 | 11 | {.alert .alert-light} 12 | *Again*: The skeleton is not the only way to set up Event Engine. You can tweak set up as needed and integrate Event Engine with Symfony, Laravel or any other framework 13 | or middleware dispatcher. 14 | 15 | ## Required Infrastructure 16 | 17 | Event Engine is based on **PHP 7.2 or higher**. Package dependencies are installed using [composer](https://getcomposer.org/). 18 | 19 | ### Database 20 | 21 | By default Event Engine uses [prooph/event-store](http://docs.getprooph.org/event-store/) to store **events** recorded by the **write model** 22 | and a **DocumentStore** (see "Document Store" chapter) to store the **read model**. 23 | 24 | {.alert .alert-info} 25 | The skeleton uses prooph's Postgres event store 26 | and a [Postgres Document Store](https://github.com/event-engine/php-postgres-document-store){: class="alert-link"} implementation. 27 | This allows Event Engine to work with a single database, but that's not a requirement. You can mix and match. Event Engine defines a lean 28 | event store interface, that can be found in the [event-engine/php-event-store](https://github.com/event-engine/php-event-store/blob/master/src/EventStore.php) package. 29 | Projections don't have a hard dependency on the document store, either. A document store is only required when using the **MultiModelStore** feature or the default **aggregate projection**. 30 | Other than that, you can use whatever you want to persist the read model. 31 | 32 | #### Creating The Event Stream 33 | 34 | By default all events are stored in a single stream and **prooph/event-store has to be set up with the SingleStreamStrategy!** 35 | The reason for this is that projections rely on a guaranteed order of events. 36 | A single stream is the only way to fulfill this requirement. 37 | 38 | {.alert .alert-light} 39 | When using a relational database as an event store a single 40 | table is also very efficient. A longer discussion about the topic can be found 41 | in the [prooph/pdo-event-store repo](https://github.com/prooph/pdo-event-store/issues/139){: class="alert-link"}. 42 | 43 | An easy way to create the needed stream is to use the event store API directly. 44 | 45 | ```php 46 | get(EventStore::class); 64 | $eventStore->create(new Stream(new StreamName('event_stream'), new ArrayIterator())); 65 | 66 | echo "done.\n"; 67 | 68 | ``` 69 | 70 | Such a [script](https://github.com/event-engine/php-engine-skeleton/blob/master/scripts/create_event_stream.php) is used in the skeleton. 71 | As you can see we request the event store from a container that we get from a config file. The skeleton uses [Zend Strategility](https://github.com/zendframework/zend-stratigility) 72 | and this is a common approach in Strategility (and Zend Expressive) based applications. 73 | 74 | {.alert .alert-light} 75 | If you want to use another framework, adopt the script accordingly. 76 | The only thing that really matters is that you get a configured prooph/event-store from the [PSR-11 container](https://www.php-fig.org/psr/psr-11/) 77 | used by Event Engine. 78 | 79 | #### Read Model Storage 80 | 81 | Projection storage is set up on the fly. You don't need to prepare it upfront, but you can if you prefer to work with a database migration tool. It is up to you. 82 | Learn more about read model storage set up in the projections chapter. 83 | 84 | {.alert .alert-warning} 85 | The **Multi-Model-Store** requires existing read model collections. Please find a detailed explanation in [the tutorial](/tutorial/partIII.html#2-4-2). 86 | 87 | ## Descriptions 88 | 89 | Event Engine is bootstrapped in three phases. **Descriptions** are loaded first, followed by a `$eventEngine->initialize(/* dependencies */)` call. 90 | Finally, `$eventEngine->bootstrap($env, $debugMode)` prepares the system so that it can handle incoming messages. 91 | 92 | {.alert .alert-info} 93 | Bootstrapping is split because description and initialization phase can be skipped in production. 94 | Read more about this in [Production Optimization](/api/set_up/production_optimization.html). 95 | 96 | A "zero configuration" approach is used. While you have to configure integrated packages like *prooph/event-store*, Event Engine itself 97 | does not require centralized configuration. Instead it loads so called **Event Engine Descriptions**: 98 | 99 | ```php 100 | registerCommand( 138 | self::REGISTER_USER, //<-- Name of the command defined as constant above 139 | JsonSchema::object([ 140 | Payload::USER_ID => Schema::userId(), 141 | Payload::USERNAME => Schema::username(), 142 | Payload::EMAIL => Schema::email(), 143 | ]) 144 | ); 145 | 146 | } 147 | } 148 | ``` 149 | 150 | Now we only need to tell Event Engine that it should load the *Description*: 151 | 152 | ```php 153 | declare(strict_types=1); 154 | 155 | require_once 'vendor/autoload.php'; 156 | 157 | $eventEngine = new EventEngine( 158 | new OpisJsonSchema() /* Or another Schema implementation */ 159 | ); 160 | 161 | $eventEngine->load(App\Api\Command::class); 162 | 163 | ``` 164 | 165 | {.alert .alert-light} 166 | Event Engine only requires a `EventEngine\Schema\Schema` implementation in the constructor. All other dependencies are passed during **initialize** phase (see next section). 167 | 168 | ## Initialize 169 | 170 | Event Engine needs to aggregate information from all **Descriptions**. 171 | This is done in the *Initialize phase*. The phase also requires mandatory and optional dependencies used by Event Engine. 172 | Details are listed on the [Dependencies](/api/set_up/di.html) page. 173 | 174 | ## Bootstrap 175 | 176 | Last, but not least `$eventEngine->bootstrap($environment, $debugMode)` starts the engine and we're ready to take off. 177 | Event Engine supports 3 different environments: `dev`, `prod` and `test`. The environment is mainly used to set up third-party components like a logger. 178 | 179 | Same is true for the `debug mode`. It can be used to enable verbose logging or displaying of exceptions even if Event Engine runs in prod environment. 180 | You have to take care of this when setting up services. Event Engine just provides the information: 181 | 182 | ``` 183 | Environment: $eventEngine->env(); // prod | dev | test 184 | Debug Mode: $eventEngine->debugMode(); // bool 185 | ``` 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /docs/api/set-up/production_optimization.md: -------------------------------------------------------------------------------- 1 | # Production Optimization 2 | 3 | Event Engine boots in three phases: **description phase, initializing phase, bootstrapping phase**. 4 | 5 | {.alert .alert-light} 6 | Learn more about the [three phases](https://event-engine.github.io/api/set_up/installation.html#3-1-1-3). 7 | 8 | To speed up the process you can skip the first two phases in production. This page explains how it works. 9 | 10 | ## Warm Cache 11 | 12 | The best approach is to warm up the Event Engine cache during deployment. Luckily, Event Engine is already 13 | prepared for that scenario. A simple PHP script is sufficient: 14 | 15 | {.alert .alert-light} 16 | Example script is taken from a production system 17 | 18 | ```php 19 | #!/usr/bin/env php 20 | get('config'); 32 | 33 | if($config['cache_enabled'] && $config['cached_config_file']) { 34 | 35 | // Thanks to the three phases, we can easily set up Event Engine 36 | // Without the need to pass all dependencies 37 | // This is useful, because we don't want to establish a database connection 38 | // during deployment ... 39 | $eventEngine = new \EventEngine\EventEngine( 40 | $container->get(\EventEngine\Schema\Schema::class) 41 | ); 42 | 43 | // ... but only want to load EE Descriptions ... 44 | foreach ($config['descriptions'] as $description) { 45 | $eventEngine->load($description); 46 | } 47 | 48 | // ... and cache the result 49 | file_put_contents( 50 | $config['cached_config_file'], 51 | "compileCacheableConfig(), true) . ';' 52 | ); 53 | 54 | echo sprintf('Event Engine config file "%s" saved.', $config['cached_config_file']) . PHP_EOL; 55 | } 56 | 57 | ``` 58 | 59 | {.alert .alert-info} 60 | Call the script while preparing the deployment (f.e. when building the production docker image) and include the generated cache file in the build. 61 | 62 | You can then check for the existence of a cache file when setting up Event Engine. 63 | 64 | {.alert .alert-light} 65 | Example factory is again taken from a production system. 66 | 67 | ```php 68 | get('config'); 91 | 92 | $schema = $container->get(Schema::class); 93 | $flavour = $container->get(Flavour::class); 94 | $multiModelStore = $container->get(MultiModelStore::class); 95 | $logger = $container->get(LogEngine::class); 96 | 97 | $messageProducer = null; 98 | 99 | if ($container->has(MessageProducer::class)) { 100 | $messageProducer = $container->get(MessageProducer::class); 101 | } 102 | 103 | if($config['cache_enabled'] && $config['cached_config_file'] && file_exists($config['cached_config_file'])) { 104 | $cachedConfig = require $config['cached_config_file']; 105 | 106 | $eventEngine = EventEngine::fromCachedConfig( 107 | $cachedConfig, 108 | $schema, 109 | $flavour, 110 | $multiModelStore, 111 | $logger, 112 | $container, 113 | null, 114 | $messageProducer 115 | ); 116 | } else { 117 | $eventEngine = $this->createEventEngine($schema, $config['descriptions']); 118 | 119 | $eventEngine->initialize($flavour, $multiModelStore, $logger, $container, null, $messageProducer); 120 | 121 | // If cache file is missing for whatever reason, recreate it 122 | if($config['cache_enabled'] && $config['cached_config_file']) { 123 | file_put_contents( 124 | $config['cached_config_file'], 125 | "compileCacheableConfig(), true) . ';' 126 | ); 127 | } 128 | } 129 | 130 | $debug = $container->get('config')['debug'] ?? false; 131 | $eventEngine->bootstrap($debug ? EventEngine::ENV_DEV : EventEngine::ENV_PROD, $debug); 132 | 133 | return $eventEngine; 134 | } 135 | 136 | /** 137 | * Returns a minimal event engine instance to generate cache file 138 | * 139 | * @param Schema $schema 140 | * @param string[] $descriptions FQCN[] of EventEngineDescription implementations 141 | * @return EventEngine 142 | */ 143 | private function createEventEngine(Schema $schema, array $descriptions): EventEngine 144 | { 145 | $eventEngine = new EventEngine($schema); 146 | 147 | foreach ($descriptions as $description) { 148 | $eventEngine->load($description); 149 | } 150 | 151 | // We use async projections 152 | $eventEngine->disableAutoProjecting(); 153 | 154 | return $eventEngine; 155 | } 156 | } 157 | 158 | ``` 159 | 160 | 161 | -------------------------------------------------------------------------------- /docs/api/state/img/VO/immutable_record.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/immutable_record.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/immutable_record_array_prop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/immutable_record_array_prop.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/immutable_record_file_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/immutable_record_file_template.png -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_bool.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_bool.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_collection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_collection.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_datetime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_datetime.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_float.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_float.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_int.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_int.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_string.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_string.gif -------------------------------------------------------------------------------- /docs/api/state/img/VO/vo_uuid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/api/state/img/VO/vo_uuid.gif -------------------------------------------------------------------------------- /docs/api/state/immutable_state.md: -------------------------------------------------------------------------------- 1 | # Immutable State 2 | 3 | Using immutable objects whenever possible results in robust implementations. Function calls and state changes become predictable. 4 | Using value objects instead of raw data structures like arrays or plain strings adds type safety, acts as documentation and makes code much more 5 | readable. All very important properties for long-lived applications that are constantly reshaped. 6 | 7 | The [EventEngine\Data](https://github.com/event-engine/php-data) package provides useful helpers to speed up development of immutable objects. 8 | In fact, when using the Prototyping or Functional Flavour **all application state should be immutable**. 9 | 10 | {.alert .alert-warning} 11 | For the OOP Flavour the only exception are Aggregate Roots. But even then it's recommended 12 | to use a single internal state property within the AR that references immutable state and is the only mutable part of the object. 13 | 14 | ## PHPStorm Templates 15 | 16 | Writing immutable value objects in PHP is painful because you need a lot of boilerplate code and always have the risk to 17 | introduce bugs due to typos. 18 | 19 | Libraries like [FPP](https://github.com/prolic/fpp) aim to simplify the task of writing immutable objects and combine them to complex structures. 20 | 21 | {.alert .alert-dark} 22 | FPP works quite well and you can use it together with Event Engine. 23 | 24 | However, if you don't want to learn another meta language but still want to avoid writing all the boilerplate that comes along with immutable objects, 25 | `EventEngine\Data` combined with PHPStorm Live Templates might be for you. 26 | 27 | {.alert .alert-info} 28 | Keep in mind that both FPP and EventEngine\Data are only suggestions. You don't have to use them. It's also fine if you prefer working with a serializer library. 29 | 30 | The `EventEngine\Data` package contains a set of live templates specifically designed to work together with the `EventEngine\Data\ImmutableRecord`. 31 | 32 | You can import the templates by following official [PHPStorm instructions](https://www.jetbrains.com/help/phpstorm/sharing-live-templates.html). 33 | Please find the `settings.zip` [here](https://github.com/event-engine/php-data/blob/master/.env/PHPStorm/settings.zip). 34 | 35 | ## Scalar Value Objects 36 | 37 | Each scalar PHP type (string, int, float, bool) has a corresponding template that you can access by typing `vo_` in a PHP file. 38 | 39 | 1. Create an empty value object class. 40 | 2. Invoke the template in the class body. 41 | 3. Define the name of the inner property. 42 | 43 | See examples: 44 | 45 | 51 | 52 |
53 |
54 |
55 | Value Object Template vo_string 56 |
57 |
58 |
59 |
60 | Value Object Template vo_int 61 |
62 |
63 |
64 |
65 | Value Object Template vo_float 66 |
67 |
68 |
69 |
70 | Value Object Template vo_bool 71 |
72 |
73 |
74 | 75 | ## Specialized Scalar Types 76 | 77 | ### vo_uuid 78 | 79 | A UUID value object template is included, too. It works with the well known [ramsey/uuid](https://github.com/ramsey/uuid) library. 80 | Along with the `vo_uuid` template you also get a `use_uuid` template. Use them in combination like shown in the example: 81 | 82 | ![Value Object Template vo_uuid](img/VO/vo_uuid.gif) 83 | 84 | ### vo_datetime 85 | 86 | The `vo_datetime` is another specialized scalar value object template. It uses PHP's built-in `\DateTimeImmutable`, ensures 87 | `UTC` is used as well as a standard format for from/to string conversion. 88 | 89 | The `FORMAT` constant can be used to change the format. By default it is `'Y-m-d\TH:i:s.u`, which is the same format as of the `Message::createdAt` property. 90 | 91 | ![Value Object Template vo_datetime](img/VO/vo_datetime.gif) 92 | 93 | ## List / Collection 94 | 95 | If you need a list or collection with all items being of the same type you can use the `vo_collection` template. It generates quite a lot of code so that you can work with the list 96 | out-of-box. Feel free to add more methods, either to the template or after code generation if it is specific to the concrete class. 97 | 98 | The template needs two information: 99 | 100 | 1. The item class. The item class should at least have a `public static from($rawType)` method, a `public to()` method and a `public equals(ItemClass $other): bool` method. 101 | Of course all immutable objects generated with our VO templates can be used as item class. 102 | 2. The raw type of the items, one of: `string, int, float, bool, array` 103 | 104 | ![Value Object Template vo_collection](img/VO/vo_collection.gif) 105 | 106 | {.alert .alert-warning} 107 | If you use the `push()` and `pop()` methods, keep in mind that the list is immutable. That said, only the returned list contains the change (item appended, last item removed). 108 | This also means, that you have to use `last()` before `pop()` to get the last item of the list. 109 | 110 | ## Complex Types 111 | 112 | Immutable objects can have arbitrary complexity. So far we only learned about single value objects and lists. What's missing is the ability to combine them to complex objects/types. 113 | A simple PHPStorm Live Template is not suitable for the job. Hence, `EventEngine\Data` provides the interface `ImmutableRecord` and the trait `ImmutableRecordLogic` to help you out. 114 | 115 | {.alert .alert-info} 116 | When you use PHPStorm and import the **settings.zip** linked above, you have a new file template **ImmutableRecord** that you can choose when adding a new class to the project. 117 | The template for **getter** methods is also aligned. **ImmutableRecord** requires getter methods that exactly match with the properties they provide read access to. Hence, the **get** prefix 118 | is removed in the file template. The example shows both in action. 119 | 120 | These are the steps required to get a working `ImmutableRecord`: 121 | 122 | 1. Create a class that implements `ImmutableRecord` and uses the `ImmutableRecordLogic` trait (either use the file template or create the class by hand). 123 | 2. Add properties and their types. You can use the live template `record_field`, which also adds a constant for each property to avoid typos. 124 | 3. Generate getter methods for the properties with appropriate return types. The getter methods should be named like the properties. 125 | 126 | {.alert .alert-info} 127 | **ImmutableRecordLogic** relies on the return types of getter methods to know which property type class should be used when creating a record instance from raw data. 128 | 129 | ![Immutable Record](img/VO/immutable_record.gif) 130 | 131 | ### fromArray vs. fromRecordData 132 | 133 | By default `ImmutableRecordLogic` provides two ways to instantiate an object: 134 | - `fromArray()`: create the record from an array containing raw data, especially useful when mapping user input or database results. 135 | - `fromRecordData()`: create the record from value objects. 136 | 137 | ```php 138 | Uuid::uuid4()->toString(), 150 | Person::NAME => 'John', 151 | Person::AGE => 42 152 | ]); 153 | 154 | $jane = Person::fromRecordData([ 155 | Person::USER_ID => UserId::generate(), 156 | Person::NAME => GivenName::fromString('Jane') 157 | ]); 158 | 159 | ``` 160 | 161 | {.alert .alert-info} 162 | It is recommended to add named constructors to a record class 163 | using the [Ubiquitous Language](https://martinfowler.com/bliki/UbiquitousLanguage.html){: class="alert-link"} of the domain. 164 | Those methods can use `fromRecordData()` internally. 165 | 166 | For example we could add a `register()` method to our `Person` class. The `UserId` is generated internally and `Age` is nullable and is not required by default. 167 | 168 | ```php 169 | UserId::generate(), 204 | self::NAME => $givenName 205 | ]); 206 | } 207 | 208 | /* ... getter methods */ 209 | } 210 | 211 | ``` 212 | 213 | ### Nullable Properties 214 | 215 | {.alert .alert-warning} 216 | **ImmutableRecordLogic** validates the given data. If a property (or to be more precise the return type of the corresponding getter method) is not marked as nullable, then it throws an exception. 217 | Property data validation is delegated to the property type classes. You don't have to replicate it. 218 | 219 | ### Array Properties 220 | 221 | It's recommended to use the `vo_collection` live template (see above) to generate lists/collections as immutable types. Such a type class can then be used for a record property. 222 | However, in some cases you might want to use a plain php array instead of an extra class to keep a list of items in a record property. In that case you have to add a `private static arrayPropItemTypeMap(): array` method, 223 | that returns a mapping of **property name to type class**. PHP does not provide a way to specify array item types in return types (yet). Hence, `ImmutableRecordLogic` needs a hint. 224 | 225 | Let's look at an example. We a add a `friends` property to our `Person` record, define array as property/return type and provide a mapping that friends are also of type `Person`. 226 | 227 | ![Immutable Record with array prop](img/VO/immutable_record_array_prop.gif) 228 | 229 | ### Initialize Properties 230 | 231 | If we replace the plain array type of the previous example with the `FriendsList` generated earlier, our `Person` class would look like this: 232 | 233 | ```php 234 | UserId::generate(), 276 | self::NAME => $givenName 277 | ]); 278 | } 279 | 280 | /** 281 | * @return FriendsList 282 | */ 283 | public function friends(): FriendsList 284 | { 285 | return $this->friends; 286 | } 287 | 288 | 289 | /* ... other getter methods */ 290 | } 291 | 292 | ``` 293 | 294 | {.alert .alert-light} 295 | Again: no extra mapping required! Try to favor collection classes over plain arrays! 296 | 297 | When a new person registers for our service their friends list would be empty. Hence, we don't want to require that property in the `register()` named constructor. 298 | On the other hand we also don't want to make the `friendsList` property nullable. Iterating over an empty list results in no iteration at all. No need to check against null first. 299 | To solve the conflict we can override the empty `init()` method of `ImmutableRecordLogic`. The method is called after all properties have been set, but before the null check is performed 300 | (which would result in an exception for the current `Person::register()` implementation). 301 | 302 | ```php 303 | UserId::generate(), 345 | self::NAME => $givenName 346 | ]); 347 | } 348 | 349 | private function init(): void 350 | { 351 | if(null === $this->friends) { 352 | $this->friends = FriendsList::emptyList(); 353 | } 354 | } 355 | 356 | /* ... getter methods */ 357 | } 358 | 359 | ``` 360 | 361 | {.alert .alert-danger} 362 | Never override `__construct()` of **ImmutableRecodLogic**! 363 | Always use the `init()` hook for setting default values. 364 | 365 | 366 | -------------------------------------------------------------------------------- /docs/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Event Engine Docs", 3 | "content": [ 4 | {"intro": "intro/about.md"}, 5 | {"tutorial": "tutorial/bookdown.json"}, 6 | {"api": "api/bookdown.json"}, 7 | {"news": "news/bookdown.json"} 8 | ], 9 | "theme": { 10 | "toc": { 11 | "collapsibleFromLevel": 1 12 | } 13 | }, 14 | "tocDepth": 1, 15 | "template": "../template/main.php", 16 | "target": "./html", 17 | "numbering": false, 18 | "extensions": { 19 | "commonmark": [ 20 | "Webuni\\CommonMark\\TableExtension\\TableExtension", 21 | "Webuni\\CommonMark\\AttributesExtension\\AttributesExtension" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/bookdown.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Event Engine Docs", 3 | "content": [ 4 | {"intro": "intro/about.md"}, 5 | {"tutorial": "tutorial/bookdown.json"}, 6 | {"api": "api/bookdown.json"}, 7 | {"news": "news/bookdown.json"} 8 | ], 9 | "theme": { 10 | "toc": { 11 | "collapsibleFromLevel": 1 12 | } 13 | }, 14 | "tocDepth": 1, 15 | "template": "../template/main.php", 16 | "target": "./html", 17 | "numbering": false, 18 | "rootHref": "https://event-engine.github.io/", 19 | "extensions": { 20 | "commonmark": [ 21 | "Webuni\\CommonMark\\TableExtension\\TableExtension", 22 | "Webuni\\CommonMark\\AttributesExtension\\AttributesExtension" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/front.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Event Engine

5 |

The world's only CQRS / ES framework that lets you pick your Flavour

6 |
  7 |                 $ composer create-project event-engine/php-engine-skeleton event-engine
  8 |             
9 |
10 |
11 |
12 |
13 |

14 | Event Engine is a CQRS / EventSourcing framework for PHP to help you rapidly develop 15 | event sourced applications, while providing a path to 16 | refactor towards a richer domain model as needed. Customize Event Engine with Flavours. Choose between different programming styles. 17 |

18 |
19 |
20 |

Choose Your Flavour

21 |
22 |
23 |
24 |
25 |

PROTOTYPING
rapid development, maximized developer happiness

26 |
27 |
28 |
29 |
30 |
31 |
32 |

FUNCTIONAL
stateless and pure, with a funcional core

33 |
34 |
35 |
36 |
37 |
38 |
39 |

OOP
event sourced objects without dependencies

40 |
41 |
42 |
43 |
44 |
45 |
46 |

MIXED
create your very own Flavour

47 |
48 |
49 |
50 |
51 |

CQRS By Design

52 |
53 |
54 |

55 | Built-in PSR-7 Messagebox with JSON Schema and Event Engine Cockpit support. Focus on the domain model. Let Event Engine handle the rest. 56 |

57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 |

Event Sourcing Engine

65 |
66 |
67 |

68 | Straightforward Event Sourcing without boilerplate. Rapid. Effective. Simple. 69 |

70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 |

Tutorial

78 |
79 |
80 |

81 | Learn CQRS / EventSourcing basics with Event Engine in a step by step tutorial. 82 |

83 |
84 |
85 |
86 |
87 | 88 |
89 |
90 |

 

91 |

 

92 |

 

93 |
94 |
95 | Get Started 96 |
97 |
98 |
99 |
100 |
101 | Imprint | Powered by Bookdown Bootswatch Templates. 102 |
103 |
104 |
105 | 106 | -------------------------------------------------------------------------------- /docs/front.php: -------------------------------------------------------------------------------- 1 | ')); 7 | $tail = substr($html, strpos($html, '') + 9); 8 | $html = $lead . file_get_contents(__DIR__ . '/front.html') . $tail; 9 | file_put_contents($file, $html); 10 | 11 | (function (string $sourceDir, string $destDir) { 12 | function copy_dir(string $sourceDir, string $destDir) { 13 | if(!is_dir($destDir)) { 14 | mkdir($destDir); 15 | } 16 | 17 | $source = new DirectoryIterator($sourceDir); 18 | 19 | foreach ($source as $file) { 20 | if($file->isDot()) { 21 | continue; 22 | } 23 | 24 | if($file->isDir()) { 25 | copy_dir($sourceDir . '/' . $file->getFilename(), $destDir . '/' . $file->getFilename()); 26 | continue; 27 | } 28 | 29 | $destFilename = $destDir . '/' . $file->getFilename(); 30 | 31 | if(file_exists($destFilename)) { 32 | unlink($destFilename); 33 | } 34 | 35 | copy($sourceDir . '/' . $file->getFilename(), $destFilename); 36 | } 37 | }; 38 | 39 | copy_dir($sourceDir, $destDir); 40 | })(__DIR__ . '/img', __DIR__ . '/html/img'); -------------------------------------------------------------------------------- /docs/img/Choose_Flavour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/Choose_Flavour.png -------------------------------------------------------------------------------- /docs/img/Choose_Flavour_no_h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/Choose_Flavour_no_h.png -------------------------------------------------------------------------------- /docs/img/Event_Engine_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/Event_Engine_intro.png -------------------------------------------------------------------------------- /docs/img/cqrs_messagebox_cockpit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/cqrs_messagebox_cockpit.png -------------------------------------------------------------------------------- /docs/img/cqrs_messagebox_swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/cqrs_messagebox_swagger.png -------------------------------------------------------------------------------- /docs/img/prooph-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/prooph-logo.png -------------------------------------------------------------------------------- /docs/img/prooph_features.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/prooph_features.png -------------------------------------------------------------------------------- /docs/img/tutorial_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/img/tutorial_screen.png -------------------------------------------------------------------------------- /docs/intro/about.md: -------------------------------------------------------------------------------- 1 | # About Event Engine 2 | 3 | Prooph Event Engine takes away all the boring, time consuming parts of event sourcing to speed up 4 | development of event sourced applications and increase the fun. It can be used for prototypes as well as full featured applications. 5 | 6 | ## Origin 7 | 8 | Event Engine was originally designed as a "workshop framework" for CQRS and Event Sourcing and is inspired by the **Dreyfus model**. 9 | 10 | ### Beginner friendly 11 | 12 | > The Dreyfus model distinguishes five levels of competence, from novice to mastery. At the absolute beginner level people execute tasks based on “rigid adherence to taught rules or plans”. Beginners need recipes. They don’t need a list of parts, or a dozen different ways to do the same thing. Instead what works are step by step instructions that they can internalize. As they practice them over time they learn the reasoning behind them, and learn to deviate from them and improvise, but they first need to feel like they’re doing something. 13 | 14 | (source: [https://lambdaisland.com/blog/25-05-2017-simple-and-happy-is-clojure-dying-and-what-has-ruby-got-to-do-with-it](https://lambdaisland.com/blog/25-05-2017-simple-and-happy-is-clojure-dying-and-what-has-ruby-got-to-do-with-it)) 15 | 16 | ### Rapid Application Development 17 | It turned out that Event Engine is not only a very good CQRS and Event Sourcing learning framework but that the same concept 18 | can be used for rapid application development (RAD). RAD frameworks focus on developer happiness and coding speed. 19 | Both can be achieved by using conventions, which allow the framework to do a lot of work "under the hood" 20 | Developers can focus on the important part: **developing the application**. 21 | 22 | ## Event Engine Flavours 23 | 24 | Event Engine Flavours make it possible to turn a rapidly developed prototype into a rock solid application. 25 | You can switch from the default **PrototypingFlavour** to either the **FunctionalFlavour** or **OopFlavour**. Finally, you can implement your own 26 | Flavour to build your very own CQRS / ES framework. 27 | 28 | [Learn More](/tutorial/) 29 | 30 | ## Pros 31 | 32 | - Developed and maintained by prooph core team members 33 | - Ready-to-use [skeleton](https://github.com/event-engine/php-engine-skeleton) 34 | - Less code to write 35 | - Guided event sourcing 36 | - extension points to inject custom logic 37 | - Audit log from day one (no data loss) 38 | - Multi-Model-Store 39 | - Replay functionality 40 | - Projections based on domain events 41 | - PSR friendly http message box 42 | - OpenAPI v3 Swagger integration 43 | 44 | ## Cons 45 | 46 | - Not suitable for monolithic architectures 47 | 48 | ### You may want to use Event Engine if: 49 | 50 | - Your project is in an early stage and you need to try out different ideas or **deliver features very fast** 51 | - You want to establish a **Microservices architecture** rather than building a monolithic system 52 | - You want to automate business processes 53 | - You have to develop a workflow-oriented service 54 | - You're **new to the concepts** of CQRS and Event Sourcing and want to learn them 55 | 56 | ## Conclusion 57 | 58 | Try the [tutorial](/tutorial/) and build a prototype with Event Engine! 59 | 60 | ## Powered By 61 | 62 | [![prooph software](https://github.com/codeliner/php-ddd-cargo-sample/raw/master/docs/assets/prooph-software-logo.png)](http://prooph.de) 63 | 64 | Event Engine is maintained by the [prooph software team](http://prooph-software.de/). 65 | Prooph software offers commercial support and workshops for Event Engine as well as for the [prooph components](http://getprooph.org/). 66 | 67 | If you are interested please [get in touch](http://getprooph.org/#get-in-touch) -------------------------------------------------------------------------------- /docs/news/2019-04-05_new_tutorial.md: -------------------------------------------------------------------------------- 1 | # Event Engine Tutorial Available 2 | 3 | {.alert .alert-light} 4 | *Written by Alexander Miertsch ([@codeliner](https://github.com/codeliner)) - CEO prooph software GmbH - prooph core team - 2019-04-05* 5 | 6 | Three weeks ago we've released the [first dev version of Event Engine](https://github.com/event-engine/php-engine/releases/tag/v0.1.0), which supersedes Event Machine. 7 | Now we reached another important milestone towards a stable release. Make sure to check out the brand new Event Engine tutorial: [https://event-engine.github.io/tutorial/](https://event-engine.github.io/tutorial/) 8 | along with a new [skeleton application](https://github.com/event-engine/php-engine-skeleton). 9 | 10 | ## Repo Split 11 | 12 | A prerequisite for the new skeleton was the repo split announced in the release notes of v0.1. 13 | All packages are listed on [GitHub](https://github.com/event-engine) and [Packagist](https://packagist.org/packages/event-engine/). 14 | 15 | ## Rewritten Tutorial 16 | 17 | The Event Engine tutorial is a rewrite of the previous Event Machine tutorial. The story is the same but it differs in many details like a changed skeleton structure and 18 | the usage of the new **MultiModelStore** feature, which replaces aggregate projections in the default skeleton set up. That said, even if you did the Event Machine tutorial before 19 | and think you know the basics, I highly recommend to do the new Event Engine tutorial again to quickly learn more about new features and structural changes. 20 | 21 | ## API Docs 22 | 23 | At the time of writing, many API docs are still outdated. You'll find a warning at the top of each page that still needs to be migrated. 24 | 25 | ## Immutable Objects 26 | 27 | We've added new documentation about how to quickly generate immutable objects using PHPStorm templates and the `event-engine/php-data` package. 28 | [Learn more](https://event-engine.github.io/api/immutable_state.html) 29 | 30 | ## Keep Up-To-Date 31 | 32 | Follow us on [twitter](https://twitter.com/prooph_software) and watch changes/releases on the [event-engine repos](https://github.com/event-engine) 33 | -------------------------------------------------------------------------------- /docs/news/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "News", 3 | "content": [ 4 | {"2019-04-05": "2019-04-05_new_tutorial.md"} 5 | ], 6 | "tocDepth": 1, 7 | "numbering": false 8 | } -------------------------------------------------------------------------------- /docs/tutorial/bonus_II.md: -------------------------------------------------------------------------------- 1 | # Bonus II - Unit and Integration Tests 2 | 3 | Unit testing the different parts of the application is easy. In most cases we have single purpose classes and 4 | functions that can be tested without mocking. 5 | 6 | ## Testing Aggregate functions 7 | 8 | Aggregate functions are pure which makes them easy to test. php-engine-skeleton provides some test helpers in 9 | `tests/BaseTestCase.php`, so, if you extend from that base class, you're ready to go. Add a the folders `Domain/Model` in `tests` 10 | and a class `BuildingTest` with the following content: 11 | 12 | ```php 13 | buildingId = Uuid::uuid4()->toString(); 34 | $this->buildingName = 'Acme Headquarters'; 35 | $this->username = 'John'; 36 | 37 | parent::setUp(); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function it_checks_in_a_user() 44 | { 45 | //Prepare expected aggregate state 46 | $state = Building\State::fromArray([ 47 | Building\State::BUILDING_ID => $this->buildingId, 48 | Building\State::NAME => $this->buildingName 49 | ]); 50 | 51 | //Use test helper UnitTestCase::makeCommand() to construct command 52 | $command = $this->makeCommand(Command::CHECK_IN_USER, [ 53 | Building\State::BUILDING_ID => $this->buildingId, 54 | Building\State::NAME => $this->username, 55 | ]); 56 | 57 | //Aggregate functions yield events, we have to collect them with a test helper 58 | $events = $this->collectNewEvents( 59 | Building::checkInUser($state, $command) 60 | ); 61 | 62 | //Another test helper to assert that list of recorded events contains given event 63 | $this->assertRecordedEvent(Event::USER_CHECKED_IN, [ 64 | Payload::BUILDING_ID => $this->buildingId, 65 | Payload::NAME => $this->username 66 | ], $events); 67 | } 68 | } 69 | 70 | ``` 71 | You can run tests with: 72 | 73 | ```bash 74 | docker-compose run php php vendor/bin/phpunit -vvv 75 | ``` 76 | 77 | ## Testing Projectors 78 | 79 | Testing projectors is also easy when they use the `DocumentStore` API to manage projections. Event Engine ships with 80 | an `InMemoryDocumentStore` implementation that works great in test cases. Here is an example: 81 | 82 | *tests/Domain/Projector/UserBuildingListTest.php* 83 | ```php 84 | projector = new UserBuildingList($this->documentStore); 114 | $this->projector->prepareForRun( 115 | self::PRJ_VERSION, 116 | Projection::USER_BUILDING_LIST 117 | ); 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function it_manages_list_of_users_with_building_reference() 124 | { 125 | $collection = UserBuildingList::generateCollectionName( 126 | self::PRJ_VERSION, 127 | Projection::USER_BUILDING_LIST 128 | ); 129 | 130 | $johnCheckedIn = $this->makeEvent(Event::USER_CHECKED_IN, [ 131 | Payload::BUILDING_ID => self::BUILDING_ID, 132 | Payload::NAME => self::USERNAME1 133 | ]); 134 | 135 | $this->projector->handle( 136 | self::PRJ_VERSION, 137 | Projection::USER_BUILDING_LIST, 138 | $johnCheckedIn 139 | ); 140 | 141 | $users = iterator_to_array($this->documentStore->findDocs( 142 | $collection, 143 | new AnyFilter() 144 | )); 145 | 146 | $this->assertEquals($users, [ 147 | 'John' => ['buildingId' => self::BUILDING_ID] 148 | ]); 149 | 150 | $janeCheckedIn = $this->makeEvent(Event::USER_CHECKED_IN, [ 151 | Payload::BUILDING_ID => self::BUILDING_ID, 152 | Payload::NAME => self::USERNAME2 153 | ]); 154 | 155 | $this->projector->handle( 156 | self::PRJ_VERSION, 157 | Projection::USER_BUILDING_LIST, 158 | $janeCheckedIn 159 | ); 160 | 161 | $users = iterator_to_array($this->documentStore->findDocs( 162 | $collection, 163 | new AnyFilter() 164 | )); 165 | 166 | $this->assertEquals($users, [ 167 | 'John' => ['buildingId' => self::BUILDING_ID], 168 | 'Jane' => ['buildingId' => self::BUILDING_ID], 169 | ]); 170 | 171 | $johnCheckedOut = $this->makeEvent(Event::USER_CHECKED_OUT, [ 172 | Payload::BUILDING_ID => self::BUILDING_ID, 173 | Payload::NAME => self::USERNAME1 174 | ]); 175 | 176 | $this->projector->handle( 177 | self::PRJ_VERSION, 178 | Projection::USER_BUILDING_LIST, 179 | $johnCheckedOut 180 | ); 181 | 182 | $users = iterator_to_array($this->documentStore->findDocs( 183 | $collection, 184 | new AnyFilter() 185 | )); 186 | 187 | $this->assertEquals($users, [ 188 | 'Jane' => ['buildingId' => self::BUILDING_ID], 189 | ]); 190 | } 191 | } 192 | 193 | ``` 194 | 195 | ## Testing Resolvers 196 | 197 | Resolvers can be tested in the same manner as projectors, using the `InMemoryDocumentStore` with test data. 198 | I will leave implementing these tests as an exercise for you ;) 199 | 200 | ## Integration Tests 201 | 202 | If you want to test the "whole thing" then you can extend your test class from `IntegrationTestCase`. It sets up Event Engine 203 | with an `InMemoryEventStore` and an `InMemoryDocumentStore`. A special PSR-11 MockContainer ensures that all other services are mocked. 204 | Let's see it in action. The annotated integration test should be self explanatory. 205 | 206 | *tests/Integration/NotifySecurityTest.php* 207 | ```php 208 | uiExchange = new class implements UiExchange { 234 | 235 | private $lastReceivedMessage; 236 | 237 | public function __invoke(Message $event): void 238 | { 239 | $this->lastReceivedMessage = $event; 240 | } 241 | 242 | public function lastReceivedMessage(): Message 243 | { 244 | return $this->lastReceivedMessage; 245 | } 246 | }; 247 | 248 | // Mocks are passed to EE set up method 249 | // The IntegrationTestCase loads all EE descriptions 250 | // and uses the configured Flavour (PrototypingFlavour in our case) 251 | // to set up Event Engine 252 | $this->setUpEventEngine([ 253 | UiExchange::class => $this->uiExchange, 254 | ]); 255 | 256 | /** 257 | * We can pass fixtures to the database set up: 258 | * 259 | * Stream to events map: 260 | * 261 | * [streamName => Event[]] 262 | * 263 | * Collection to documents map: 264 | * 265 | * [collectionName => [docId => doc]] 266 | */ 267 | $this->setUpDatabase([ 268 | // We use the default write model stream in the buildings app 269 | // and add a history for the test building 270 | // aggregate state is derived from history automatically during set up 271 | $this->eventEngine->writeModelStreamName() => [ 272 | $this->makeEvent(Event::BUILDING_ADDED, [ 273 | Payload::BUILDING_ID => self::BUILDING_ID, 274 | Payload::NAME => self::BUILDING_NAME 275 | ]), 276 | $this->makeEvent(Event::USER_CHECKED_IN, [ 277 | Payload::BUILDING_ID => self::BUILDING_ID, 278 | Payload::NAME => self::USERNAME 279 | ]), 280 | ] 281 | ]); 282 | } 283 | 284 | /** 285 | * @test 286 | */ 287 | public function it_detects_double_check_in_and_notifies_security() 288 | { 289 | //Try to check in John twice 290 | $checkInJohn = $this->makeCommand(Command::CHECK_IN_USER, [ 291 | Payload::BUILDING_ID => self::BUILDING_ID, 292 | Payload::NAME => self::USERNAME 293 | ]); 294 | 295 | $this->eventEngine->dispatch($checkInJohn); 296 | 297 | //The IntegrationTestCase sets up an in-memory queue (accessible by $this->eventQueue) 298 | //You can inspect published events or simply process the queue 299 | //so that event listeners get invoked like our mocked UiExchange listener 300 | $this->processEventQueueWhileNotEmpty(); 301 | 302 | //Now $this->lastPublishedEvent should point to the event received by UiExchange mock 303 | $this->assertNotNull($this->uiExchange->lastReceivedMessage()); 304 | 305 | $this->assertEquals(Event::DOUBLE_CHECK_IN_DETECTED, $this->uiExchange->lastReceivedMessage()->messageName()); 306 | 307 | $this->assertEquals([ 308 | Payload::BUILDING_ID => self::BUILDING_ID, 309 | Payload::NAME => self::USERNAME 310 | ], $this->uiExchange->lastReceivedMessage()->payload()); 311 | } 312 | } 313 | 314 | ``` 315 | {.alert .alert-success} 316 | With a solid test suite in place, we can safely start refactoring our code towards a rich domain model. The next bonus part 317 | introduces stricter types for state and messages. 318 | 319 | -------------------------------------------------------------------------------- /docs/tutorial/bookdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial", 3 | "content": [ 4 | {"intro": "intro.md"}, 5 | {"partI": "part_I.md"}, 6 | {"partII": "part_II.md"}, 7 | {"partIII": "part_III.md"}, 8 | {"partIV": "part_IV.md"}, 9 | {"partV": "part_V.md"}, 10 | {"partVI": "part_VI.md"}, 11 | {"partVII": "part_VII.md"}, 12 | {"bonusI": "bonus_I.md"}, 13 | {"bonusII": "bonus_II.md"}, 14 | {"bonusIII": "bonus_III.md"}, 15 | {"bonusIV": "bonus_IV.md"} 16 | ], 17 | "tocDepth": 1, 18 | "numbering": false 19 | } 20 | -------------------------------------------------------------------------------- /docs/tutorial/img/Time_Travel.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/Time_Travel.gif -------------------------------------------------------------------------------- /docs/tutorial/img/double_check_in_detected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/double_check_in_detected.png -------------------------------------------------------------------------------- /docs/tutorial/img/double_check_out_detected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/double_check_out_detected.png -------------------------------------------------------------------------------- /docs/tutorial/img/inspect_aggregate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/inspect_aggregate.gif -------------------------------------------------------------------------------- /docs/tutorial/img/inspectio_buildings_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/inspectio_buildings_intro.png -------------------------------------------------------------------------------- /docs/tutorial/img/monitoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/monitoring.png -------------------------------------------------------------------------------- /docs/tutorial/img/notify_security.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/event-engine/docs/d14cc8422cf4c9bdbcb581a645a9eff94b7ce759/docs/tutorial/img/notify_security.png -------------------------------------------------------------------------------- /docs/tutorial/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Event Engine is a rapid application development (RAD) framework. The basic concepts will be explained throughout the tutorial. 4 | Once finished, you should be able to start with your own project. The API docs will help you along the way. 5 | 6 | ## Tutorial Domain 7 | 8 | We're going to build a backend for a small web application where you can register `buildings` and then `check in` and `check out` 9 | users in the buildings. The backend will expose a messagebox endpoint that excepts commands and queries. 10 | Each time a user is `checked in` or `checked out` we get a notification via a websocket connection. 11 | 12 | ![InspectIO Buildings Intro](img/inspectio_buildings_intro.png) 13 | 14 | {.alert .alert-info} 15 | The screenshot is taken from [InspectIO](https://github.com/event-engine/inspectio){: class="alert-link"} - a domain modelling tool for (remote) teams that supports living documentation. 16 | Event Engine users can request free access in the chat. 17 | 18 | {.alert .alert-light} 19 | *Credits: The tutorial domain is the same as the one used by Marco Pivetta in his CQRS and Event Sourcing Workshops.* 20 | 21 | ## Application set up 22 | 23 | Please make sure you have [Docker](https://docs.docker.com/engine/installation/ "Install Docker") and [Docker Compose](https://docs.docker.com/compose/install/ "Install Docker Compose") installed. 24 | 25 | {.alert .alert-warning} 26 | *Note: Docker is THE ONLY supported set up at the moment. If you don't want to install docker you need PHP 7.4+ and Postgres 9.4+.* 27 | 28 | ### Clone Event Engine Skeleton 29 | 30 | Change into your working directory and use `composer` to create a new project based on the [event engine skeleton](https://github.com/event-engine/php-engine-skeleton) 31 | using `ee-buildings` as project name. 32 | 33 | ```bash 34 | $ docker run --rm -it -v $(pwd):/app prooph/composer:7.4 create-project event-engine/php-engine-skeleton ee-buildings 35 | ``` 36 | 37 | Change into the newly created project dir `ee-buildings`, start the docker containers and run the set up script 38 | for the event store. 39 | 40 | ```bash 41 | $ cd ee-buildings 42 | $ sudo chown -R $(id -u -n):$(id -g -n) . 43 | $ docker-compose up -d 44 | $ docker-compose run php php scripts/create_event_stream.php 45 | ``` 46 | The last command should output `done.` otherwise it will throw an exception. 47 | 48 | ### Verify set up 49 | 50 | #### Database 51 | Verify database set up by connecting to the Postgres database using: 52 | 53 | ``` 54 | host: localhost 55 | port: 5432 56 | dbname: event_engine 57 | user: postgres 58 | pwd: dev 59 | ``` 60 | 61 | {.alert .alert-info} 62 | *Note: Credentials are defined in `app.env` and can be changed there.* 63 | 64 | You should see three tables: `event_streams`, `projections` and `_`. The latter is a table created by `prooph/event-store`. 65 | It will contain all `domain events`. 66 | 67 | #### Webserver 68 | Head over to [https://localhost](https://localhost) to check if the containers are up and running. 69 | After accepting the self-signed certificate you should see a simple "It works" message. 70 | 71 | #### Event Engine Cockpit 72 | By default Event Engine exposes commands (we will learn more about them in a minute), events, and queries via a message box endpoint. 73 | We can use [Event Engine Cockpit](https://github.com/event-engine/cockpit) to interact with the backend. 74 | 75 | The Event Engine skeleton ships with a ready to use Cockpit configuration. Open [https://localhost:4444](https://localhost:4444) 76 | in your browser and try the built-in `HealthCheck` query. 77 | 78 | You should get a JSON response similar to that one: 79 | 80 | ```json 81 | { 82 | "system": true 83 | } 84 | ``` 85 | 86 | {.alert .alert-success} 87 | If everything works correctly we are ready to implement our first use case: **Add a building** 88 | 89 | {.alert .alert-danger} 90 | If something is not working as expected (now or later in the tutorial) please check the trouble shooting section 91 | of the [event-engine/php-engine-skeleton README](https://github.com/event-engine/php-engine-skeleton/blob/master/README.md#troubleshooting){: class="alert-link"} first. 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /docs/tutorial/part_I.md: -------------------------------------------------------------------------------- 1 | # Part I - Add A Building 2 | 3 | We're going to add the first action to our buildings application. In a CQRS system, such as 4 | Event Engine, operations and processes are triggered by messages. Those messages can have three different types and 5 | define the API of the application. In the first part of the tutorial we learn the first message type: `command`. 6 | 7 | ## API 8 | 9 | The Event Engine skeleton includes an API folder (src/Domain/Api) that contains a predefined set of `EventEngineDescription` classes. 10 | We will look at these descriptions step by step and start with `src/Domain/Api/Command.php`: 11 | 12 | {.alert .alert-light} 13 | Throughout the tutorial we'll use the default namespace of the skeleton **MyService**. If you use the skeleton for a project, you can replace it with your own. 14 | 15 | ```php 16 | registerCommand( 72 | Command::ADD_BUILDING, 73 | JsonSchema::object( 74 | [ 75 | 'buildingId' => JsonSchema::uuid(), 76 | 'name' => JsonSchema::string(['minLength' => 2]) 77 | ] 78 | ) 79 | ); 80 | } 81 | } 82 | 83 | ``` 84 | Event Engine supports [JSON Schema](http://json-schema.org/) to describe messages. 85 | The advantage of JSON schema is that we can configure validation rules for our messages. Whenever Event Engine receives a message 86 | (command, event or query) it uses the defined JSON Schema for that message to validate the input. We configure it once 87 | and Event Engine takes care of the rest. 88 | 89 | ## Descriptions 90 | 91 | {.alert .alert-info} 92 | Event Engine Descriptions are very important. They are called at "**compile time**" and used to configure Event Engine. 93 | Descriptions can be cached to speed up bootstrapping. Find more information in the API docs **@TODO: link docs**. 94 | 95 | ## Cockpit Integration 96 | 97 | Switch to the Cockpit UI and reload the schema (press refresh button in top menu). 98 | Cockpit should show a new **command** called `AddBuilding` in the commands section. 99 | 100 | {.alert .alert-light} 101 | Click on "commands" in the left sidebar and then on "Not Categorized" to see the command. 102 | **Send** the `AddBuilding` command with this payload: 103 | 104 | ```json 105 | { 106 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 107 | "name": "Acme Headquarters" 108 | } 109 | ``` 110 | 111 | *Response:* 112 | 113 | ```json 114 | { 115 | "exception": { 116 | "message": "No routing information found for command AddBuilding", 117 | "details": "..." 118 | } 119 | } 120 | ``` 121 | 122 | Our command cannot be handled because a command handler is missing. In Event Engine 123 | commands can be routed directly to `Aggregates`. 124 | In **part II** you'll learn more about pure aggregates. 125 | 126 | {.alert .alert-success} 127 | Sum up: Event Engine Descriptions allow you to easily describe the API of your application using messages. The messages get 128 | a unique name and payload is described with JSON Schema which allow us to add validation rules. The messages and their 129 | schema can be used by Event Engine Cockpit to interact with the backend service. 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /docs/tutorial/part_II.md: -------------------------------------------------------------------------------- 1 | # Part II - The Building Aggregate 2 | 3 | In Event Engine we can take a short cut and skip command handlers. 4 | This is possible because `Aggregates` in Event Engine are **stateless** and **pure**. This means that 5 | they don't have internal **state** and also **no dependencies**. 6 | 7 | *Simply put: they are just functions* 8 | 9 | Event Engine can take over the boilerplate and we, as developers, can **focus on the business logic**. I'll explain 10 | in greater detail later, but first we want to see a **pure aggregate function** in action. 11 | 12 | {.alert .alert-light} 13 | *Note*: If you've worked with a CQRS framework before, it's maybe confusing 14 | because normally a command is handled by a command handler (comparable to an application service that handles a domain action) 15 | and the command handler would load a business entity or "DDD" aggregate from a repository. We still use the aggregate concept but make 16 | use of a functional programming approach. It keeps the domain model lean and testable and allows some nice 17 | optimizations for RAD infrastructure. 18 | 19 | Let's add the first aggregate called `Building` in a new `Model` folder: 20 | 21 | ```php 22 | registerEvent( 82 | self::BUILDING_ADDED, 83 | JsonSchema::object( 84 | [ 85 | 'buildingId' => JsonSchema::uuid(), 86 | 'name' => JsonSchema::string(['minLength' => 2]) 87 | ] 88 | ) 89 | ); 90 | } 91 | } 92 | 93 | ``` 94 | It looks similar to the `AddBuilding` command but uses a past tense name. That is a very important difference. 95 | Commands **tell** the application what it should do and events **represent facts** that have happened. 96 | 97 | ## Yielding Events 98 | 99 | Aggregate methods can yield `null`, one domain event or multiple domain events depending on the result of the executed business logic. 100 | If an aggregate method yields `null` it indicates that no important fact happened and no event needs to be recorded. 101 | In many cases an aggregate method will yield one event which is the fact caused by the corresponding command. 102 | But there is no one-to-one connection between commands and events. In some cases more than one event is needed to communicate 103 | important facts or an error event is yielded instead of the expected event (we'll see that later). 104 | 105 | For the first use case we simply yield a `BuildingAdded` domain event when `Building::add()` is called with a `AddBuilding` 106 | command. 107 | 108 | ```php 109 | payload()]; 123 | } 124 | } 125 | 126 | ``` 127 | 128 | {.alert .alert-info} 129 | The special array syntax for yielding events is a short cut used by Event Engine. It creates the event based on given 130 | event name and payload and stores it in the event stream. 131 | 132 | ## Aggregate Description 133 | 134 | If we switch back to Cockpit and send the `AddBuilding` command again, Event Engine still 135 | complains about a missing command handler. We need to tell Event Engine about our new aggregate and that it is 136 | responsible for handling `AddBuilding` commands. We can do this in another Event Engine Description in `src/Domain/Api/Aggregate`. 137 | 138 | ```php 139 | process(Command::ADD_BUILDING) 159 | ->withNew(self::BUILDING) 160 | ->identifiedBy('buildingId') 161 | ->handle([Building::class, 'add']) 162 | ->recordThat(Event::BUILDING_ADDED); 163 | } 164 | } 165 | 166 | ``` 167 | The connection between command and aggregate is described in a very verbose and readable way. Our IDE can suggest the 168 | describing methods of Event Engine's fluent interface and it is easy to remember each step. 169 | 170 | - `process` tells Event Engine that the following description is for the given command name. 171 | - `withNew/withExisting` tells Event Engine which aggregate handles the command and if the aggregate exists already or a new one should be created. 172 | - `identifiedBy` tells Event Engine which message payload property should be used to identify the responsible aggregate. Every command sent to the aggregate and 173 | every event yielded by the aggregate should contain this property 174 | - `handle` takes a callable argument which is the aggregate method responsible for handling the command defined in `process`. We use the callable array syntax of PHP 175 | which can be analyzed by modern IDEs like PHPStorm for auto completion and refactorings. 176 | - `recordThat` tells Event Engine which event is yielded by the aggregate's command handling method. 177 | 178 | If we try again to send `AddBuilding` (or reload the schema in Cockpit) we get a new error: 179 | 180 | ```json 181 | { 182 | "exception": { 183 | "message": "No apply function specified for event: BuildingAdded", 184 | "details": "..." 185 | } 186 | } 187 | ``` 188 | {.alert .alert-success} 189 | Command handling works now but an apply function is missing. In part III of the tutorial you'll learn how to add such a function and why it is needed. 190 | 191 | 192 | -------------------------------------------------------------------------------- /docs/tutorial/part_III.md: -------------------------------------------------------------------------------- 1 | # Part III - Aggregate State 2 | 3 | In part II we took a closer look at pure aggregate functions (implemented as static class methods in PHP because of missing function autoloading capabilities). 4 | Pure functions don't have side effects and are stateless. This makes them easy to test and understand. 5 | But an aggregate without state? How can an aggregate protect invariants (its main purpose) without state? 6 | 7 | The aggregate needs a way "to look back". It needs to know what happened in the past 8 | according to its own lifecycle. Without its current state and without information about past changes the aggregate could 9 | only execute business logic and enforce business rules based on the given information of the current command passed to a handling function. 10 | In most cases this is not enough. 11 | 12 | The functional programming solution to that problem is to pass the current state (which is computed from past events) 13 | to each command handling function (except the one handling the first command). This means that aggregate **behaviour** (command handling functions) 14 | and aggregate **state** (a data structure of a certain type) are two different things and separated from each other. 15 | How this is implemented in Event Engine is shown in this part of the tutorial. 16 | 17 | ## Applying Domain Events 18 | 19 | Aggregate state is computed by iterating over all recorded domain events of the aggregate history starting with the oldest event. 20 | Event Engine does not provide a generic way to compute current state, instead the aggregate should have an apply function 21 | for each recorded event. Those apply functions are often prefixed with *when* followed by the event name. 22 | 23 | Let's add such a function for our `BuildingAdded` domain event. 24 | 25 | ```php 26 | payload()]; 40 | } 41 | 42 | public static function whenBuildingAdded(Message $buildingAdded): Building\State 43 | { 44 | //@TODO: Return new state for the aggregate 45 | } 46 | } 47 | ``` 48 | `BuildingAdded` communicates that a new lifecycle of a building was started (new building was added to our system), so the 49 | `Building::whenBuildingAdded()` function has to return a new state object and does not receive a current state object 50 | as an argument (next when* function will receive one!). 51 | 52 | But what does the `State` object look like? Well, you can use whatever you want. Event Engine does not care about a particular 53 | implementation (see docs for details). However, Event Engine ships with a default implementation of an `ImmutableRecord`. 54 | We use that implementation in the tutorial, but it is your choice if you want to use it, too. 55 | 56 | Create a `State` class in `src/Domain/Model/Building` (new directory): 57 | 58 | ```php 59 | buildingId; 90 | } 91 | 92 | /** 93 | * @return string 94 | */ 95 | public function name(): string 96 | { 97 | return $this->name; 98 | } 99 | } 100 | 101 | ``` 102 | {.alert .alert-light} 103 | *Note: You can use PHPStorm to generate the Getter-Methods. You only have to write the private properties and add the doc blocks with @var type hints. 104 | Then use PHPStorm's ability to add the Getter-Methods (ALT+EINF). By default PHPStorm sets a `get*` prefix for each method. However, immutable records don't 105 | have setter methods and don't work with the `get*` prefix. Just change the template in your PHPStorm config: Settings -> Editor -> File and Code Templates -> PHP Getter Method to:* 106 | 107 | ``` 108 | /** 109 | * @return ${TYPE_HINT} 110 | */ 111 | public ${STATIC} function ${FIELD_NAME}()#if(${RETURN_TYPE}): ${RETURN_TYPE}#else#end 112 | { 113 | #if (${STATIC} == "static") 114 | return self::$${FIELD_NAME}; 115 | #else 116 | return $this->${FIELD_NAME}; 117 | #end 118 | } 119 | ``` 120 | Now we can return a new `Building\State` from `Building::whenBuildingAdded()`. 121 | 122 | ```php 123 | payload()]; 137 | } 138 | 139 | public static function whenBuildingAdded(Message $buildingAdded): Building\State 140 | { 141 | return Building\State::fromArray($buildingAdded->payload()); 142 | } 143 | } 144 | 145 | ``` 146 | 147 | Finally, we have to tell Event Engine about that apply function to complete the `AddBuilding` use case description. 148 | In `src/Domain/Api/Aggregate`: 149 | 150 | ```php 151 | process(Command::ADD_BUILDING) 172 | ->withNew(self::BUILDING) 173 | ->identifiedBy('buildingId') 174 | ->handle([Building::class, 'add']) 175 | ->recordThat(Event::BUILDING_ADDED) 176 | //Map recorded event to apply function 177 | ->apply([Building::class, 'whenBuildingAdded']); 178 | } 179 | } 180 | 181 | ``` 182 | We're done with the write model for the first use case. If you send the `AddBuilding` command again using Cockpit: 183 | 184 | ```json 185 | { 186 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 187 | "name": "Acme Headquarters" 188 | } 189 | ``` 190 | 191 | ... you should receive a new error. 192 | 193 | ```json 194 | { 195 | "exception": { 196 | "message": "SQLSTATE[42P01]: Undefined table: 7 ERROR: relation \"building_projection_0_1_0\" does not exist ...", 197 | "details": "..." 198 | } 199 | } 200 | ``` 201 | 202 | {.alert .alert-light} 203 | Sorry for that many errors. But learning by mistake is the best way to learn! 204 | 205 | ## MultiModelStore 206 | 207 | The skeleton comes preconfigured with a so called `MultiModelStore`. Such a store is capable of storing events and state of an aggregate in one transaction. 208 | 209 | {.alert .alert-info} 210 | Using a **MultiModelStore** reduces the problem of [eventual consistency](https://en.wikipedia.org/wiki/Eventual_consistency){: class="alert-link"}, which many 211 | see as a main drawback of Event Sourcing. This hybrid approach has its own downsides, discussed in more details in the docs (@TODO: link docs). 212 | However, in Event Engine you can switch between "**state only**", "**events and state**" and "**events only**" mode on a per aggregate basis. 213 | That's really powerful. You can choose the right storage strategy for each scenario and continuously fine tune the system. 214 | 215 | The error is caused by a missing state table for our `Building` aggregate. At the beginning of the tutorial we've only set up the **write model event stream**. 216 | By default all recorded events of all aggregates are stored in that stream table. A similar table for aggregate state does not exist. We have to create one for each aggregate. 217 | 218 | ### Buildings Collection 219 | 220 | Add a new php file called `create_collections.php` next to the `create_event_stream.php` file in the `scripts` folder: 221 | 222 | ```php 223 | get(EventEngine\DocumentStore\DocumentStore::class); 232 | 233 | if(!$documentStore->hasCollection('buildings')) { 234 | echo "Creating collection buildings.\n"; 235 | $documentStore->addCollection('buildings'); 236 | } 237 | 238 | echo "done.\n"; 239 | 240 | ``` 241 | 242 | Run the script with: 243 | 244 | ```bash 245 | docker-compose run php php scripts/create_collections.php 246 | ``` 247 | 248 | If you look at the Postgres database, you'll see a new `buildings` table. 249 | 250 | {.alert .alert-light} 251 | Ok, what did we do? 252 | 253 | The `MultiModelStore` is composed of an `EventStore` and a `DocumentStore`. 254 | Both use the same underlying database which is Postgres in our case and they share the same `\PDO` connection. 255 | 256 | `src/Persistence/PersistenceServices.php` contains the actual set up logic: 257 | 258 | ```php 259 | public function multiModelStore(): MultiModelStore 260 | { 261 | return $this->makeSingleton(MultiModelStore::class, function () { 262 | return new ComposedMultiModelStore( 263 | $this->transactionalConnection(), 264 | $this->eventEngineEventStore(), 265 | $this->documentStore() 266 | ); 267 | }); 268 | } 269 | ``` 270 | 271 | This allows the `MultiModelStore` to control the transaction for both. The `DocumentStore` interface is inspired by MongoDB's API. 272 | Postgres can be used as a document store due to **JSON** support. You don't need to worry about the low level JSON API but can instead use 273 | the high level abstraction provided by Event Engine. 274 | 275 | ### Aggregate Storage Settings 276 | 277 | We've just created a new table in Postgres called `buildings` using the high level `DocumentStore` abstraction provided by Event Engine, 278 | but the error complains about a missing table called `building_projection_0_1_0`. 279 | Event Engine applies a default naming strategy for aggregate state collections (in case of Postgres collection equals table) if not specified otherwise. 280 | 281 | We can tell Event Engine to use the `buildings` collection instead by adding a hint to the aggregate description in `src/Domain/Api/Aggregate.php`: 282 | 283 | ```php 284 | process(Command::ADD_BUILDING) 304 | ->withNew(self::BUILDING) 305 | ->identifiedBy('buildingId') 306 | ->handle([Building::class, 'add']) 307 | ->recordThat(Event::BUILDING_ADDED) 308 | ->apply([Building::class, 'whenBuildingAdded']) 309 | ->storeStateIn('buildings'); //Use buildings collection for aggregate state 310 | } 311 | } 312 | 313 | ``` 314 | 315 | Send the command again: 316 | 317 | ```json 318 | { 319 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 320 | "name": "Acme Headquarters" 321 | } 322 | ``` 323 | 324 | This time the command goes through. If everything is fine the message box returns a `202 command accepted` response. 325 | 326 | {.alert .alert-success} 327 | Event Engine emphasizes a CQRS and Event Sourcing architecture. For commands this means that no data is returned. 328 | The write model has received and processed the command **AddBuilding** successfully but we don't know what the new 329 | application state looks like. We will use a query, which is the third message type, to get this data. 330 | Head over to tutorial part IV to learn more about queries and application state management using the **MultiModelStore**. 331 | 332 | -------------------------------------------------------------------------------- /docs/tutorial/part_V.md: -------------------------------------------------------------------------------- 1 | # Part V - DRY 2 | 3 | You may have noticed that we use the static classes in `src/Domain/Api` as a central place to define constants. 4 | At least we did that for messages (Command, Event, Query) and aggregate names. We did not touch `src/Domain/Api/Payload` and 5 | `src/Domain/Api/Schema` yet. 6 | 7 | The idea behind those two classes is to group some common constants and static methods so that we don't have to repeat them 8 | over and over again. This makes it much easier to refactor the codebase later. 9 | 10 | ## Payload 11 | 12 | In `src/Domain/Api/Payload` we simply define a constant for each possible message payload key. We've used two keys so far: 13 | `buildingId` and `name` so we should add them ... 14 | 15 | ```php 16 | process(Command::ADD_BUILDING) 57 | ->withNew(self::BUILDING) 58 | ->identifiedBy(Payload::BUILDING_ID) //<-- AggregateId payload property 59 | ->handle([Building::class, 'add']) 60 | ->recordThat(Event::BUILDING_ADDED) 61 | ->apply([Building::class, 'whenBuildingAdded']); 62 | 63 | /* ... */ 64 | } 65 | } 66 | 67 | ``` 68 | 69 | 70 | `src/Domain/Api/Command` 71 | 72 | ```php 73 | registerCommand( 93 | Command::ADD_BUILDING, 94 | JsonSchema::object( 95 | [ 96 | Payload::BUILDING_ID => JsonSchema::uuid(), 97 | Payload::NAME => JsonSchema::string(['minLength' => 2]) 98 | ] 99 | ) 100 | ); 101 | 102 | 103 | } 104 | } 105 | 106 | ``` 107 | `src/Domain/Api/Event` 108 | 109 | ```php 110 | registerEvent( 130 | self::BUILDING_ADDED, 131 | JsonSchema::object( 132 | [ 133 | Payload::BUILDING_ID => JsonSchema::uuid(), 134 | Payload::NAME => JsonSchema::string(['minLength' => 2]) 135 | ] 136 | ) 137 | ); 138 | } 139 | } 140 | 141 | ``` 142 | `src/Domain/Api/Query` 143 | 144 | ```php 145 | registerQuery(self::BUILDING, JsonSchema::object([ 164 | Payload::BUILDING_ID => JsonSchema::uuid(), 165 | ])) 166 | ->resolveWith(BuildingResolver::class) 167 | ->setReturnType(JsonSchema::typeRef(Type::BUILDING)); 168 | 169 | //New query 170 | $eventEngine->registerQuery( 171 | self::BUILDINGS, 172 | JsonSchema::object( 173 | [], //No required arguments for this query 174 | //Optional argument name, is a nullable string 175 | [Payload::NAME => JsonSchema::nullOr(JsonSchema::string()->withMinLength(1))] 176 | ) 177 | ) 178 | //Resolve query with same finder ... 179 | ->resolveWith(BuildingResolver::class) 180 | //... but return an array of Building type 181 | ->setReturnType(JsonSchema::array( 182 | JsonSchema::typeRef(Aggregate::BUILDING) 183 | )); 184 | } 185 | } 186 | 187 | ``` 188 | `src/Domain/Resolver/BuildingResolver` 189 | 190 | ```php 191 | documentStore = $documentStore; 218 | } 219 | 220 | /** 221 | * @param Message $query 222 | * @return array 223 | */ 224 | public function resolve(Message $query): array 225 | { 226 | switch ($query->messageName()) { 227 | case Query::BUILDING: 228 | return $this->resolveBuilding($query->get(Payload::BUILDING_ID)); 229 | case Query::BUILDINGS: 230 | return $this->resolveBuildings($query->getOrDefault(Payload::NAME, null)); 231 | } 232 | } 233 | 234 | private function resolveBuilding(string $buildingId): array 235 | { 236 | $buildingDoc = $this->documentStore->getDoc(self::COLLECTION, $buildingId); 237 | 238 | if(!$buildingDoc) { 239 | throw new \RuntimeException("Building not found", 404); 240 | } 241 | 242 | return $buildingDoc[self::STATE]; 243 | } 244 | 245 | private function resolveBuildings(string $nameFilter = null): array 246 | { 247 | $filter = $nameFilter? 248 | new LikeFilter(self::STATE_DOT . Payload::NAME, "%$nameFilter%") 249 | : new AnyFilter(); 250 | 251 | $cursor = $this->documentStore->findDocs(self::COLLECTION, $filter); 252 | 253 | $buildings = []; 254 | 255 | foreach ($cursor as $doc) { 256 | $buildings[] = $doc[self::STATE]; 257 | } 258 | 259 | return $buildings; 260 | } 261 | } 262 | 263 | ``` 264 | 265 | `scripts/create_collections.php` 266 | 267 | ```php 268 | get(EventEngine\DocumentStore\DocumentStore::class); 277 | 278 | if(!$documentStore->hasCollection(\MyService\Domain\Resolver\BuildingResolver::COLLECTION)) { 279 | echo "Creating collection buildings.\n"; 280 | $documentStore->addCollection( 281 | \MyService\Domain\Resolver\BuildingResolver::COLLECTION 282 | ); 283 | } 284 | 285 | echo "done.\n"; 286 | 287 | ``` 288 | 289 | The `buildings` collection name is now also defined as a constant. Because `BuildingResolver` is responsible for building 290 | related queries, it owns the collection constant. That's not a hard rule but in our case it's a good documentation, 291 | especially in the aggregate description. All information about buildings is in one place now: 292 | 293 | ```php 294 | process(Command::ADD_BUILDING) 315 | ->withNew(self::BUILDING) 316 | ->identifiedBy(Payload::BUILDING_ID) 317 | ->handle([Building::class, 'add']) 318 | ->recordThat(Event::BUILDING_ADDED) 319 | ->apply([Building::class, 'whenBuildingAdded']) 320 | ->storeStateIn(BuildingResolver::COLLECTION); //Use buildings collection for aggregate state 321 | } 322 | } 323 | 324 | ``` 325 | 326 | 327 | ## Schema 328 | 329 | Schema definitions are another area where DRY (Don't Repeat Yourself) makes a lot of sense. A good practice is to define 330 | a schema for each payload key and reuse it when registering messages. Type references (JsonSchema::typeRef) should also be wrapped 331 | by a schema method. Open `src/Domain/Api/Schema` and add the static methods: 332 | 333 | ```php 334 | withMinLength(1); 355 | } 356 | 357 | public static function buildingNameFilter(): StringType 358 | { 359 | return JsonSchema::string()->withMinLength(1); 360 | } 361 | 362 | public static function building(): TypeRef 363 | { 364 | return JsonSchema::typeRef(Type::BUILDING); 365 | } 366 | 367 | public static function buildingList(): ArrayType 368 | { 369 | return JsonSchema::array(self::building()); 370 | } 371 | /* ... */ 372 | } 373 | 374 | ``` 375 | Doing this creates one place that gives us an overview of all domain specific schema definitions and we can simply 376 | change them if requirements change. 377 | 378 | {.alert .alert-light} 379 | *Note: Even if we only use "name" in message payload for building names we use a more precise method name in Schema. 380 | A message defines the context so we can use a shorter payload key but the schema should be explicit.* 381 | 382 | You can now replace all schema definitions. 383 | 384 | `src/Domain/Api/Command` 385 | 386 | ```php 387 | registerCommand( 407 | Command::ADD_BUILDING, 408 | JsonSchema::object( 409 | [ 410 | Payload::BUILDING_ID => Schema::buildingId(), 411 | Payload::NAME => Schema::buildingName(), 412 | ] 413 | ) 414 | ); 415 | 416 | 417 | } 418 | } 419 | 420 | ``` 421 | 422 | `src/Domain/Api/Event` 423 | 424 | ```php 425 | registerEvent( 445 | self::BUILDING_ADDED, 446 | JsonSchema::object( 447 | [ 448 | Payload::BUILDING_ID => Schema::buildingId(), 449 | Payload::NAME => Schema::buildingName(), 450 | ] 451 | ) 452 | ); 453 | } 454 | } 455 | 456 | ``` 457 | `src/Domain/Api/Query` 458 | 459 | ```php 460 | registerQuery(self::BUILDING, JsonSchema::object([ 479 | Payload::BUILDING_ID => Schema::buildingId(), 480 | ])) 481 | ->resolveWith(BuildingResolver::class) 482 | ->setReturnType(Schema::building()); 483 | 484 | $eventEngine->registerQuery( 485 | self::BUILDINGS, 486 | JsonSchema::object( 487 | [], 488 | [Payload::NAME => JsonSchema::nullOr(Schema::buildingNameFilter())] 489 | ) 490 | ) 491 | ->resolveWith(BuildingResolver::class) 492 | ->setReturnType(Schema::buildingList()); 493 | } 494 | } 495 | 496 | ``` 497 | {.alert .alert-success} 498 | We're done with the refactoring and ready to add the next use case. Head over to part VI. 499 | -------------------------------------------------------------------------------- /docs/tutorial/part_VI.md: -------------------------------------------------------------------------------- 1 | # Part VI - Check in User 2 | 3 | The second use case of our Building Management system checks users into buildings. Users are identified by their name. 4 | 5 | ## Command 6 | 7 | Let's add a new command for the use case in `src/Domain/Api/Command`: 8 | 9 | ```php 10 | registerCommand( 33 | Command::CHECK_IN_USER, 34 | JsonSchema::object([ 35 | Payload::BUILDING_ID => Schema::buildingId(), 36 | Payload::NAME => Schema::username(), 37 | ]) 38 | ); 39 | } 40 | } 41 | 42 | ``` 43 | We can reuse `Payload::NAME` but assign a different schema so that we can change schema for a `building name` without 44 | influencing the schema of `user name`: 45 | 46 | ```php 47 | withMinLength(1); 66 | } 67 | } 68 | 69 | ``` 70 | ## Event 71 | 72 | ```php 73 | registerEvent( 94 | self::BUILDING_ADDED, 95 | JsonSchema::object( 96 | [ 97 | Payload::BUILDING_ID => Schema::buildingId(), 98 | Payload::NAME => Schema::buildingName(), 99 | ] 100 | ) 101 | ); 102 | 103 | $eventEngine->registerEvent( 104 | self::USER_CHECKED_IN, 105 | JsonSchema::object([ 106 | Payload::BUILDING_ID => Schema::buildingId(), 107 | Payload::NAME => Schema::username(), 108 | ]) 109 | ); 110 | } 111 | } 112 | 113 | ``` 114 | 115 | ## Aggregate 116 | 117 | Did you notice that we are getting faster? Once, you're used to Event Engine's API you can develop at the 118 | speed of light ;). 119 | 120 | A user can only check into an existing building. `builidngId` is part of the command payload and should reference a 121 | building in our system. For the command handling aggregate function this means that we also have state of the aggregate 122 | and Event Engine will pass that state as the first argument to the command handling function as well as to the 123 | event apply function: 124 | 125 | ```php 126 | payload()]; 141 | } 142 | 143 | public static function whenBuildingAdded(Message $buildingAdded): Building\State 144 | { 145 | return Building\State::fromArray($buildingAdded->payload()); 146 | } 147 | 148 | public static function checkInUser(Building\State $state, Message $checkInUser): \Generator 149 | { 150 | yield [Event::USER_CHECKED_IN, $checkInUser->payload()]; 151 | } 152 | 153 | public static function whenUserCheckedIn(Building\State $state, Message $userCheckedIn): Building\State 154 | { 155 | return $state->withCheckedInUser($userCheckedIn->get(Payload::NAME)); 156 | } 157 | } 158 | 159 | ``` 160 | 161 | `Building::checkInUser()` is still a dumb function (we will change that in a minute) but `Building::whenUserCheckedIn()` 162 | contains an interesting detail. `Building\State` is an immutable record. But we can add `with*` methods to it to 163 | modify state. You may know these `with*` methods from the `PSR-7` standard. It is a common practice to prefix 164 | state changing methods of immutable objects with `with`. Those methods should return a new instance with the modified 165 | state rather than changing its own state. Here is the implementation of `Building\State::withCheckedInUser(string $username): Building\State`: 166 | 167 | ```php 168 | JsonSchema::TYPE_STRING]; 204 | } 205 | 206 | /** 207 | * @return string 208 | */ 209 | public function buildingId(): string 210 | { 211 | return $this->buildingId; 212 | } 213 | 214 | /** 215 | * @return string 216 | */ 217 | public function name(): string 218 | { 219 | return $this->name; 220 | } 221 | 222 | /** 223 | * @return array 224 | */ 225 | public function users(): array 226 | { 227 | return array_keys($this->users); 228 | } 229 | 230 | public function withCheckedInUser(string $username): State 231 | { 232 | $copy = clone $this; 233 | $copy->users[$username] = null; 234 | return $copy; 235 | } 236 | 237 | public function isUserCheckedIn(string $username): bool 238 | { 239 | return array_key_exists($username, $this->users); 240 | } 241 | } 242 | 243 | ``` 244 | 245 | We can make a copy of the record and modify that. The original record is not modified, 246 | and we return the copy to satisfy the immutable record contract. 247 | 248 | Besides `withCheckedInUser` we've added a new property, `users`, and a getter for it. We also overrode the private static method `arrayPropItemTypeMap` 249 | of `ImmutableRecordLogic` to define a type hint for the items in the `users` array property. 250 | Unfortunately, we can only type hint for `array` in PHP, and it is not possible to use return type hints like `string[]`. 251 | Hopefully this will change in a future version of PHP, but, for now, we have to live with the workaround and give 252 | `ImmutableRecordLogic` a hint that array items of the `users` property are of type `string`. 253 | 254 | {.alert .alert-light} 255 | *Note: ImmutableRecordLogic derives type information by inspecting return types of getter methods named like their 256 | corresponding private properties.* 257 | 258 | Internally, user names are used as the array index so the same user cannot appear twice in the list. With `Building\State::isUserCheckedIn(string $username): bool` 259 | we can look up if the given user is currently in the building. `Building\State::users()` on the other hand returns a list 260 | of user names. Internal state is used for fast look ups and external schema is used for the 261 | read model. More on that in a minute. 262 | 263 | ## Command Processing 264 | 265 | ```php 266 | process(Command::CHECK_IN_USER) 289 | ->withExisting(self::BUILDING) 290 | ->handle([Building::class, 'checkInUser']) 291 | ->recordThat(Event::USER_CHECKED_IN) 292 | ->apply([Building::class, 'whenUserCheckedIn']); 293 | } 294 | } 295 | 296 | ``` 297 | 298 | Pretty much the same command processing description but with command, event and function names based on 299 | the new use case. An important difference is that we use `->withExisting` instead of `->withNew`. 300 | As already stated this tells Event Engine to look up an existing Building using the `buildingId` from the `CheckInUser` command. 301 | 302 | The following command should check in *John* into the *Acme Headquarters*. 303 | 304 | ```json 305 | { 306 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 307 | "name": "John" 308 | } 309 | ``` 310 | 311 | Looks good! And what does the response of the `Buildings` query look now? 312 | 313 | ```json 314 | { 315 | "name": "Acme" 316 | } 317 | ``` 318 | Response 319 | 320 | ```json 321 | [ 322 | { 323 | "name": "Acme Headquarters", 324 | "users": [ 325 | "John" 326 | ], 327 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb" 328 | } 329 | ] 330 | ``` 331 | Great! We get back the list of users checked into the building. 332 | 333 | ## Protect Invariants 334 | 335 | One of the main tasks of an aggregate is to protect invariants. A user cannot check in twice. The `Building` aggregate 336 | should enforce the business rule: 337 | 338 | ```php 339 | payload()]; 354 | } 355 | 356 | public static function whenBuildingAdded(Message $buildingAdded): Building\State 357 | { 358 | return Building\State::fromArray($buildingAdded->payload()); 359 | } 360 | 361 | public static function checkInUser(Building\State $state, Message $checkInUser): \Generator 362 | { 363 | if($state->isUserCheckedIn($checkInUser->get(Payload::NAME))) { 364 | throw new \DomainException(sprintf( 365 | "User %s is already in the building", 366 | $checkInUser->get(Payload::NAME) 367 | )); 368 | } 369 | 370 | yield [Event::USER_CHECKED_IN, $checkInUser->payload()]; 371 | } 372 | 373 | public static function whenUserCheckedIn(Building\State $state, Message $userCheckedIn): Building\State 374 | { 375 | return $state->withCheckedInUser($userCheckedIn->get(Payload::NAME)); 376 | } 377 | } 378 | 379 | ``` 380 | 381 | The command handling function can make use of `$state` passed to it as this will always be the current state of the aggregate. 382 | If the given user is already checked in we throw an exception to stop command processing. 383 | 384 | Let's try it: 385 | 386 | ```json 387 | { 388 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 389 | "name": "John" 390 | } 391 | ``` 392 | 393 | Response: 394 | 395 | ```json 396 | { 397 | "exception": { 398 | "message": "User John is already in the building", 399 | "details": "..." 400 | } 401 | } 402 | ``` 403 | 404 | {.alert .alert-success} 405 | Throwing an exception is the simplest way to protect invariants. However, with event sourcing we have a different 406 | (and in most cases) better option. This will be covered in the next part. 407 | -------------------------------------------------------------------------------- /docs/tutorial/part_VII.md: -------------------------------------------------------------------------------- 1 | # Part VII - The Unhappy Path 2 | 3 | Developers tend to work out the happy path of a feature only and throw exceptions in every unknown situation. 4 | This behaviour is often caused by bad project management. Developers get domain knowledge from Jira tickets written by a product owner 5 | (Jira is used here as a synonym for any ticket system) 6 | instead of talking to domain experts face-to-face. Most tickets don't include unhappy paths until they happen and find 7 | their way back to the developer as a bug ticket. 8 | 9 | Is this really the best way to deal with unexpected scenarios? Wouldn't it be better to prepare for 10 | the unhappy paths as well? Sure, it takes more time upfront but saves a lot of time later when the application runs 11 | in production and can deal with failure scenarios in a sane way. 12 | 13 | Our `Building` aggregate does a bad job with regards to failure handling. Imagine a user is already in a building and tries 14 | to check in again. What does that mean in the real world? First of all it is not possible to be in and out of a building 15 | at the same time. So either a hacker has stolen the identity or system state is broken for whatever reason. 16 | Deciding if entrance to the building is blocked or not should be made by the business. And regardless of 17 | the decision it is always interesting to have an event in the event stream about the double check in. This makes monitoring 18 | much simpler than scanning error logs. 19 | 20 | We've talked to the domain experts and they want us to notify security in case of a `DoubleCheckIn`. With Event Engine 21 | this is as simple as throwing an exception ;) 22 | 23 | ![InspectIO Notify Security](img/notify_security.png) 24 | 25 | {.alert .alert-info} 26 | The screenshot is taken from [InspectIO](https://github.com/event-engine/inspectio){: class="alert-link"} - a domain modelling tool for (remote) teams that supports living documentation. 27 | Event Engine users can request free access in the chat. 28 | 29 | We need an event to record a `DoubleCheckIn`: 30 | 31 | ```php 32 | registerEvent( 56 | self::DOUBLE_CHECK_IN_DETECTED, 57 | JsonSchema::object([ 58 | Payload::BUILDING_ID => Schema::buildingId(), 59 | Payload::NAME => Schema::username(), 60 | ]) 61 | ); 62 | } 63 | } 64 | 65 | ``` 66 | 67 | Now that we have the event we can replace the exception and yield a `DoubleCheckInDetected` event: 68 | 69 | ```php 70 | isUserCheckedIn($checkInUser->get(Payload::NAME))) { 87 | yield [Event::DOUBLE_CHECK_IN_DETECTED, $checkInUser->payload()]; 88 | return; //<-- Note: we need to return, otherwise UserCheckedIn would be yielded, too 89 | } 90 | 91 | yield [Event::USER_CHECKED_IN, $checkInUser->payload()]; 92 | } 93 | 94 | /* ... */ 95 | 96 | public static function whenDoubleCheckInDetected(Building\State $state, Message $event): Building\State 97 | { 98 | //No state change required, simply return current state 99 | return $state; 100 | } 101 | } 102 | 103 | ``` 104 | Event Engine requires an event apply function for each event type, even if state does not change and it needs to know 105 | that `Building::checkInUser()` yields `DoubleCheckInDetected` in some situations: 106 | 107 | ```php 108 | process(Command::CHECK_IN_USER) 131 | ->withExisting(self::BUILDING) 132 | ->handle([Building::class, 'checkInUser']) 133 | ->recordThat(Event::USER_CHECKED_IN) 134 | ->apply([Building::class, 'whenUserCheckedIn']) 135 | ->orRecordThat(Event::DOUBLE_CHECK_IN_DETECTED) 136 | ->apply([Building::class, 'whenDoubleCheckInDetected']); 137 | } 138 | } 139 | 140 | ``` 141 | 142 | Try to check *John* in again: 143 | 144 | ```json 145 | { 146 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 147 | "name": "John" 148 | } 149 | ``` 150 | 151 | Instead of an error we get a 202 command accepted response. 152 | 153 | But when we look at the [aggregate details page](https://localhost:4444/#/aggregates/building/9ee8d8a8-3bd3-4425-acee-f6f08b8633bb) in Cockpit, we see a `DoubleCheckInDetected` event. 154 | 155 | ![Cockpit double check in detected](img/double_check_in_detected.png) 156 | 157 | ## Process Manager 158 | 159 | To complete the user story we have to notify security. The security team uses a dedicated monitoring application that 160 | can receive arbitrary notification messages. To communicate with that external system we can use a so-called **process manager** or 161 | **policy**. Maybe you're more familiar with the term event listener but be careful to not mix it with event listeners known 162 | from web frameworks like Symfony or Laravel. Listeners in Event Engine **react** to domain events and trigger follow up 163 | commands for actions, like sending emails or interacting with external systems. 164 | 165 | We can simulate the security monitoring system with a small JS app shipped with the php-engine-skeleton. 166 | Open [http://localhost:8080/ws.html](http://localhost:8080/ws.html) in your browser. 167 | 168 | *Note: If the app shows a connection error then try to log into the rabbit mgmt console first: `https://localhost:8081`. Accept the self-signed certificate 169 | and login with usr: `prooph` pwd: `prooph`. If you're logged in switch back to `http://localhost:8080/ws.html` and reload the page.* 170 | 171 | If the app says `Status: Connected to websocket: ui-queue` it is ready to receive messages from Event Engine. 172 | 173 | In `src/System/SystemServices` you can find a factory method for `MyService\System\UiExchange`. 174 | It's a default domain event listener shipped with the skeleton that can be used to push events on a *RabbitMQ ui-exchange*. 175 | The exchange is preconfigured (you can see that in the rabbit mgmt UI) and the JS app connects to a corresponding *ui-queue*. 176 | 177 | In `src/Domain/Api/Listener` we can put the pieces together: 178 | 179 | ```php 180 | on(Event::DOUBLE_CHECK_IN_DETECTED, UiExchange::class); 196 | } 197 | } 198 | 199 | ``` 200 | 201 | Whenever a `DoubleCheckInDetected` event is recorded and written to the stream Event Engine invokes the `UiExchange` 202 | listener that takes the event and pushes it to *rabbit*. 203 | 204 | Try to check *John* in again, while keeping an eye on the monitoring app `http://localhost:8080/ws.html`. 205 | 206 | ```json 207 | { 208 | "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb", 209 | "name": "John" 210 | } 211 | ``` 212 | ![Monitoring UI](img/monitoring.png)] 213 | 214 | ## The End 215 | 216 | Congratulations! You've mastered the Event Engine tutorial. There are two bonus parts available to learn more 217 | about **custom projections** and **testing with Event Engine**. 218 | And another two bonus parts introduce **Event Engine Flavours**. Choose your own Flavour and maximize personal 219 | developer experience with Event Engine. 220 | 221 | The Event Engine API docs contain a lot more details. 222 | 223 | {.alert .alert-success} 224 | The prooph software team offers commercial project support and workshops for Event Engine and the prooph components. 225 | Our workshops include Event Storming sessions and guidance on how to turn the results into working prototypes using Event Engine. 226 | We can also show and discuss framework integrations. Event Engine can easily be integrated with *Symfony*, *Laravel* and 227 | other PHP web frameworks. The skeleton is based on *Laminas Strategility* so you can handle http related tasks, like authentication, 228 | using *PSR-15* middleware. But again, other web frameworks play nicely with Event Engine! 229 | 230 | [![prooph software](https://github.com/codeliner/php-ddd-cargo-sample/raw/master/docs/assets/prooph-software-logo.png)](http://prooph.de) 231 | 232 | If you are interested please [get in touch](http://prooph.de)! 233 | -------------------------------------------------------------------------------- /examples/ContextProvider/AddItemContextProvider.php: -------------------------------------------------------------------------------- 1 | get(Payload::ITEM_ID)); 27 | $itemPrice = $this->priceFinder->findItemPrice($itemId); 28 | 29 | $item = Item::withIdAndPrice($itemId, $itemPrice); 30 | $freeShipping = FreeShipping::fromInt(4000); 31 | 32 | return AddItemContext::fromRecordData(['item' => $item, 'freeShipping' => $freeShipping]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/ContextProvider/Api/Aggregate.php: -------------------------------------------------------------------------------- 1 | process(Command::START_SHOPPING_SESSION) 19 | ->withNew(self::SHOPPING_CART) 20 | ->identifiedBy(Payload::SHOPPING_CART_ID) 21 | ->handle([ShoppingCart::class, 'startShoppingSession']) 22 | ->recordThat(Event::SHOPPING_SESSION_STARTED) 23 | ->apply([ShoppingCart::class, 'whenShoppingSessionStarted']); 24 | 25 | 26 | $eventMachine->process(Command::ADD_ITEM) 27 | ->withExisting(self::SHOPPING_CART) 28 | ->provideContext(AddItemContextProvider::class) 29 | ->handle([ShoppingCart::class, 'addItem']) 30 | ->recordThat(Event::ITEM_ADDED) 31 | ->apply([ShoppingCart::class, 'whenItemAdded']) 32 | ->andRecordThat(Event::FREE_SHIPPING_ENABLED) 33 | ->apply([ShoppingCart::class, 'whenFreeShippingEnabled']); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/ContextProvider/Api/Command.php: -------------------------------------------------------------------------------- 1 | id; 23 | } 24 | 25 | /** 26 | * @return ItemPrice 27 | */ 28 | public function price(): ItemPrice 29 | { 30 | return $this->price; 31 | } 32 | 33 | public static function fromArray(array $data): self 34 | { 35 | return new self( 36 | ItemId::fromString($data['itemId'] ?? ''), 37 | ItemPrice::fromArray($data['price'] ?? []) 38 | ); 39 | } 40 | 41 | private function __construct(ItemId $itemId, ItemPrice $itemPrice) 42 | { 43 | $this->id = $itemId; 44 | $this->price = $itemPrice; 45 | 46 | } 47 | 48 | public function toArray(): array 49 | { 50 | return [ 51 | 'itemId' => $this->id->toString(), 52 | 'price' => $this->price->toArray(), 53 | ]; 54 | } 55 | 56 | public function equals($other): bool 57 | { 58 | if(!$other instanceof self) { 59 | return false; 60 | } 61 | 62 | return $this->toArray() === $other->toArray(); 63 | } 64 | 65 | public function __toString(): string 66 | { 67 | return json_encode($this->toArray()); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/ContextProvider/ItemId.php: -------------------------------------------------------------------------------- 1 | itemId = $itemId; 27 | } 28 | 29 | public function toString(): string 30 | { 31 | return $this->itemId->toString(); 32 | } 33 | 34 | public function equals($other): bool 35 | { 36 | if (!$other instanceof self) { 37 | return false; 38 | } 39 | 40 | return $this->itemId->equals($other->itemId); 41 | } 42 | 43 | public function __toString(): string 44 | { 45 | return $this->itemId->toString(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/ContextProvider/ItemPrice.php: -------------------------------------------------------------------------------- 1 | amount = $amount; 26 | $this->currency = $currency; 27 | } 28 | 29 | /** 30 | * @return Amount 31 | */ 32 | public function amount(): Amount 33 | { 34 | return $this->amount; 35 | } 36 | 37 | /** 38 | * @return Currency 39 | */ 40 | public function currency(): Currency 41 | { 42 | return $this->currency; 43 | } 44 | 45 | public function toArray(): array 46 | { 47 | return [ 48 | 'amount' => $this->amount->toInt(), 49 | 'currency' => $this->currency->toString(), 50 | ]; 51 | } 52 | 53 | public function equals($other): bool 54 | { 55 | if(!$other instanceof self) { 56 | return false; 57 | } 58 | 59 | return $this->toArray() === $other->toArray(); 60 | } 61 | 62 | public function __toString(): string 63 | { 64 | return json_encode($this->toArray()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/ContextProvider/Policy/FreeShipping.php: -------------------------------------------------------------------------------- 1 | minTotal = $minTotal; 19 | } 20 | 21 | public function toInt(): int 22 | { 23 | return $this->minTotal; 24 | } 25 | 26 | public function isFree(int $orderSum): bool 27 | { 28 | return $orderSum >= $this->minTotal; 29 | } 30 | 31 | public function equals($other): bool 32 | { 33 | if(!$other instanceof self) { 34 | return false; 35 | } 36 | 37 | return $this->minTotal === $other->minTotal; 38 | } 39 | 40 | public function __toString(): string 41 | { 42 | return (string)$this->minTotal; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/ContextProvider/Price/Amount.php: -------------------------------------------------------------------------------- 1 | amount = $amount; 19 | } 20 | 21 | public function toInt(): int 22 | { 23 | return $this->amount; 24 | } 25 | 26 | public function equals($other): bool 27 | { 28 | if(!$other instanceof self) { 29 | return false; 30 | } 31 | 32 | return $this->amount === $other->amount; 33 | } 34 | 35 | public function __toString(): string 36 | { 37 | return (string)$this->amount; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/ContextProvider/Price/Currency.php: -------------------------------------------------------------------------------- 1 | currency = $currency; 19 | } 20 | 21 | public function toString(): string 22 | { 23 | return $this->currency; 24 | } 25 | 26 | public function equals($other): bool 27 | { 28 | if(!$other instanceof self) { 29 | return false; 30 | } 31 | 32 | return $this->currency === $other->currency; 33 | } 34 | 35 | public function __toString(): string 36 | { 37 | return $this->currency; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/ContextProvider/PriceFinder.php: -------------------------------------------------------------------------------- 1 | $startShoppingSession->get(Payload::SHOPPING_CART_ID), 20 | ]]; 21 | } 22 | 23 | public static function whenShoppingSessionStarted(Message $shoppingSessionStarted): State 24 | { 25 | return State::newSession(ShoppingCartId::fromString( 26 | $shoppingSessionStarted->get(Payload::SHOPPING_CART_ID) 27 | )); 28 | } 29 | 30 | public static function addItem(State $cart, Message $addItem, AddItemContext $context): \Generator 31 | { 32 | yield [Event::ITEM_ADDED, [ 33 | Payload::SHOPPING_CART_ID => $addItem->get(Payload::SHOPPING_CART_ID), 34 | Payload::ITEM => $context->item()->toArray(), 35 | ]]; 36 | 37 | if(!$cart->freeShipping()) { 38 | //Temporarily add item. We can safely do this, because $cart is immutable 39 | $cart = $cart->withAddedItem($context->item()); 40 | 41 | if($context->freeShipping()->isFree($cart->orderSum())) { 42 | yield [Event::FREE_SHIPPING_ENABLED, [ 43 | Payload::SHOPPING_CART_ID => $addItem->get(Payload::SHOPPING_CART_ID), 44 | ]]; 45 | } 46 | } 47 | } 48 | 49 | public static function whenItemAdded(State $cart, Message $itemAdded): State 50 | { 51 | return $cart->withAddedItem(Item::fromArray($itemAdded->get(Payload::ITEM))); 52 | } 53 | 54 | public static function whenFreeShippingEnabled(State $cart, Message $freeShippingEnabled): State 55 | { 56 | return $cart->withFreeShippingEnabled(); 57 | } 58 | 59 | public static function removeItem(State $cart, Message $removeItem): \Generator 60 | { 61 | $item = Item::fromArray($removeItem->get(Payload::ITEM)); 62 | 63 | if(!$cart->hasItem($item)) { 64 | yield null; 65 | return; 66 | } 67 | 68 | yield [Event::ITEM_REMOVED, $removeItem->payload()]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/ContextProvider/ShoppingCart/AddItemContext.php: -------------------------------------------------------------------------------- 1 | item; 32 | } 33 | 34 | /** 35 | * @return FreeShipping 36 | */ 37 | public function freeShipping(): FreeShipping 38 | { 39 | return $this->freeShipping; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/ContextProvider/ShoppingCart/ShoppingCartId.php: -------------------------------------------------------------------------------- 1 | cartId = $cartId; 27 | } 28 | 29 | public function toString(): string 30 | { 31 | return $this->cartId->toString(); 32 | } 33 | 34 | public function equals($other): bool 35 | { 36 | if (!$other instanceof self) { 37 | return false; 38 | } 39 | 40 | return $this->cartId->equals($other->cartId); 41 | } 42 | 43 | public function __toString(): string 44 | { 45 | return $this->cartId->toString(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/ContextProvider/ShoppingCart/State.php: -------------------------------------------------------------------------------- 1 | Item::class]; 33 | } 34 | 35 | public static function newSession(ShoppingCartId $shoppingCartId): self 36 | { 37 | $self = new self(); 38 | 39 | $self->shoppingCartId = $shoppingCartId; 40 | 41 | return $self; 42 | } 43 | 44 | /** 45 | * @return ShoppingCartId 46 | */ 47 | public function shoppingCartId(): ShoppingCartId 48 | { 49 | return $this->shoppingCartId; 50 | } 51 | 52 | /** 53 | * @return Item[] 54 | */ 55 | public function items(): array 56 | { 57 | return $this->items; 58 | } 59 | 60 | public function hasItem(Item $item): bool 61 | { 62 | foreach ($this->items() as $inCartItem) { 63 | if($inCartItem->equals($item)) { 64 | return true; 65 | } 66 | } 67 | 68 | return false; 69 | } 70 | 71 | /** 72 | * @return bool 73 | */ 74 | public function freeShipping(): bool 75 | { 76 | return $this->freeShipping; 77 | } 78 | 79 | public function orderSum(): int 80 | { 81 | $total = 0; 82 | 83 | foreach ($this->items as $item) { 84 | $total += $item->price()->amount()->toInt(); 85 | } 86 | 87 | return $total; 88 | } 89 | 90 | public function withAddedItem(Item $item): State 91 | { 92 | $copy = clone $this; 93 | $copy->items[] = $item; 94 | return $copy; 95 | } 96 | 97 | public function withFreeShippingEnabled(): State 98 | { 99 | $copy = clone $this; 100 | $copy->freeShipping = true; 101 | return $copy; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/ValueObject/Age.php: -------------------------------------------------------------------------------- 1 | age = $age; 18 | } 19 | 20 | public function toInt(): int 21 | { 22 | return $this->age; 23 | } 24 | 25 | public function equals($other): bool 26 | { 27 | if(!$other instanceof self) { 28 | return false; 29 | } 30 | 31 | return $this->age === $other->age; 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return (string)$this->age; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /examples/ValueObject/FriendsList.php: -------------------------------------------------------------------------------- 1 | items = $items; 33 | } 34 | 35 | public function push(Person $item): self 36 | { 37 | $copy = clone $this; 38 | $copy->items[] = $item; 39 | return $copy; 40 | } 41 | 42 | public function pop(): self 43 | { 44 | $copy = clone $this; 45 | \array_pop($copy->items); 46 | return $copy; 47 | } 48 | 49 | public function first(): ?Person 50 | { 51 | return $this->items[0] ?? null; 52 | } 53 | 54 | public function last(): ?Person 55 | { 56 | if (count($this->items) === 0) { 57 | return null; 58 | } 59 | 60 | return $this->items[count($this->items) - 1]; 61 | } 62 | 63 | public function contains(Person $item): bool 64 | { 65 | foreach ($this->items as $existingItem) { 66 | if ($existingItem->equals($item)) { 67 | return true; 68 | } 69 | } 70 | 71 | return false; 72 | } 73 | 74 | /** 75 | * @return Person[] 76 | */ 77 | public function items(): array 78 | { 79 | return $this->items; 80 | } 81 | 82 | public function toArray(): array 83 | { 84 | return \array_map(function (Person $item) { 85 | return $item->toArray(); 86 | }, $this->items); 87 | } 88 | 89 | public function equals($other): bool 90 | { 91 | if (!$other instanceof self) { 92 | return false; 93 | } 94 | 95 | return $this->toArray() === $other->toArray(); 96 | } 97 | 98 | public function __toString(): string 99 | { 100 | return \json_encode($this->toArray()); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /examples/ValueObject/GivenName.php: -------------------------------------------------------------------------------- 1 | name = $name; 18 | } 19 | 20 | public function toString(): string 21 | { 22 | return $this->name; 23 | } 24 | 25 | public function equals($other): bool 26 | { 27 | if(!$other instanceof self) { 28 | return false; 29 | } 30 | 31 | return $this->name === $other->name; 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return $this->name; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/ValueObject/GivenNameList.php: -------------------------------------------------------------------------------- 1 | toString(); 27 | }, $items); 28 | 29 | return new self($rawValues, ...$items); 30 | } 31 | 32 | public static function fromArray(array $items): self 33 | { 34 | return new self($items, ...array_map(function (string $item) { 35 | return GivenName::fromString($item); 36 | }, $items)); 37 | } 38 | 39 | private function __construct(array $rawValues, GivenName ...$items) 40 | { 41 | $this->items = $items; 42 | $this->rawValues = \array_values($rawValues); 43 | } 44 | 45 | public function push(GivenName $item): self 46 | { 47 | $copy = clone $this; 48 | $copy->items[] = $item; 49 | $copy->rawValues[] = $item->toString(); 50 | return $copy; 51 | } 52 | 53 | public function pop(): self 54 | { 55 | $copy = clone $this; 56 | \array_pop($copy->items); 57 | \array_pop($copy->rawValues); 58 | return $copy; 59 | } 60 | 61 | public function first(): ?GivenName 62 | { 63 | return $this->items[0] ?? null; 64 | } 65 | 66 | public function last(): ?GivenName 67 | { 68 | if (count($this->items) === 0) { 69 | return null; 70 | } 71 | 72 | return $this->items[count($this->items) - 1]; 73 | } 74 | 75 | public function contains(GivenName $item): bool 76 | { 77 | foreach ($this->items as $item) { 78 | if($item->equals($item)) { 79 | return true; 80 | } 81 | } 82 | 83 | return false; 84 | } 85 | 86 | 87 | /** 88 | * @return GivenName[] 89 | */ 90 | public function items(): array 91 | { 92 | return $this->items; 93 | } 94 | 95 | public function toArray(): array 96 | { 97 | return $this->rawValues; 98 | } 99 | 100 | public function equals($other): bool 101 | { 102 | if (!$other instanceof self) { 103 | return false; 104 | } 105 | 106 | return $this->toArray() === $other->toArray(); 107 | } 108 | 109 | public function __toString(): string 110 | { 111 | return \json_encode($this->toArray()); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /examples/ValueObject/Person.php: -------------------------------------------------------------------------------- 1 | UserId::generate(), 43 | self::NAME => $givenName 44 | ]); 45 | } 46 | 47 | private function init(): void 48 | { 49 | if(null === $this->friends) { 50 | $this->friends = FriendsList::emptyList(); 51 | } 52 | } 53 | 54 | /** 55 | * @return FriendsList 56 | */ 57 | public function friends(): FriendsList 58 | { 59 | return $this->friends; 60 | } 61 | 62 | /** 63 | * @return Age|null 64 | */ 65 | public function age(): ?Age 66 | { 67 | return $this->age; 68 | } 69 | 70 | /** 71 | * @return GivenName 72 | */ 73 | public function name(): GivenName 74 | { 75 | return $this->name; 76 | } 77 | 78 | /** 79 | * @return UserId 80 | */ 81 | public function userId(): UserId 82 | { 83 | return $this->userId; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /examples/ValueObject/RegistrationDate.php: -------------------------------------------------------------------------------- 1 | date = $date; 42 | } 43 | 44 | public function toString(): string 45 | { 46 | return $this->date->format(self::FORMAT); 47 | } 48 | 49 | public function dateTime(): \DateTimeImmutable 50 | { 51 | return $this->date; 52 | } 53 | 54 | public function add(\DateInterval $interval): self 55 | { 56 | return new self($this->date->add($interval)); 57 | } 58 | 59 | public function sub(\DateInterval $interval): self 60 | { 61 | return new self($this->date->sub($interval)); 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return $this->toString(); 67 | } 68 | 69 | private static function ensureUTC(\DateTimeImmutable $date): \DateTimeImmutable 70 | { 71 | if ($date->getTimezone()->getName() !== 'UTC') { 72 | $date = $date->setTimezone(new \DateTimeZone('UTC')); 73 | } 74 | 75 | return $date; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/ValueObject/UserId.php: -------------------------------------------------------------------------------- 1 | userId = $userId; 26 | } 27 | 28 | public function toString(): string 29 | { 30 | return $this->userId->toString(); 31 | } 32 | 33 | public function equals($other): bool 34 | { 35 | if (!$other instanceof self) { 36 | return false; 37 | } 38 | 39 | return $this->userId->equals($other->userId); 40 | } 41 | 42 | public function __toString(): string 43 | { 44 | return $this->userId->toString(); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /examples/ValueObject/Version.php: -------------------------------------------------------------------------------- 1 | version = $version; 18 | } 19 | 20 | public function toFloat(): float 21 | { 22 | return $this->version; 23 | } 24 | 25 | public function equals($other): bool 26 | { 27 | if(!$other instanceof self) { 28 | return false; 29 | } 30 | 31 | return $this->version === $other->version; 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return (string)$this->version; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /examples/ValueObject/WriteAccess.php: -------------------------------------------------------------------------------- 1 | access = $access; 18 | } 19 | 20 | public function toBool(): bool 21 | { 22 | return $this->access; 23 | } 24 | 25 | public function equals($other): bool 26 | { 27 | if(!$other instanceof self) { 28 | return false; 29 | } 30 | 31 | return $this->access === $other->access; 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return $this->access ? 'TRUE' : 'FALSE'; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /template/body.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | $cssBootswatch = getenv('CSS_BOOTSWATCH') ?: 'cerulean'; 10 | 11 | ?> 12 | 13 |
14 | render('core'); ?> 15 |
16 | forkOnGithub(); ?> 17 | render('script'); ?> 18 | 19 | -------------------------------------------------------------------------------- /template/head.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | ?> 10 | 11 | render('meta'); ?> 12 | render('style'); ?> 13 | render('styleProoph'); ?> 14 | 15 | -------------------------------------------------------------------------------- /template/helper/forkOnGithub.php: -------------------------------------------------------------------------------- 1 | isRemote()) { 10 | $this->githubRepo = $this->extractGithubRepo($config); 11 | } 12 | } 13 | 14 | public function __invoke() 15 | { 16 | return 'Fork me on GitHub'; 17 | } 18 | 19 | private function extractGithubRepo(\Bookdown\Bookdown\Config\IndexConfig $config) 20 | { 21 | $match = []; 22 | if(preg_match('/^https:\/\/raw.githubusercontent.com\/(?P[\w-_]+)\/(?P[\w-_]+)\/.*$/', $config->getFile(), $match)) { 23 | return 'https://github.com/' . $match['orga'] . '/' . $match['repo']; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /template/main.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | putenv('MENU_LOGO=http://getprooph.org/images/prooph-logo.svg'); 11 | 12 | $templatePath = __DIR__ . '/../vendor/bookdown/themes/templates'; 13 | 14 | require_once $templatePath . '/helper/tocList.php'; 15 | require_once __DIR__ . '/helper/forkOnGithub.php'; 16 | 17 | $config = $this->page->getRoot()->getConfig(); 18 | 19 | $indexPage = $this->page instanceof \Bookdown\Bookdown\Content\IndexPage? $this->page : $this->page->getParent(); 20 | $indexConfig = $indexPage->getConfig(); 21 | 22 | // register view helper 23 | $helpers = $this->getHelpers(); 24 | 25 | $helpers->set('tocListHelper', function () use ($config) { 26 | return new \tocListHelper($this->get('anchorRaw'), $config); 27 | }); 28 | 29 | $helpers->set('forkOnGithub', function () use ($indexConfig) { 30 | return new \forkOnGithub($indexConfig); 31 | }); 32 | 33 | 34 | // register the templates 35 | $templates = $this->getViewRegistry(); 36 | 37 | $templates->set('head', __DIR__ . '/head.php'); 38 | $templates->set('meta', __DIR__ . '/meta.php'); 39 | $templates->set('style', $templatePath . '/style.php'); 40 | $templates->set('styleProoph', __DIR__ . '/style.php'); 41 | $templates->set('body', __DIR__ . '/body.php'); 42 | $templates->set('script', $templatePath . '/script.php'); 43 | $templates->set('nav', __DIR__ . '/nav.php'); 44 | $templates->set('core', $templatePath . '/core.php'); 45 | $templates->set('navheader', __DIR__ . '/navheader.php'); 46 | $templates->set('navfooter', __DIR__ . '/navfooter.php'); 47 | $templates->set('toc', $templatePath . '/toc.php'); 48 | $templates->set('partialTopNav', __DIR__ . '/partial/topNav.php'); 49 | $templates->set('partialBreadcrumb', $templatePath . '/partial/breadcrumb.php'); 50 | $templates->set('partialSideNav', __DIR__ . '/partial/sideNav.php'); 51 | ?> 52 | 53 | 54 | 55 | render('head'); ?> 56 | render('body'); ?> 57 | 58 | -------------------------------------------------------------------------------- /template/meta.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | ?> 10 | 11 | 12 | 13 | 14 | <?php echo $this->page->getTitle(); ?> 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /template/nav.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /* @var $page \Bookdown\Bookdown\Content\Page */ 11 | $page = $this->page->getRoot(); 12 | 13 | 14 | $navClass = $this->page->isRoot()? 'navbar-front' : 'navbar-default'; 15 | ?> 16 | 17 | 75 | -------------------------------------------------------------------------------- /template/navfooter.php: -------------------------------------------------------------------------------- 1 | page->getPrev(); 11 | $parent = $this->page->getParent(); 12 | $next = $this->page->getNext(); 13 | 14 | if (! ($copyright = $this->page->getCopyright())) { 15 | $copyright = 'Imprint | Powered by Bookdown Bootswatch Templates.'; 16 | } 17 | ?> 18 | 19 | 20 | 21 | 22 |
23 | 39 | 48 |
49 | -------------------------------------------------------------------------------- /template/navheader.php: -------------------------------------------------------------------------------- 1 | page->getPrev(); 14 | $parent = $this->page->getParent(); 15 | $next = $this->page->getNext(); 16 | 17 | $col = '12'; 18 | 19 | if ($useSideMenu = !($this->page instanceof IndexPage || $this->page instanceof RootPage)){ 20 | $col = '9'; 21 | } 22 | ?> 23 |
24 | render("nav"); ?> 25 |
26 |
27 |
28 | 29 | 30 |
31 | render("partialSideNav"); ?> 32 |
33 | 34 |
35 | -------------------------------------------------------------------------------- /template/partial/sideNav.php: -------------------------------------------------------------------------------- 1 | page instanceof \Bookdown\Bookdown\Content\IndexPage) { 13 | return ''; 14 | } 15 | $startLevel = 0; 16 | ?> 17 | 48 | -------------------------------------------------------------------------------- /template/partial/topNav.php: -------------------------------------------------------------------------------- 1 | 16 | 17 |
  • 18 | anchorRaw($page->getHref(), $page->getTitle()); ?> 19 | 20 | 21 | getChildren()) > 0 23 | && $depth <= $maxDepth 24 | ): ?> 25 | 26 | 27 |
      28 | getChildren() as $child) : ?> 29 | render('partialTopNav', array( 30 | 'page' => $child, 31 | 'depth' => $depth 32 | )); 33 | ?> 34 | 35 |
    36 | 37 | 38 | 39 |
  • 40 | 41 | -------------------------------------------------------------------------------- /template/style.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | ?> 10 | 334 | -------------------------------------------------------------------------------- /template/toc.php: -------------------------------------------------------------------------------- 1 | page->hasNestedTocEntries()) { 11 | return; 12 | } 13 | 14 | ?> 15 | 16 |

    page->getNumberAndTitle(); ?>

    17 | 18 | tocListHelper($this->page->getNestedTocEntries()); ?> 19 | --------------------------------------------------------------------------------