├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── coverage.svg ├── docs ├── advanced-usage.md ├── basic-usage.md ├── domain-and-namespace.md ├── migrations.md └── unit-tests.md ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── src ├── Console │ └── Commands │ │ └── MakeEventSourcingDomainCommand.php ├── Domain │ ├── Blueprint │ │ ├── Concerns │ │ │ ├── HasBlueprintColumnType.php │ │ │ └── HasBlueprintFake.php │ │ └── Contracts │ │ │ └── BlueprintUnsupportedInterface.php │ ├── Command │ │ ├── Concerns │ │ │ └── CanCreateDirectories.php │ │ ├── Contracts │ │ │ ├── AcceptedNotificationInterface.php │ │ │ └── DefaultSettingsInterface.php │ │ └── Models │ │ │ └── CommandSettings.php │ ├── Migrations │ │ └── Migration.php │ ├── PhpParser │ │ ├── Concerns │ │ │ ├── HasMethodNode.php │ │ │ └── HasSchemaUpNode.php │ │ ├── MigrationParser.php │ │ ├── Models │ │ │ ├── EnterNode.php │ │ │ ├── MigrationCreateProperties.php │ │ │ ├── MigrationCreateProperty.php │ │ │ └── MigrationCreatePropertyType.php │ │ └── Traversers │ │ │ └── BlueprintClassNodeVisitor.php │ └── Stubs │ │ ├── Models │ │ └── StubCallback.php │ │ ├── StubReplacer.php │ │ ├── StubResolver.php │ │ └── Stubs.php ├── Exceptions │ ├── MigrationDoesNotExistException.php │ ├── MigrationInvalidException.php │ ├── MigrationInvalidPrimaryKeyException.php │ └── ParserFailedException.php └── Providers │ └── PackageServiceProvider.php ├── stubs ├── actions.create.with-aggregate.stub ├── actions.create.without-aggregate.stub ├── actions.delete.with-aggregate.stub ├── actions.delete.without-aggregate.stub ├── actions.update.with-aggregate.stub ├── actions.update.without-aggregate.stub ├── aggregate.stub ├── data-transfer-object.stub ├── events.created.stub ├── events.creation_failed.stub ├── events.deleted.stub ├── events.deletion_failed.stub ├── events.update_failed.stub ├── events.updated.stub ├── notifications.concerns.has_data_as_array.stub ├── notifications.concerns.has_microsoft_teams_notification.stub ├── notifications.concerns.has_slack_notification.stub ├── notifications.created.stub ├── notifications.creation_failed.stub ├── notifications.deleted.stub ├── notifications.deletion_failed.stub ├── notifications.update_failed.stub ├── notifications.updated.stub ├── projection.stub ├── projector.stub ├── reactor.stub ├── stub-mapping.json └── test.stub ├── testbench.yaml └── tests ├── Concerns ├── AssertsDomainGenerated.php ├── CreatesMockMigration.php └── WithMockPackages.php ├── Domain ├── Migrations │ ├── Contracts │ │ └── MigrationOptionInterface.php │ └── ModifyMigration.php └── PhpParser │ └── Traversers │ └── BlueprintClassModifyNodeVisitor.php ├── Mocks └── MockFilesystem.php ├── TestCase.php └── Unit ├── Console └── Commands │ ├── MakeEventSourcingDomainCommandAggregatesTest.php │ ├── MakeEventSourcingDomainCommandBasicTest.php │ ├── MakeEventSourcingDomainCommandFailedEventsTest.php │ ├── MakeEventSourcingDomainCommandFailuresTest.php │ ├── MakeEventSourcingDomainCommandMigrationsTest.php │ ├── MakeEventSourcingDomainCommandNotificationsTest.php │ ├── MakeEventSourcingDomainCommandReactorsTest.php │ └── MakeEventSourcingDomainCommandUnitTestsTest.php └── Domain └── PhpParser └── Models ├── MigrationCreatePropertiesTest.php └── MigrationCreatePropertyTypeTest.php /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ ubuntu-latest ] 15 | php: [ 8.2, 8.3 ] 16 | dependency-version: [ prefer-lowest, prefer-stable ] 17 | 18 | name: P${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | 24 | - name: Setup PHP 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php }} 28 | extensions: dom, curl, libxml, mbstring, zip, http 29 | coverage: none 30 | 31 | - name: Install dependencies 32 | run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist 33 | 34 | - name: Execute tests 35 | run: composer run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | node_modules/ 3 | npm-debug.log 4 | yarn-error.log 5 | 6 | # Laravel 4 specific 7 | bootstrap/compiled.php 8 | app/storage/ 9 | 10 | # Laravel 5 & Lumen specific 11 | public/storage 12 | public/hot 13 | 14 | # Laravel 5 & Lumen specific with changed public path 15 | public_html/storage 16 | public_html/hot 17 | 18 | storage/*.key 19 | .env 20 | Homestead.yaml 21 | Homestead.json 22 | /.vagrant 23 | .phpunit.result.cache 24 | 25 | .docs 26 | .idea 27 | 28 | # Unit tests 29 | .phpunit.cache/ 30 | 31 | # Coverage 32 | reports 33 | clover.xml 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-event-sorucing-generator` will be documented in this file: 4 | 5 | ## 1.0.12 - 2025-03-18 6 | 7 | ### What's Changed 8 | 9 | * Migrations, bug fix: exclude down() method from being parsed 10 | 11 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.11...v1.0.12 12 | 13 | ## 1.0.11 - 2025-03-18 14 | 15 | ### What's Changed 16 | 17 | * Migrations, support dropColumn and renameColumn 18 | * Migrations, support excluded parameter 19 | 20 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.10...v1.0.11 21 | 22 | ## 1.0.10 - 2025-03-18 23 | 24 | ### What's Changed 25 | 26 | * Composer update (Laravel 11.44.2) 27 | 28 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.9...v1.0.10 29 | 30 | ## 1.0.9 - 2025-03-16 31 | 32 | ### What's Changed 33 | 34 | * Add database notifications 35 | 36 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.8...v1.0.9 37 | 38 | ## 1.0.8 - 2025-02-16 39 | 40 | ### What's Changed 41 | 42 | * Support update migrations 43 | 44 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.7...v1.0.8 45 | 46 | ## 1.0.7 - 2024-12-31 47 | 48 | ### What's Changed 49 | 50 | * Support PHP 8.3 51 | 52 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.6...v1.0.7 53 | 54 | ## 1.0.6 - 2024-12-21 55 | 56 | ### What's Changed 57 | 58 | * Fix Slack notifications 59 | * Improve stub asserts 60 | 61 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.5...v1.0.6 62 | 63 | ## 1.0.5 - 2024-12-21 64 | 65 | ### What's Changed 66 | 67 | * Fix: do not add comments for Blueprint skipped methods 68 | 69 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.4...v1.0.5 70 | 71 | ## 1.0.4 - 2024-12-21 72 | 73 | ### What's Changed 74 | 75 | * Improve documentation 76 | * Add changelog 77 | * Change indentation option 78 | * Improve documentation. Add Contributing page. 79 | 80 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.3...v1.0.4 81 | 82 | ## 1.0.3 - 2024-12-20 83 | 84 | ### What's Changed 85 | 86 | * Refactor aggregates to use Spatie folders 87 | * Composer update 88 | 89 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.2...v1.0.3 90 | 91 | ## 1.0.2 - 2024-12-02 92 | 93 | ### What's Changed 94 | 95 | * Fix namespace of generated unit tests 96 | 97 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.1...v1.0.2 98 | 99 | ## 1.0.1 - 2024-12-01 100 | 101 | ### What's Changed 102 | 103 | * Infer if Carbon must be included in generated files 104 | 105 | **Full Changelog**: https://github.com/albertoarena/laravel-event-sourcing-generator/compare/v1.0.0...v1.0.1 106 | 107 | ## 1.0.0 - 2024-11-25 108 | 109 | ### What's Changed 110 | 111 | * first version! 112 | 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you for considering contributing to Laravel Event Sourcing Generator! Your contributions are greatly 4 | appreciated and help make this project better for everyone. 5 | 6 | The following guidelines are here to ensure the contribution 7 | process runs smoothly. 8 | 9 | ## How Can I Contribute? 10 | 11 | ### 1. Reporting Bugs 12 | 13 | If you find a bug, please: 14 | 15 | - Check the [issue tracker](https://github.com/albertoarena/laravel-event-sourcing-generator/issues) to see if it has 16 | already been reported. 17 | - Open a new issue if it hasn't been reported. 18 | - Use a clear and descriptive title. 19 | - Include steps to reproduce the issue, expected behavior, and actual behavior. 20 | - Provide any relevant logs, screenshots, or code snippets. 21 | 22 | ### 2. Suggesting Features 23 | 24 | We welcome feature requests! To suggest a feature: 25 | 26 | - Check the [issue tracker](https://github.com/albertoarena/laravel-event-sourcing-generator/issues) to see if the 27 | feature has already been 28 | suggested. 29 | - Open a new issue labeled `feature request`. 30 | - Clearly explain the feature and why it would be beneficial. 31 | - Include examples or use cases if possible. 32 | 33 | ### 3. Submitting Code Changes 34 | 35 | Want to fix a bug or implement a feature? Great! Here’s how to contribute code: 36 | 37 | 1. **Fork** the repository. 38 | 2. Create a new **branch** for your work: 39 | ```bash 40 | git checkout -b feature/your-feature-name 41 | ``` 42 | 3. **Make your changes** and ensure your code adheres to the project’s style guidelines. 43 | ```bash 44 | # Run Laravel Pint 45 | composer fix 46 | # Run LaraStan analysis 47 | composer static 48 | ``` 49 | 4. **Test your changes** to make sure everything works as expected. 50 | ```bash 51 | composer test 52 | ``` 53 | 5. Refresh code coverage badge icon 54 | ```bash 55 | composer test-coverage 56 | ``` 57 | 6. **Commit** your changes with a clear and concise commit message. 58 | 7. **Push** your branch to your forked repository: 59 | ```bash 60 | git push origin feature/your-feature-name 61 | ``` 62 | 8. Open a **pull request (PR)** to the main repository: 63 | - Provide a detailed description of your changes. 64 | - Reference any related issues. 65 | - Wait for feedback or approval from the maintainers. 66 | 67 | ### Improving Documentation 68 | 69 | If you find areas in the documentation that can be improved: 70 | 71 | - Open an issue to discuss your proposed changes. 72 | - Submit a pull request with your updates. 73 | 74 | ## Etiquette 75 | 76 | To maintain a welcoming and collaborative environment: 77 | 78 | - **Be respectful:** Treat everyone with kindness and respect, even when there are disagreements. 79 | - **Be constructive:** Provide helpful, actionable feedback. Avoid harsh criticism. 80 | - **Be inclusive:** Encourage diverse perspectives and ensure your contributions are accessible to everyone. 81 | - **Be patient:** Remember that maintainers are volunteers and may not respond immediately. Allow time for reviews and 82 | discussions. 83 | - **Acknowledge contributions:** Give credit to other contributors where applicable. 84 | 85 | ## Code Guidelines 86 | 87 | - This project uses [Laravel Pint](https://laravel.com/docs/11.x/pint) and its code is analysed 88 | using [LaraStan](https://github.com/larastan/larastan). 89 | - Write clear, maintainable, and well-documented code. 90 | - Ensure your code passes all tests and adheres to the project’s formatting rules. 91 | 92 | ## Getting Help 93 | 94 | If you need help, feel free to: 95 | 96 | - Ask a question by opening an issue. 97 | - [Contact the main developer](https://github.com/albertoarena/) 98 | 99 | Thank you for contributing! Your support helps keep this project thriving. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alberto Arena 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel event sourcing generator 2 | 3 | ![build-test](coverage.svg) 4 | [![License](https://img.shields.io/badge/license-MIT-red.svg?style=flat-square)](LICENSE.md) 5 | ![Version](https://img.shields.io/github/v/tag/albertoarena/laravel-event-sourcing-generator?label=version) 6 | ![Code Size](https://img.shields.io/github/languages/code-size/albertoarena/laravel-event-sourcing-generator) 7 | 8 | Laravel event sourcing generator adds a new Artisan command that can generate a full domain directory structure 9 | for [Spatie event sourcing](https://github.com/spatie/laravel-event-sourcing). 10 | 11 | ## Table of Contents 12 | 13 | - [Changelog](#changelog) 14 | - [Contributing](#contributing) 15 | - [Installation](#installation) 16 | - [Compatibility](#compatibility) 17 | - [Install](#install) 18 | - [Usage](#usage) 19 | - [Show help](#show-help) 20 | - [Basic usage](#basic-usage) 21 | - [Domain and namespace](#domain-and-namespace) 22 | - [Advanced usage](#advanced-usage) 23 | - [Set primary key](#set-primary-key) 24 | - [Generate PHPUnit tests](#generate-phpunit-tests) 25 | - [Generate aggregates](#generate-aggregates) 26 | - [Generate reactors](#generate-reactors) 27 | - [Generate failed events](#generate-failed-events) 28 | - [Generate notifications](#generate-notifications) 29 | - [Specify indentation](#specify-indentation) 30 | - [Specify the path of root folder](#specify-the-path-of-root-folder) 31 | - [Limitations and future enhancements](#limitations-and-future-enhancements) 32 | 33 | ## Changelog 34 | 35 | [⬆️ Go to TOC](#table-of-contents) 36 | 37 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 38 | 39 | ## Contributing 40 | 41 | [⬆️ Go to TOC](#table-of-contents) 42 | 43 | Feel free to fork, improve and create a pull request. 44 | 45 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 46 | 47 | ## Installation 48 | 49 | [⬆️ Go to TOC](#table-of-contents) 50 | 51 | ### Compatibility 52 | 53 | | What | Version | 54 | |-----------------------------------------------------------------------------|-----------------| 55 | | PHP | 8.2 / 8.3 | 56 | | [Laravel](https://github.com/laravel/laravel) | 10.x / 11.x (*) | 57 | | [Spatie's event sourcing](https://github.com/spatie/laravel-event-sourcing) | 7.x | 58 | 59 | > (*) Package has been tested in Laravel 10, even it is not officially released for that version. 60 | 61 | ### Install 62 | 63 | ```shell 64 | composer require albertoarena/laravel-event-sourcing-generator 65 | ``` 66 | 67 | ## Usage 68 | 69 | [⬆️ Go to TOC](#table-of-contents) 70 | 71 | ```text 72 | php artisan make:event-sourcing-domain 73 | [-d|--domain=] # The name of the domain 74 | [--namespace=] # The namespace or root folder (default: "Domain") 75 | [-m|--migration=] # Indicate any existing migration for the model, with or without timestamp prefix. Table name is sufficient 76 | [--migration-exclude=] # Indicate any existing migration for the model, that must be excluded. It accepts regex. Table name is sufficient 77 | [-a|--aggregate=<0|1>] # Indicate if aggregate must be created or not (accepts 0 or 1) 78 | [-r|--reactor=<0|1>] # Indicate if reactor must be created or not (accepts 0 or 1) 79 | [-u|--unit-test] # Indicate if PHPUnit tests must be created 80 | [-p|--primary-key=] # Indicate which is the primary key (uuid, id) 81 | [--indentation=] # Indentation spaces 82 | [--failed-events=<0|1>] # Indicate if failed events must be created (accepts 0 or 1) 83 | [--notifications=] # Indicate if notifications must be created, comma separated (accepts database,mail,no,slack,teams) 84 | [--root= # The name of the root folder (default: "app") 85 | ``` 86 | 87 | ### Show help 88 | 89 | ```shell 90 | php artisan help make:event-sourcing-domain 91 | ``` 92 | 93 | ### Basic usage 94 | 95 | [⬆️ Go to TOC](#table-of-contents) 96 | 97 | [Documentation about basic usage](./docs/basic-usage.md) 98 | 99 | #### Generate a model with same name of the domain 100 | 101 | ```shell 102 | php artisan make:event-sourcing-domain Animal \ 103 | --domain=Animal 104 | ``` 105 | 106 | #### Generate a model with different domain 107 | 108 | [Read documentation with examples](./docs/domain-and-namespace.md#choosing-the-name-of-the-domain) 109 | 110 | ```shell 111 | php artisan make:event-sourcing-domain Tiger \ 112 | --domain=Animal 113 | ``` 114 | 115 | #### Generate a model with different domain and namespace 116 | 117 | [Read documentation with examples](./docs/domain-and-namespace.md#choosing-the-namespace) 118 | 119 | ```shell 120 | php artisan make:event-sourcing-domain Tiger \ 121 | --domain=Animal \ 122 | --namespace=CustomDomain 123 | ``` 124 | 125 | #### Generate a model from existing migration 126 | 127 | [Read documentation with examples](./docs/migrations.md) 128 | 129 | ```shell 130 | php artisan make:event-sourcing-domain Animal \ 131 | --migration=create_animal_table \ 132 | --unit-test 133 | ``` 134 | 135 | #### Generate a model from existing migration using pattern and exclude specific one 136 | 137 | [Read documentation with examples](./docs/migrations.md#generate-a-domain-using-update-migration-excluding-some-specific-migration) 138 | 139 | ```shell 140 | php artisan make:event-sourcing-domain Animal \ 141 | --migration=animal \ 142 | --migration-exclude=drop_last_column_from_animals \ 143 | --unit-test 144 | ``` 145 | 146 | #### Generate a model from existing migration using pattern and exclude using regex 147 | 148 | [Read documentation with examples](./docs/migrations.md#generate-a-domain-using-update-migration-excluding-some-specific-migration) 149 | 150 | ```shell 151 | php artisan make:event-sourcing-domain Animal \ 152 | --migration=animal \ 153 | --migration-exclude="/drop_.*_from_animals/" \ 154 | --unit-test 155 | ``` 156 | 157 | #### Generate a model from existing migration with PHPUnit tests 158 | 159 | ```shell 160 | php artisan make:event-sourcing-domain Animal \ 161 | --migration=create_animal_table \ 162 | --unit-test 163 | ``` 164 | 165 | #### Generate a model from existing migration with failed events and database / mail / Slack notifications 166 | 167 | ```shell 168 | php artisan make:event-sourcing-domain Animal \ 169 | --migration=create_animal_table \ 170 | --failed-events=1 \ 171 | --notifications=database,mail,slack 172 | ``` 173 | 174 | ### Domain and namespace 175 | 176 | [⬆️ Go to TOC](#table-of-contents) 177 | 178 | [Read documentation about directory structure](./docs/domain-and-namespace.md#directory-structure) 179 | 180 | #### Specify the name of the domain 181 | 182 | [Read documentation with examples](./docs/domain-and-namespace.md#specify-the-name-of-the-domain) 183 | 184 | ```shell 185 | php artisan make:event-sourcing-domain Animal --domain=Tiger 186 | php artisan make:event-sourcing-domain Animal --domain=Lion 187 | ``` 188 | 189 | #### Specify the namespace 190 | 191 | [Read documentation with examples](./docs/domain-and-namespace.md#specify-the-namespace) 192 | 193 | ```shell 194 | php artisan make:event-sourcing-domain Tiger --namespace=MyDomain --domain=Animal 195 | ``` 196 | 197 | ### Advanced usage 198 | 199 | [⬆️ Go to TOC](#table-of-contents) 200 | 201 | #### Set primary key 202 | 203 | [Read documentation with examples](./docs/advanced-usage.md#specify-primary-key) 204 | 205 | Default primary key is `uuid`. That will work with Aggregate class. 206 | 207 | It is possible to use `id` as primary key: 208 | 209 | ```shell 210 | php artisan make:event-sourcing-domain Animal --primary-key=id 211 | ``` 212 | 213 | When importing migrations, primary key will be automatically loaded from file. 214 | 215 | #### Generate PHPUnit tests 216 | 217 | [Read documentation with examples](./docs/unit-tests.md) 218 | 219 | ```shell 220 | php artisan make:event-sourcing-domain Animal --unit-test 221 | ``` 222 | 223 | #### Generate aggregates 224 | 225 | [Read documentation with examples](./docs/advanced-usage.md#generate-aggregates) 226 | 227 | ```shell 228 | php artisan make:event-sourcing-domain Animal --aggregate=1 229 | ``` 230 | 231 | This is available only for models using `uuid` as primary key. 232 | 233 | #### Generate reactors 234 | 235 | [Read documentation with examples](./docs/advanced-usage.md#generate-reactors) 236 | 237 | ```shell 238 | php artisan make:event-sourcing-domain Animal --reactor=1 239 | ``` 240 | 241 | #### Generate failed events 242 | 243 | [Read documentation with examples](./docs/advanced-usage.md#generate-failed-events) 244 | 245 | ```shell 246 | php artisan make:event-sourcing-domain Animal --failed-events=1 247 | ``` 248 | 249 | #### Generate notifications 250 | 251 | [Read documentation with examples](./docs/advanced-usage.md#generate-notifications) 252 | 253 | ```shell 254 | php artisan make:event-sourcing-domain Animal --notifications= 255 | ``` 256 | 257 | #### Specify indentation 258 | 259 | [Read documentation with examples](./docs/advanced-usage.md#specify-the-indentation) 260 | 261 | ```shell 262 | php artisan make:event-sourcing-domain Animal --indentation=2 263 | ``` 264 | 265 | #### Specify the path of root folder 266 | 267 | [Read documentation with examples](./docs/advanced-usage.md#specify-the-path-of-root-folder) 268 | 269 | ```shell 270 | php artisan make:event-sourcing-domain Animal --root=src 271 | ``` 272 | 273 | ## Limitations and future enhancements 274 | 275 | [⬆️ Go to TOC](#table-of-contents) 276 | 277 | ### Blueprint column types 278 | 279 | [Read documentation](./docs/migrations.md#unsupported-column-types) 280 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "albertoarena/laravel-event-sourcing-generator", 3 | "description": "Laravel event sourcing domain generator", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Albertoarena\\LaravelEventSourcingGenerator\\": "src/" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "Tests\\": "tests/" 14 | } 15 | }, 16 | "authors": [ 17 | { 18 | "role": "Developer", 19 | "name": "Alberto Arena", 20 | "email": "arena.alberto@gmail.com", 21 | "homepage": "https://albertoarena.it/" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2|^8.3", 26 | "aldemeery/onion": "^1.0", 27 | "illuminate/contracts": "*", 28 | "illuminate/support": "*", 29 | "nikic/php-parser": "^5.1", 30 | "spatie/laravel-event-sourcing": "^7.9" 31 | }, 32 | "minimum-stability": "dev", 33 | "prefer-stable": true, 34 | "config": { 35 | "sort-packages": true, 36 | "preferred-install": "dist", 37 | "optimize-autoloader": true, 38 | "allow-plugins": { 39 | "pestphp/pest-plugin": true 40 | } 41 | }, 42 | "require-dev": { 43 | "jaschilz/php-coverage-badger": "^2.0", 44 | "larastan/larastan": "^2.9", 45 | "laravel/pint": "^1.1", 46 | "orchestra/testbench": "^9.0", 47 | "php-mock/php-mock-mockery": "^1.4", 48 | "phpstan/phpstan": "^1.12", 49 | "phpunit/phpunit": "^11.4" 50 | }, 51 | "scripts": { 52 | "test": "@php ./vendor/bin/phpunit --testdox tests", 53 | "fix": "@php ./vendor/bin/pint", 54 | "check": "@php ./vendor/bin/pint --test -v", 55 | "static": "@php ./vendor/bin/phpstan analyse", 56 | "all": [ 57 | "@test", 58 | "@fix", 59 | "@check", 60 | "@static" 61 | ], 62 | "post-autoload-dump": [ 63 | "@clear", 64 | "@prepare" 65 | ], 66 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 67 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 68 | "build": "@php vendor/bin/testbench workbench:build --ansi", 69 | "lint": [ 70 | "@php vendor/bin/pint --ansi", 71 | "@php vendor/bin/phpstan analyse --verbose --ansi" 72 | ], 73 | "test-coverage": [ 74 | "@putenv XDEBUG_MODE=coverage", 75 | "@php ./vendor/bin/phpunit --coverage-html reports/ --coverage-clover clover.xml --process-isolation tests", 76 | "@php ./vendor/bin/php-coverage-badger clover.xml coverage.svg" 77 | ] 78 | }, 79 | "extra": { 80 | "laravel": { 81 | "providers": [ 82 | "Albertoarena\\LaravelEventSourcingGenerator\\Providers\\PackageServiceProvider" 83 | ] 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 98% 19 | 98% 20 | 21 | -------------------------------------------------------------------------------- /docs/advanced-usage.md: -------------------------------------------------------------------------------- 1 | # Advanced options 2 | 3 | [Back to README](./../README.md) 4 | 5 | ## Table of Contents 6 | 7 | - [Specify primary key](#specify-primary-key) 8 | - [Generate aggregates](#generate-aggregates) 9 | - [Generate reactors](#generate-reactors) 10 | - [Generate failed events](#generate-failed-events) 11 | - [Generate notifications](#generate-notifications) 12 | - [Specify the indentation](#specify-the-indentation) 13 | - [Specify the path of root folder](#specify-the-path-of-root-folder) 14 | 15 | ## Specify primary key 16 | 17 | [⬆️ Go to TOC](#table-of-contents) 18 | 19 | Primary key can be `uuid` or `id`. 20 | 21 | Use option `--primary-key=[uuid|id]` to choose it, or answer the interactive question. 22 | 23 | **Important:** when using a [migration](/docs/migrations.md), the primary key will be automatically inferred. 24 | 25 | If primary key `id` is preferred, [aggregates](#generate-aggregates) will not be available. 26 | 27 | ### Example 28 | 29 | Generate a domain using `id` as primary key: 30 | 31 | ```shell 32 | php artisan make:event-sourcing-domain Animal --primary-key=id 33 | ``` 34 | 35 | ## Generate aggregates 36 | 37 | [⬆️ Go to TOC](#table-of-contents) 38 | 39 | *Read more about aggregates 40 | in [Spatie documentation](https://spatie.be/docs/laravel-event-sourcing/v7/using-aggregates/writing-your-first-aggregate).* 41 | 42 | Aggregates can be generated only if primary key is `uuid`. 43 | 44 | Use option `--aggregate=[0|1]`, or answer interactive question. 45 | 46 | ### Example 47 | 48 | Generate aggregates: 49 | 50 | ```shell 51 | php artisan make:event-sourcing-domain Animal --aggregate=1 --primary-key=uuid 52 | ``` 53 | 54 | If aggregates have been generated, actions will automatically use them. 55 | 56 | ## Generate reactors 57 | 58 | [⬆️ Go to TOC](#table-of-contents) 59 | 60 | *Read more about reactors 61 | in [Spatie documentation](https://spatie.be/docs/laravel-event-sourcing/v7/using-reactors/writing-your-first-reactor).* 62 | 63 | Use option `--reactor=[0|1]`. 64 | 65 | ### Example 66 | 67 | Generate reactors: 68 | 69 | ```shell 70 | php artisan make:event-sourcing-domain Animal --reactor=1 71 | ``` 72 | 73 | Reactors will be generated for all events, including [failed ones](#generate-failed-events) when enabled with option 74 | `--failed-events=1`. 75 | 76 | ## Generate failed events 77 | 78 | [⬆️ Go to TOC](#table-of-contents) 79 | 80 | The command can generate create / update / delete failed events. 81 | 82 | Use option `--failed-events=[0|1]`. 83 | 84 | ### Example 85 | 86 | Generate failed events: 87 | 88 | ```shell 89 | php artisan make:event-sourcing-domain Animal --failed-events=1 90 | ``` 91 | 92 | The following events will be created 93 | 94 | ``` 95 | AnimalCreationFailed 96 | AnimalDeletionFailed 97 | AnimalUpdateFailed 98 | ``` 99 | 100 | If [notifications](#generate-notifications) are created as well using option `--notification=VALUE`, a failed 101 | notification for each failed event will be automatically created. 102 | 103 | ## Generate notifications 104 | 105 | [⬆️ Go to TOC](#table-of-contents) 106 | 107 | The command supports 4 types of notifications: 108 | 109 | - database 110 | - mail 111 | - Slack 112 | - Teams 113 | 114 | Use option `--notifications=[database,mail,slack,teams]`. Notifications must be separated by comma. 115 | 116 | When notifications are created, one or more concerns (traits) will be created as well in `Notifications/Concerns` 117 | folder, for shared properties and formatting. 118 | 119 | ### Examples 120 | 121 | Generate automatically database notifications: 122 | 123 | ```shell 124 | php artisan make:event-sourcing-domain Animal --notifications=database 125 | ``` 126 | 127 | Generate automatically Teams notifications: 128 | 129 | ```shell 130 | php artisan make:event-sourcing-domain Animal --notifications=teams 131 | ``` 132 | 133 | Generate automatically mail and Slack notifications: 134 | 135 | ```shell 136 | php artisan make:event-sourcing-domain Animal --notifications=mail,slack 137 | ``` 138 | 139 | ## Specify the indentation 140 | 141 | [⬆️ Go to TOC](#table-of-contents) 142 | 143 | Default indentation of generated files is 4 space. 144 | 145 | Use option `--indentation=NUMBER`. 146 | 147 | ### Example 148 | 149 | ```shell 150 | php artisan make:event-sourcing-domain Animal --indentation=2 151 | ``` 152 | 153 | This setup will use 2 space as indentation. 154 | 155 | ## Specify the path of root folder 156 | 157 | [⬆️ Go to TOC](#table-of-contents) 158 | 159 | It is possible to specify the App folder, e.g. if a domain must be created in unit tests folder or in a package (root is 160 | `src`). 161 | 162 | Default root folder is `app`. 163 | 164 | Use option `--root=VALUE`. 165 | 166 | ### Example: Generate domain in `src` folder for a package 167 | 168 | Generate domain in `src` folder: 169 | 170 | ```shell 171 | php artisan make:event-sourcing-domain Animal --root=src 172 | ``` 173 | 174 | Directory structure 175 | 176 | ``` 177 | src/ 178 | ├── Domain/ 179 | │ └── Animal/ 180 | │ ├── Actions/ 181 | │ │ ├── CreateAnimal 182 | │ │ ├── DeleteAnimal 183 | │ │ └── UpdateAnimal 184 | │ ├── Aggregates/ 185 | │ │ └── AnimalAggregate 186 | │ ├── DataTransferObjects/ 187 | │ │ └── AnimalData 188 | │ ├── Events/ 189 | │ │ ├── AnimalCreated 190 | │ │ ├── AnimalDeleted 191 | │ │ └── AnimalUpdated 192 | │ ├── Projections/ 193 | │ │ └── Animal 194 | │ ├── Projectors/ 195 | │ │ └── AnimalProjector 196 | │ └── Reactors/ 197 | │ └── AnimalReactor 198 | └── etc. 199 | ``` 200 | 201 | ### Example: Generate domain in `tests/Unit` folder 202 | 203 | Generate domain in `tests/Unit` folder: 204 | 205 | ```shell 206 | php artisan make:event-sourcing-domain Animal --root=tests/Unit 207 | ``` 208 | 209 | Directory structure 210 | 211 | ``` 212 | tests/ 213 | └── Unit/ 214 | ├── Domain/ 215 | │ └── Animal/ 216 | │ ├── Actions/ 217 | │ │ ├── CreateAnimal 218 | │ │ ├── DeleteAnimal 219 | │ │ └── UpdateAnimal 220 | │ ├── Aggregates/ 221 | │ │ └── AnimalAggregate 222 | │ ├── DataTransferObjects/ 223 | │ │ └── AnimalData 224 | │ ├── Events/ 225 | │ │ ├── AnimalCreated 226 | │ │ ├── AnimalDeleted 227 | │ │ └── AnimalUpdated 228 | │ ├── Projections/ 229 | │ │ └── Animal 230 | │ ├── Projectors/ 231 | │ │ └── AnimalProjector 232 | │ └── Reactors/ 233 | │ └── AnimalReactor 234 | └── etc. 235 | ``` 236 | 237 | -------------------------------------------------------------------------------- /docs/basic-usage.md: -------------------------------------------------------------------------------- 1 | # Basic usage 2 | 3 | [Back to README](./../README.md) 4 | 5 | ## Table of Contents 6 | 7 | - [Example](#example) 8 | - [Run command interactively](#run-command-interactively) 9 | - [Generated directory structure and files](#generated-directory-structure-and-files) 10 | - [Sample code](#sample-code) 11 | 12 | ## Example 13 | 14 | Default mode is based on interactive command line. 15 | 16 | In this example, `uuid` will be used as primary key, with an aggregate class. 17 | 18 | ### Run command interactively 19 | 20 | [⬆️ Go to TOC](#table-of-contents) 21 | 22 | ```shell 23 | php artisan make:event-sourcing-domain Animal 24 | ``` 25 | 26 | ``` 27 | Which is the name of the domain? [Animal] 28 | > Animal 29 | 30 | Do you want to import properties from existing database migration? 31 | > no 32 | 33 | Do you want to specify model properties? 34 | > yes 35 | 36 | Property name (exit to quit)? 37 | > name 38 | 39 | Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string) 40 | > string 41 | 42 | Property name (exit to quit)? 43 | > age 44 | 45 | Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string) 46 | > int 47 | 48 | Property name (exit to quit)? 49 | > exit 50 | 51 | Do you want to use uuid as model primary key? 52 | > yes 53 | 54 | Do you want to create an Aggregate class? 55 | > yes 56 | 57 | Do you want to create a Reactor class? 58 | > yes 59 | 60 | Your choices: 61 | 62 | | Option | Choice | 63 | |----------------------------|-------------| 64 | | Model | Animal | 65 | | Domain | Animal | 66 | | Namespace | Domain | 67 | | Use migration | no | 68 | | Primary key | uuid | 69 | | Create Aggregate class | yes | 70 | | Create Reactor class | yes | 71 | | Create PHPUnit tests | no | 72 | | Create failed events | no | 73 | | Model properties | string name | 74 | | | int age | 75 | | Notifications | no | 76 | 77 | Do you confirm the generation of the domain? 78 | > yes 79 | 80 | Domain [Animal] with model [Animal] created successfully. 81 | ``` 82 | 83 | ### Generated directory structure and files 84 | 85 | [⬆️ Go to TOC](#table-of-contents) 86 | 87 | Directory structure generated (using `uuid` as primary key) 88 | 89 | ``` 90 | app/ 91 | ├── Domain/ 92 | │ └── Animal/ 93 | │ ├── Actions/ 94 | │ │ ├── CreateAnimal 95 | │ │ ├── DeleteAnimal 96 | │ │ └── UpdateAnimal 97 | │ ├── Aggregates/ 98 | │ │ └── AnimalAggregate 99 | │ ├── DataTransferObjects/ 100 | │ │ └── AnimalData 101 | │ ├── Events/ 102 | │ │ ├── AnimalCreated 103 | │ │ ├── AnimalDeleted 104 | │ │ └── AnimalUpdated 105 | │ ├── Projections/ 106 | │ │ └── Animal 107 | │ ├── Projectors/ 108 | │ │ └── AnimalProjector 109 | │ └── Reactors/ 110 | │ └── AnimalReactor 111 | └── etc. 112 | ``` 113 | 114 | ### Sample code 115 | 116 | [⬆️ Go to TOC](#table-of-contents) 117 | 118 | If Spatie event sourcing is configured to auto-discover projectors, the following code is immediately usable: 119 | 120 | ```php 121 | use App\Domain\Animal\Actions\CreateAnimal; 122 | use App\Domain\Animal\DataTransferObjects\AnimalData; 123 | use App\Domain\Animal\Projections\Animal; 124 | 125 | # This will create a record in 'animals' table, using projector AnimalProjector 126 | (new CreateAnimal)(new AnimalData( 127 | name: 'tiger', 128 | age: 7 129 | )); 130 | 131 | # Retrieve record 132 | $animal = Animal::query()->where('name', 'tiger')->first(); 133 | ``` 134 | -------------------------------------------------------------------------------- /docs/domain-and-namespace.md: -------------------------------------------------------------------------------- 1 | # Domain and namespace 2 | 3 | [Back to README](./../README.md) 4 | 5 | ## Table of Contents 6 | 7 | - [Directory structure](#directory-structure) 8 | - [Specify the name of the domain](#specify-the-name-of-the-domain) 9 | - [Specify the namespace](#specify-the-namespace) 10 | - [Notes and limitations](#notes-and-limitations) 11 | 12 | ## Directory structure 13 | 14 | [⬆️ Go to TOC](#table-of-contents) 15 | 16 | The directory structure of a domain is as follows: 17 | 18 | ``` 19 | app/ 20 | ├── / 21 | │ └── / 22 | │ ├── Actions/ 23 | │ │ ├── Create 24 | │ │ ├── Delete 25 | │ │ └── etc. 26 | │ └── etc. 27 | └── etc. 28 | ``` 29 | 30 | By default, the **namespace** (or root folder) is `Domain`. 31 | 32 | The name of the **domain** can be the same of the name of the **model**, or different. 33 | 34 | E.g., for model `Animal`: 35 | 36 | ``` 37 | app/ 38 | ├── Domain/ 39 | │ └── Animal/ 40 | │ ├── Actions/ 41 | │ │ ├── CreateAnimal 42 | │ │ ├── DeleteAnimal 43 | │ │ └── etc. 44 | │ └── etc. 45 | └── etc. 46 | ``` 47 | 48 | ## Specify the name of the domain 49 | 50 | [⬆️ Go to TOC](#table-of-contents) 51 | 52 | It is possible to specify a different domain name by answering the interactive question, or by using the option 53 | `--domain`. 54 | 55 | This allows sharing the same domain for different models. 56 | 57 | ### Answering the question 58 | 59 | ```shell 60 | php artisan make:event-sourcing-domain Tiger 61 | ``` 62 | 63 | ``` 64 | Which is the name of the domain? [Tiger] 65 | > Animal 66 | 67 | ... etc. 68 | ``` 69 | 70 | ```shell 71 | php artisan make:event-sourcing-domain Lion 72 | ``` 73 | 74 | ``` 75 | Which is the name of the domain? [Lion] 76 | > Animal 77 | 78 | ... etc. 79 | ``` 80 | 81 | ### Using command line option 82 | 83 | ```shell 84 | php artisan make:event-sourcing-domain Animal --domain=Tiger 85 | php artisan make:event-sourcing-domain Animal --domain=Lion 86 | ``` 87 | 88 | If specified as option, the name of the domain will not be asked. 89 | 90 | ### Result 91 | 92 | Result of both approaches: 93 | 94 | ``` 95 | app/ 96 | ├── Domain/ 97 | │ └── Animal/ 98 | │ ├── Actions/ 99 | │ │ ├── CreateLion 100 | │ │ ├── CreateTiger 101 | │ │ ├── DeleteLion 102 | │ │ ├── DeleteTiger 103 | │ │ └── etc. 104 | │ └── etc. 105 | └── etc. 106 | ``` 107 | 108 | ## Specify the namespace 109 | 110 | [⬆️ Go to TOC](#table-of-contents) 111 | 112 | It is possible to specify a different namespace using option `--namespace`. 113 | 114 | ```shell 115 | php artisan make:event-sourcing-domain Tiger --namespace=MyDomain --domain=Animal 116 | ``` 117 | 118 | Result: 119 | 120 | ``` 121 | app/ 122 | ├── MyDomain/ 123 | │ └── Animal/ 124 | │ ├── Actions/ 125 | │ │ ├── CreateTiger 126 | │ │ ├── DeleteTiger 127 | │ │ └── etc. 128 | │ └── etc. 129 | └── etc. 130 | ``` 131 | 132 | ## Notes and limitations 133 | 134 | [⬆️ Go to TOC](#table-of-contents) 135 | 136 | [Reserved PHP words](https://www.php.net/manual/en/reserved.keywords.php) cannot be used as namespace or domain. 137 | 138 | ### Examples 139 | 140 | Namespace example: 141 | 142 | ```shell 143 | php artisan make:event-sourcing-domain Tiger --namespace=Array --domain=Animal 144 | ``` 145 | 146 | ``` 147 | ERROR The namespace Array is reserved by PHP. 148 | ``` 149 | 150 | Domain example: 151 | 152 | ```shell 153 | php artisan make:event-sourcing-domain Tiger --domain=Echo 154 | ``` 155 | 156 | ``` 157 | ERROR The domain Echo is reserved by PHP. 158 | ``` -------------------------------------------------------------------------------- /docs/unit-tests.md: -------------------------------------------------------------------------------- 1 | # Unit Tests 2 | 3 | [Back to README](./../README.md) 4 | 5 | The command can generate automatically PHPUnit test for the domain, that will cover create / update / delete events. 6 | 7 | ## Table of Contents 8 | 9 | - [Basic example](#basic-example) 10 | - [Advanced example: generate domain using existing migration, failed events, notifications and PHPUnit tests](#advanced-example-generate-domain-using-existing-migration-failed-events-notifications-and-phpunit-tests-) 11 | 12 | ## Basic example 13 | 14 | [⬆️ Go to TOC](#table-of-contents) 15 | 16 | ```shell 17 | php artisan make:event-sourcing-domain Animal --unit-test 18 | ``` 19 | 20 | This setup will create a PHPUnit test, already working for create / update / delete events. 21 | 22 | ``` 23 | tests 24 | ├── Unit 25 | │ └── Domain 26 | │ └── Animal 27 | │ └── AnimalTest.php 28 | └── etc. 29 | ``` 30 | 31 | ## Advanced example: generate domain using existing migration, failed events, notifications and PHPUnit tests 32 | 33 | [⬆️ Go to TOC](#table-of-contents) 34 | 35 | Command can generate a full domain directory structure starting from an existing migration. 36 | 37 | **Important: the command can process _only_ "create" migrations. Other migrations that modify table structure will be 38 | skipped.** 39 | 40 | E.g. migration `2024_10_01_112344_create_tigers_table.php` 41 | 42 | ```php 43 | return new class extends Migration 44 | { 45 | /** 46 | * Run the migrations. 47 | */ 48 | public function up(): void 49 | { 50 | Schema::create('tigers', function (Blueprint $table) { 51 | $table->bigIncrements('id'); 52 | $table->string('name')->index(); 53 | $table->int('age'); 54 | $table->json('meta'); 55 | $table->timestamps(); 56 | }); 57 | } 58 | 59 | // etc. 60 | }; 61 | ``` 62 | 63 | In this example, `id` will be used as primary key. No aggregate will be available. 64 | 65 | It is possible to specify the migration interactively or, more efficiently, passing it to command options. Please notice 66 | that the migration filename timestamp is not needed: 67 | 68 | ```shell 69 | php artisan make:event-sourcing-domain Tiger --domain=Animal --migration=create_tigers_table --notifications=slack --failed-events=1 --reactor=0 --unit-test 70 | ``` 71 | 72 | ``` 73 | Your choices: 74 | 75 | | Option | Choice | 76 | |----------------------------|--------------------------------------------| 77 | | Model | Tiger | 78 | | Domain | Animal | 79 | | Namespace | Domain | 80 | | Use migration | 2024_10_01_112344_create_animals_table.php | 81 | | Primary key | id | 82 | | Create Aggregate class | no | 83 | | Create Reactor class | no | 84 | | Create PHPUnit tests | yes | 85 | | Create failed events | yes | 86 | | Model properties | string name | 87 | | | int age | 88 | | | array meta | 89 | | Notifications | yes | 90 | 91 | Do you confirm the generation of the domain? 92 | > yes 93 | 94 | Domain [Animal] with model [Tiger] created successfully. 95 | ``` 96 | 97 | Directory structure generated (using `id` as primary key) 98 | 99 | ``` 100 | app/ 101 | ├── Domain/ 102 | │ └── Animal/ 103 | │ ├── Actions/ 104 | │ │ ├── CreateTiger 105 | │ │ ├── DeleteTiger 106 | │ │ └── UpdateTiger 107 | │ ├── DataTransferObjects/ 108 | │ │ └── TigerData 109 | │ ├── Events/ 110 | │ │ ├── TigerCreated 111 | │ │ ├── TigerCreationFailed 112 | │ │ ├── TigerDeleted 113 | │ │ ├── TigerDeletionFailed 114 | │ │ ├── TigerUpdateFailed 115 | │ │ └── TigerUpdated 116 | │ ├── Notifications/ 117 | │ │ ├── Concerns/ 118 | │ │ │ ├── HasDataAsArray 119 | │ │ │ └── HasSlackNotification 120 | │ │ ├── TigerCreated 121 | │ │ ├── TigerCreationFailed 122 | │ │ ├── TigerDeleted 123 | │ │ ├── TigerDeletionFailed 124 | │ │ ├── TigerUpdateFailed 125 | │ │ └── TigerUpdated 126 | │ ├── Projections/ 127 | │ │ └── Tiger 128 | │ └── Projectors/ 129 | │ └── TigerProjector 130 | └── etc. 131 | 132 | tests/ 133 | ├── Unit/ 134 | │ └── Domain/ 135 | │ └── Animal/ 136 | │ └── TigerTest.php 137 | └── etc. 138 | ``` 139 | 140 | If Spatie event sourcing is configured to auto-discover projectors, that is immediately usable: 141 | 142 | ```php 143 | use App\Domain\Animal\Actions\CreateTiger; 144 | use App\Domain\Animal\DataTransferObjects\TigerData; 145 | use App\Domain\Animal\Projections\Tiger; 146 | 147 | # This will create a record in 'tigers' table, using projector TigerProjector 148 | (new CreateTiger())(new TigerData( 149 | name: 'tiger', 150 | age: 7, 151 | meta: [] 152 | )); 153 | 154 | # Retrieve record 155 | $tiger = Tiger::query()->where('name', 'tiger')->first(); 156 | ``` -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src/ 8 | - tests/ 9 | 10 | # Level 9 is the highest level 11 | level: 5 12 | 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests 12 | 13 | 14 | 15 | 16 | ./src 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "simplified_null_return": true, 5 | "braces": false, 6 | "new_with_braces": { 7 | "anonymous_class": false, 8 | "named_class": false 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Domain/Blueprint/Concerns/HasBlueprintColumnType.php: -------------------------------------------------------------------------------- 1 | 'bool', 11 | 'bigIncrements', 'bigInteger', 'foreignId', 'id', 'increments', 'integer', 'mediumIncrements', 'mediumInteger', 'smallIncrements', 'smallInteger', 'tinyIncrements', 'tinyInteger', 'unsignedBigInteger', 'unsignedInteger', 'unsignedMediumInteger', 'unsignedSmallInteger', 'unsignedTinyInteger', 'year' => 'int', 12 | 'decimal', 'double' => 'float', 13 | 'json', 'jsonb' => 'array', 14 | 'dateTimeTz', 'dateTime', 'softDeletesTz', 'softDeletes', 'timestampTz', 'timestamp', 'timestampsTz', 'timestamps' => 'Carbon', 15 | 'nullableTimestamps' => '?Carbon', 16 | 'date' => 'Carbon:Y-m-d', 17 | 'timeTz', 'time' => 'Carbon:H:i:s', 18 | 'char', 'enum', 'foreignUuid', 'ipAddress', 'longText', 'macAddress', 'mediumText', 'rememberToken', 'text', 'tinyText', 'uuid' => 'string', 19 | default => $type 20 | }; 21 | } 22 | 23 | protected function carbonToBuiltInType(string $type): string 24 | { 25 | return match ($type) { 26 | 'Carbon', '?Carbon' => 'date:Y-m-d H:i:s', 27 | 'Carbon:Y-m-d' => 'date:Y-m-d', 28 | 'Carbon:H:i:s' => 'date:H:i:s', 29 | default => $type 30 | }; 31 | } 32 | 33 | protected function normaliseCarbon(string $type): string 34 | { 35 | return preg_replace('/Carbon:.*/i', 'Carbon', $type); 36 | } 37 | 38 | protected function builtInTypeToColumnType(string $type): string 39 | { 40 | return match ($type) { 41 | 'bool' => 'boolean', 42 | 'int' => 'integer', 43 | 'float' => 'double', 44 | default => $type 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Domain/Blueprint/Concerns/HasBlueprintFake.php: -------------------------------------------------------------------------------- 1 | 'boolean', 11 | 'int' => 'randomNumber()', 12 | 'float' => 'randomNumber(2)', 13 | 'array' => 'randomElements', 14 | 'Carbon' => 'Carbon::parse($this->faker->date())', 15 | '?Carbon' => 'Carbon::parse($this->faker->date())', 16 | 'Carbon:Y-m-d' => 'Carbon::parse($this->faker->date())', 17 | 'Carbon:H:i:s' => 'Carbon::parse($this->faker->date())', 18 | 'string' => 'word', 19 | ]; 20 | 21 | protected function blueprintToFakeFunction(string $type): string 22 | { 23 | $fakeFunction = self::BLUEPRINT_TO_FAKE[$type] ?? 'word'; 24 | if (! Str::endsWith($fakeFunction, ')')) { 25 | $fakeFunction .= '()'; 26 | } 27 | 28 | return Str::startsWith($fakeFunction, 'Carbon') ? $fakeFunction : "\$this->faker->$fakeFunction"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Domain/Blueprint/Contracts/BlueprintUnsupportedInterface.php: -------------------------------------------------------------------------------- 1 | makeDirectory($this->settings->domainPath.'Actions/*'); 13 | $this->makeDirectory($this->settings->domainPath.'DataTransferObjects/*'); 14 | $this->makeDirectory($this->settings->domainPath.'Events/*'); 15 | $this->makeDirectory($this->settings->domainPath.'Projections/*'); 16 | $this->makeDirectory($this->settings->domainPath.'Projectors/*'); 17 | 18 | if ($this->settings->createAggregate) { 19 | $this->makeDirectory($this->settings->domainPath.'Aggregates/*'); 20 | } 21 | 22 | if ($this->settings->createReactor) { 23 | $this->makeDirectory($this->settings->domainPath.'Reactors/*'); 24 | } 25 | 26 | if ($this->settings->notifications) { 27 | $this->makeDirectory($this->settings->domainPath.'Notifications/*'); 28 | $this->makeDirectory($this->settings->domainPath.'Notifications/Concerns/*'); 29 | } 30 | 31 | if ($this->settings->createUnitTest) { 32 | $this->makeDirectory($this->settings->testDomainPath.'/*'); 33 | } 34 | 35 | return $this; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Domain/Command/Contracts/AcceptedNotificationInterface.php: -------------------------------------------------------------------------------- 1 | indentSpace = Str::repeat(' ', $this->indentation); 39 | $this->modelProperties = new MigrationCreateProperties($modelProperties); 40 | $this->ignoredProperties = new MigrationCreateProperties($ignoredProperties); 41 | $this->inferUseCarbon(); 42 | } 43 | 44 | public function primaryKey(): string 45 | { 46 | return $this->useUuid ? 'uuid' : 'id'; 47 | } 48 | 49 | public function inferUseCarbon(): void 50 | { 51 | foreach ($this->modelProperties->toArray() as $property) { 52 | if ($property->type->isCarbon() && $property->name !== 'timestamps') { 53 | $this->useCarbon = true; 54 | break; 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Domain/Migrations/Migration.php: -------------------------------------------------------------------------------- 1 | primary = null; 33 | $this->properties = new MigrationCreateProperties; 34 | $this->ignored = new MigrationCreateProperties; 35 | $this->migrations = []; 36 | $this->parse(); 37 | } 38 | 39 | /** 40 | * @throws MigrationDoesNotExistException 41 | * @throws MigrationInvalidException 42 | */ 43 | protected function getMigrations(): array 44 | { 45 | $this->migrations = []; 46 | $found = []; 47 | if (File::exists($this->path)) { 48 | $this->migrations = [basename($this->path)]; 49 | $found = [File::get($this->path)]; 50 | } else { 51 | if (in_array(strtolower($this->path), ['create', 'update'])) { 52 | throw new MigrationInvalidException; 53 | } 54 | 55 | $files = File::files(database_path('migrations')); 56 | foreach ($files as $file) { 57 | $filename = $file->getFilename(); 58 | 59 | if ($this->excludePath) { 60 | // Check if regex 61 | if (@preg_match($this->excludePath, '') !== false && preg_match($this->excludePath, $filename)) { 62 | continue; 63 | } elseif (Str::contains($filename, $this->excludePath)) { 64 | continue; 65 | } 66 | } 67 | 68 | if (Str::contains($filename, $this->path)) { 69 | $this->migrations[] = basename($filename); 70 | $found[] = $file->getContents(); 71 | } 72 | } 73 | } 74 | 75 | if (! $found) { 76 | throw new MigrationDoesNotExistException; 77 | } 78 | 79 | return $found; 80 | } 81 | 82 | /** 83 | * @throws MigrationDoesNotExistException 84 | * @throws ParserFailedException 85 | * @throws MigrationInvalidException 86 | */ 87 | protected function parse(): void 88 | { 89 | $found = $this->getMigrations(); 90 | foreach ($found as $migration) { 91 | $parser = (new MigrationParser($migration))->parse(); 92 | $this->properties->import($parser->getProperties(), reset: false); 93 | $this->ignored->import($parser->getIgnored(), reset: false); 94 | $this->primary = $this->properties->primary()->name; 95 | } 96 | } 97 | 98 | public function primary(): ?string 99 | { 100 | return $this->primary; 101 | } 102 | 103 | /** 104 | * @return MigrationCreateProperty[] 105 | */ 106 | public function properties(): array 107 | { 108 | return $this->properties->toArray(); 109 | } 110 | 111 | /** 112 | * @return MigrationCreateProperty[] 113 | */ 114 | public function ignored(): array 115 | { 116 | return $this->ignored->withoutSkippedMethods()->toArray(); 117 | } 118 | 119 | public function migrations(): array 120 | { 121 | return $this->migrations; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Concerns/HasMethodNode.php: -------------------------------------------------------------------------------- 1 | getMethod($method); 15 | if (! $currentMethod) { 16 | return $node; 17 | } 18 | 19 | /** @var Node\Stmt\Expression $expression */ 20 | $expression = $currentMethod->getStmts()[0] ?? null; 21 | if (! $expression instanceof Node\Stmt\Expression) { 22 | return $node; 23 | } 24 | 25 | ($enterNode->onEnter)($expression); 26 | 27 | return $node; 28 | } 29 | 30 | if ($enterNode->afterEnter) { 31 | ($enterNode->afterEnter)($node); 32 | } 33 | 34 | return $node; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Concerns/HasSchemaUpNode.php: -------------------------------------------------------------------------------- 1 | enterMethodNode($node, 'up', $enterNode); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/MigrationParser.php: -------------------------------------------------------------------------------- 1 | properties = []; 21 | $this->ignored = []; 22 | } 23 | 24 | protected function getTraverser(): NodeTraverser 25 | { 26 | $traverser = new NodeTraverser; 27 | $traverser->addVisitor( 28 | new BlueprintClassNodeVisitor($this->properties, $this->ignored) 29 | ); 30 | 31 | return $traverser; 32 | } 33 | 34 | /** 35 | * @throws ParserFailedException 36 | */ 37 | protected function getStatements(): ?array 38 | { 39 | $parser = (new ParserFactory)->createForNewestSupportedVersion(); 40 | try { 41 | return $parser->parse($this->migrationContent); 42 | } catch (Error $error) { 43 | throw new ParserFailedException($error->getMessage()); 44 | } 45 | } 46 | 47 | /** 48 | * @throws ParserFailedException 49 | */ 50 | public function parse(): self 51 | { 52 | $this->getTraverser()->traverse($this->getStatements()); 53 | 54 | return $this; 55 | } 56 | 57 | public function getProperties(): array 58 | { 59 | return array_values($this->properties); 60 | } 61 | 62 | public function getIgnored(): array 63 | { 64 | return array_values($this->ignored); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Models/EnterNode.php: -------------------------------------------------------------------------------- 1 | collection = new Collection; 21 | $this->rejected = new Collection; 22 | 23 | // Import existing collection 24 | if ($collection) { 25 | $this->import($collection); 26 | } 27 | } 28 | 29 | public function add($property, bool $overwriteIfExists = false): self 30 | { 31 | // Check if property type is correct and if it already exists 32 | if ($property instanceof MigrationCreateProperty) { 33 | $exists = $this->collection->offsetExists($property->name); 34 | if ($exists && ! $overwriteIfExists) { 35 | return $this; 36 | } 37 | 38 | if ($exists) { 39 | /** @var MigrationCreateProperty $existing */ 40 | $existing = $this->collection->offsetGet($property->name); 41 | if ($property->type->isDropped) { 42 | // Remove item 43 | $this->collection->offsetUnset($property->name); 44 | 45 | return $this; 46 | } elseif ($property->type->renameTo) { 47 | // Rename item 48 | $existing->name = $property->type->renameTo; 49 | $this->collection->offsetSet($existing->name, $existing); 50 | $this->collection->offsetUnset($property->name); 51 | 52 | return $this; 53 | } 54 | } 55 | 56 | $this->collection->offsetSet($property->name, $property); 57 | } 58 | 59 | return $this; 60 | } 61 | 62 | public function primary(): MigrationCreateProperty 63 | { 64 | return $this->collection->where( 65 | fn (MigrationCreateProperty $property) => in_array($property->name, self::PRIMARY_KEY) 66 | )[0] ?? $this->collection->first(); 67 | } 68 | 69 | public function import(array|Collection $modelProperties, bool $reset = true): self 70 | { 71 | if ($reset) { 72 | $this->collection = new Collection; 73 | } 74 | foreach ($modelProperties as $name => $typeOrProperty) { 75 | if ($typeOrProperty instanceof MigrationCreateProperty) { 76 | $this->add($typeOrProperty, true); 77 | } else { 78 | $this->add(new MigrationCreateProperty( 79 | name: $name, 80 | type: $typeOrProperty, 81 | ), true); 82 | } 83 | } 84 | 85 | return $this; 86 | } 87 | 88 | public function withoutReservedFields(): self 89 | { 90 | return new self($this->collection->except(self::RESERVED_FIELDS)); 91 | } 92 | 93 | public function withoutSkippedMethods(): self 94 | { 95 | return new self($this->collection->except(BlueprintUnsupportedInterface::SKIPPED_METHODS)); 96 | } 97 | 98 | /** 99 | * @return MigrationCreateProperty[] 100 | */ 101 | public function toArray(): array 102 | { 103 | return $this->collection->toArray(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Models/MigrationCreateProperty.php: -------------------------------------------------------------------------------- 1 | type = $type instanceof MigrationCreatePropertyType ? 22 | $type : 23 | new MigrationCreatePropertyType( 24 | type: $type, 25 | isIgnored: in_array($name, BlueprintUnsupportedInterface::IGNORED) 26 | ); 27 | 28 | if (! $this->name) { 29 | $this->name = $this->type->type; 30 | $this->type->setAsBuiltInType(); 31 | } 32 | } 33 | 34 | protected static function getArrayExprValues(Node\Expr\Array_ $value): array 35 | { 36 | $ret = []; 37 | foreach ($value->items as $item) { 38 | if ($item->value instanceof Node\Scalar\String_) { 39 | $ret[] = $item->value->value; 40 | } 41 | } 42 | 43 | return $ret; 44 | } 45 | 46 | protected static function exprMethodCallToTypeArgs(Node\Expr\MethodCall $expr, bool $nullable = false): array 47 | { 48 | if ($expr->var instanceof Node\Expr\MethodCall) { 49 | return self::exprMethodCallToTypeArgs($expr->var, $expr->name->name === 'nullable'); 50 | } 51 | 52 | $type = $expr->name->name; 53 | $args = array_filter( 54 | Arr::map( 55 | $expr->args ?? [], 56 | fn (Node\Arg $arg) => $arg->value instanceof Node\Scalar\String_ ? 57 | $arg->value->value : 58 | ( 59 | $arg->value instanceof Node\Expr\Array_ ? 60 | self::getArrayExprValues($arg->value) : 61 | null 62 | ) 63 | ) 64 | ); 65 | 66 | return [$type, $args, $nullable]; 67 | } 68 | 69 | /** 70 | * @return MigrationCreateProperty[] 71 | * 72 | * @throws MigrationInvalidPrimaryKeyException 73 | */ 74 | public static function createPropertiesFromExprMethodCall(Node\Expr\MethodCall $expr): array 75 | { 76 | $warning = null; 77 | $droppedColumns = []; 78 | $renameTo = null; 79 | [$type, $args, $nullable] = self::exprMethodCallToTypeArgs($expr); 80 | 81 | if (! $args) { 82 | $name = ''; 83 | } else { 84 | $name = $args[0]; 85 | } 86 | 87 | // If $table->uuid('id') is used, cannot parse migration 88 | if ($type === 'uuid' && $name === 'id') { 89 | // Bad setup, cannot parse migration 90 | throw new MigrationInvalidPrimaryKeyException; 91 | } elseif ($type === 'primary') { 92 | // Auto-adjust primary but store warning 93 | $name = 'id'; 94 | $type = 'integer'; 95 | $first = Arr::first($expr->args); 96 | if ($first && $first->value instanceof Node\Expr\Array_) { 97 | $warning = 'Composite keys are not supported for primary key'; 98 | } else { 99 | $warning = 'Type not supported for primary key'; 100 | } 101 | } elseif ($type === 'dropColumn') { 102 | $droppedColumns = is_array($args[0]) ? $args[0] : $args; 103 | } elseif ($type === 'renameColumn') { 104 | $renameTo = $args[1]; 105 | } 106 | 107 | if ($droppedColumns) { 108 | return Arr::map( 109 | $droppedColumns, 110 | fn ($dropColumn) => new self( 111 | $dropColumn, 112 | new MigrationCreatePropertyType( 113 | type: $type, 114 | nullable: $nullable, 115 | isIgnored: in_array($dropColumn, BlueprintUnsupportedInterface::IGNORED), 116 | warning: $warning, 117 | isDropped: true, 118 | renameTo: $renameTo 119 | ), 120 | ) 121 | ); 122 | } 123 | 124 | return [new self( 125 | $name, 126 | new MigrationCreatePropertyType( 127 | type: $type, 128 | nullable: $nullable, 129 | isIgnored: in_array($name, BlueprintUnsupportedInterface::IGNORED), 130 | warning: $warning, 131 | renameTo: $renameTo 132 | ), 133 | )]; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Models/MigrationCreatePropertyType.php: -------------------------------------------------------------------------------- 1 | nullable = false; 29 | if ($nullable) { 30 | $this->nullable = $nullable; 31 | } else { 32 | // Check if built-in type is nullable (e.g. nullableTimestamps) 33 | if (Str::startsWith($this->columnTypeToBuiltInType($type), '?')) { 34 | $this->nullable = true; 35 | } 36 | } 37 | 38 | $this->isIgnored = $isIgnored || in_array($this->type, BlueprintUnsupportedInterface::IGNORED); 39 | $this->isSkipped = $this->isIgnored && in_array($this->type, BlueprintUnsupportedInterface::SKIPPED_METHODS); 40 | } 41 | 42 | public function setAsBuiltInType(): void 43 | { 44 | $this->type = $this->columnTypeToBuiltInType($this->type); 45 | } 46 | 47 | public function toBuiltInType(): string 48 | { 49 | return (new Onion([ 50 | fn ($type) => $this->columnTypeToBuiltInType($type), 51 | fn ($type) => Str::replaceFirst('?', '', $type), 52 | ]))->peel($this->type); 53 | } 54 | 55 | public function toNormalisedBuiltInType(): string 56 | { 57 | return (new Onion([ 58 | fn ($type) => $this->toBuiltInType(), 59 | fn ($type) => $this->normaliseCarbon($type), 60 | ]))->peel($this->type); 61 | } 62 | 63 | public function toProjection(): string 64 | { 65 | return (new Onion([ 66 | fn ($type) => $this->columnTypeToBuiltInType($type), 67 | fn ($type) => $this->carbonToBuiltInType($type), 68 | fn ($type) => Str::replaceFirst('?', '', $type), 69 | ]))->peel($this->type); 70 | } 71 | 72 | public function isCarbon(): bool 73 | { 74 | return Str::startsWith('Carbon', $this->columnTypeToBuiltInType($this->type)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Domain/PhpParser/Traversers/BlueprintClassNodeVisitor.php: -------------------------------------------------------------------------------- 1 | enterSchemaUpNode( 28 | $node, 29 | new EnterNode( 30 | function (Node\Stmt\Expression $expression) { 31 | // Nope 32 | }, 33 | function (Node $node) { 34 | if ($node instanceof Node\Stmt\ClassMethod) { 35 | $this->currentMethod = $node->name->name; 36 | } 37 | 38 | if ($node instanceof Node\Stmt\Expression && $this->currentMethod === 'up') { 39 | // Collect properties from Schema::up method 40 | if ($node->expr instanceof Node\Expr\MethodCall) { 41 | foreach (MigrationCreateProperty::createPropertiesFromExprMethodCall($node->expr) as $property) { 42 | if ($property->type->isDropped) { 43 | $this->properties[$property->name] = $property; 44 | } elseif ($property->type->renameTo) { 45 | $this->properties[$property->name] = $property; 46 | } elseif (! $property->type->isIgnored) { 47 | $this->properties[$property->name] = $property; 48 | } elseif (! $property->type->isSkipped) { 49 | $this->ignored[$property->name] = $property; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | ) 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Domain/Stubs/Models/StubCallback.php: -------------------------------------------------------------------------------- 1 | closure, $stubPath, $outputPath); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Domain/Stubs/StubResolver.php: -------------------------------------------------------------------------------- 1 | path, '.stub')) { 16 | $this->path .= '.stub'; 17 | } 18 | } 19 | 20 | protected function resolvePath(Application $laravel): string 21 | { 22 | return file_exists($customPath = $laravel->basePath(trim($this->path, '/'))) 23 | ? $customPath 24 | : realpath(__DIR__.'/../../../'.$this->path); 25 | } 26 | 27 | protected function resolveOutputPath( 28 | CommandSettings $settings 29 | ): string { 30 | return Str::replace( 31 | [ 32 | '{{path}}', '{{ path }}', 33 | '{{name}}', '{{ name }}', 34 | '{{test_path}}', '{{ test_path }}', 35 | '{{namespace}}', '{{ namespace }}', 36 | '//', 37 | ], 38 | [ 39 | $settings->domainPath, $settings->domainPath, 40 | $settings->model, $settings->model, 41 | $settings->testDomainPath, $settings->testDomainPath, 42 | $settings->namespacePath, $settings->namespacePath, 43 | '/'], 44 | $this->resolverPattern 45 | ); 46 | } 47 | 48 | public function resolve( 49 | Application $laravel, 50 | CommandSettings $settings, 51 | ): array { 52 | return [ 53 | $this->resolvePath($laravel), 54 | $this->resolveOutputPath($settings), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Domain/Stubs/Stubs.php: -------------------------------------------------------------------------------- 1 | availableStubs = []; 20 | } 21 | 22 | protected function resolvePath($path): string 23 | { 24 | return file_exists($customPath = $this->laravel->basePath(trim($path, '/'))) 25 | ? $customPath 26 | : realpath(__DIR__.'/../../../'.$path); 27 | } 28 | 29 | protected function getAvailableStubs(): array 30 | { 31 | if (! $this->availableStubs) { 32 | $this->availableStubs = File::json($this->resolvePath('stubs/stub-mapping.json')); 33 | } 34 | 35 | return $this->availableStubs; 36 | } 37 | 38 | /** 39 | * Get stub resolvers, based on domain settings 40 | */ 41 | protected function getStubResolvers(): array 42 | { 43 | return array_filter(array_map(function ($stubResolverData) { 44 | $context = $stubResolverData['context'] ?? null; 45 | 46 | // Unit tests 47 | $test = $context['unit-test'] ?? null; 48 | if (! is_null($test) && ! $this->settings->createUnitTest) { 49 | return false; 50 | } 51 | 52 | // Failed events 53 | $failedEvent = $context['failed-events'] ?? null; 54 | if (! is_null($failedEvent) && ! $this->settings->createFailedEvents) { 55 | return false; 56 | } 57 | 58 | // Notifications 59 | $notifications = $context['notifications'] ?? null; 60 | if (! is_null($notifications)) { 61 | if (! $this->settings->notifications) { 62 | return false; 63 | } elseif (is_array($notifications) && ! array_intersect($notifications, $this->settings->notifications)) { 64 | return false; 65 | } 66 | } 67 | 68 | // Reactor 69 | $reactor = $context['reactor'] ?? null; 70 | if (! is_null($reactor) && $this->settings->createReactor !== $reactor) { 71 | return false; 72 | } 73 | 74 | // Aggregate root 75 | $aggregate = $context['aggregate'] ?? null; 76 | if (! is_null($aggregate) && $this->settings->createAggregate !== $aggregate) { 77 | return false; 78 | } 79 | 80 | return new StubResolver($stubResolverData['stub'], $stubResolverData['output']); 81 | }, $this->getAvailableStubs())); 82 | } 83 | 84 | /** 85 | * Resolve paths of stub and output 86 | * 87 | * @throws FileNotFoundException 88 | */ 89 | public function resolve(StubCallback $callback): void 90 | { 91 | /** @var StubResolver $stubResolver */ 92 | foreach ($this->getStubResolvers() as $stubResolver) { 93 | $callback->call( 94 | ...$stubResolver->resolve($this->laravel, $this->settings) 95 | ); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Exceptions/MigrationDoesNotExistException.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->commands( 14 | commands: [ 15 | MakeEventSourcingDomainCommand::class, 16 | ], 17 | ); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stubs/actions.create.with-aggregate.stub: -------------------------------------------------------------------------------- 1 | toString(); 17 | 18 | {{ class }}Aggregate::retrieve($uuid) 19 | ->create(${{ id }}Data) 20 | ->persist(); 21 | {% endif %} 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stubs/actions.create.without-aggregate.stub: -------------------------------------------------------------------------------- 1 | toString(), 18 | {% endif %} 19 | {{ id }}Data: ${{ id }}Data, 20 | createdAt: now(), 21 | )); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stubs/actions.delete.with-aggregate.stub: -------------------------------------------------------------------------------- 1 | remove() 13 | ->persist(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /stubs/actions.delete.without-aggregate.stub: -------------------------------------------------------------------------------- 1 | update(${{ id }}Data) 14 | ->persist(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /stubs/actions.update.without-aggregate.stub: -------------------------------------------------------------------------------- 1 | recordThat(new {{ class }}Created( 16 | {{ id }}{{ primary_key:uppercase }}: $this->uuid(), 17 | {{ id }}Data: ${{ id }}Data, 18 | createdAt: now(), 19 | )); 20 | 21 | return $this; 22 | } 23 | 24 | public function update({{ class }}Data ${{ id }}Data): self 25 | { 26 | $this->recordThat(new {{ class }}Updated( 27 | {{ id }}{{ primary_key:uppercase }}: $this->uuid(), 28 | {{ id }}Data: ${{ id }}Data, 29 | createdAt: now(), 30 | )); 31 | 32 | return $this; 33 | } 34 | 35 | public function remove(): self 36 | { 37 | $this->recordThat(new {{ class }}Deleted( 38 | {{ id }}{{ primary_key:uppercase }}: $this->uuid(), 39 | createdAt: now(), 40 | )); 41 | 42 | return $this; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /stubs/data-transfer-object.stub: -------------------------------------------------------------------------------- 1 | {{ id }}Data instanceof {{ class }}Data ? $this->{{ id }}Data->toArray() : $this->{{ id }}Data; 12 | } 13 | } -------------------------------------------------------------------------------- /stubs/notifications.concerns.has_microsoft_teams_notification.stub: -------------------------------------------------------------------------------- 1 | getDataAsArray()); 18 | 19 | return $message.'

'. 20 | implode('
', Arr::map( 21 | $data, 22 | fn ($value, $key) => sprintf('_%s:_ %s', ucfirst($key), $value) 23 | )); 24 | } 25 | } -------------------------------------------------------------------------------- /stubs/notifications.concerns.has_slack_notification.stub: -------------------------------------------------------------------------------- 1 | function (SectionBlock $block) use ($primaryKey, $fields) { 15 | if ($primaryKey) { 16 | $block->field("*uuid:* $this->{{ id }}{{ primary_key:uppercase }}")->markdown(); 17 | } 18 | if ($fields) { 19 | foreach ($this->getDataAsArray() as $key => $value) { 20 | $block->field("*$key:* $value")->markdown(); 21 | } 22 | } 23 | }; 24 | } 25 | } -------------------------------------------------------------------------------- /stubs/notifications.created.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 63 | ->subject('{{ class }} created successfully') 64 | ->greeting('Hello,') 65 | ->line('A new {{ class }} has been created') 66 | ->lines(Arr::map($this->getDataAsArray(), function ($value, $key) { 67 | return '- '.$key.': '.$value; 68 | })); 69 | } 70 | {% endif %} 71 | 72 | {% if notifications.teams %} 73 | /** 74 | * @throws CouldNotSendNotification 75 | */ 76 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 77 | { 78 | return MicrosoftTeamsMessage::create() 79 | ->to(config('services.microsoft_teams.webhook_url')) 80 | ->type('success') 81 | ->title('{{ class }} created successfully') 82 | ->content( 83 | $this->getMicrosoftTeamsContent('A new {{ id }} has been created') 84 | ); 85 | } 86 | {% endif %} 87 | 88 | {% if notifications.slack %} 89 | public function toSlack($notifiable): SlackMessage 90 | { 91 | return (new SlackMessage) 92 | ->text('A new {{ class }} has been created') 93 | ->headerBlock('A new {{ class }} has been created') 94 | ->sectionBlock($this->getSlackSectionBlock(fields: true)); 95 | } 96 | {% endif %} 97 | 98 | {% if notifications.database %} 99 | public function toArray($notifiable): array 100 | { 101 | return array_merge( 102 | [ 103 | 'notification_message' => '{{ class }} created successfully', 104 | ], 105 | $this->getDataAsArray() 106 | ); 107 | } 108 | {% endif %} 109 | } 110 | -------------------------------------------------------------------------------- /stubs/notifications.creation_failed.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 64 | ->subject('{{ class }} creation failed') 65 | ->greeting('Hello,') 66 | ->line('{{ class }} creation failed') 67 | ->lines(Arr::map($this->getDataAsArray(), function ($value, $key) { 68 | return '- '.$key.': '.$value; 69 | })); 70 | } 71 | {% endif %} 72 | 73 | {% if notifications.teams %} 74 | /** 75 | * @throws CouldNotSendNotification 76 | */ 77 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 78 | { 79 | return MicrosoftTeamsMessage::create() 80 | ->to(config('services.microsoft_teams.webhook_url')) 81 | ->type('error') 82 | ->title('{{ class }} creation failed') 83 | ->content( 84 | $this->getMicrosoftTeamsContent('{{ class }} creation failed') 85 | ); 86 | } 87 | {% endif %} 88 | 89 | {% if notifications.slack %} 90 | public function toSlack($notifiable): SlackMessage 91 | { 92 | return (new SlackMessage) 93 | ->text('{{ class }} creation failed') 94 | ->headerBlock('{{ class }} creation failed') 95 | ->sectionBlock(function (SectionBlock $block) { 96 | $block->text($this->failure); 97 | }) 98 | ->sectionBlock($this->getSlackSectionBlock(fields: true)); 99 | } 100 | {% endif %} 101 | 102 | {% if notifications.database %} 103 | public function toArray($notifiable): array 104 | { 105 | return array_merge( 106 | [ 107 | 'notification_message' => '{{ class }} creation failed', 108 | ], 109 | $this->getDataAsArray() 110 | ); 111 | } 112 | {% endif %} 113 | } 114 | -------------------------------------------------------------------------------- /stubs/notifications.deleted.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 61 | ->subject('{{ class }} deleted successfully') 62 | ->greeting('Hello,') 63 | ->line('A new {{ class }} has been deleted') 64 | ->line('- {{ primary_key }}: '.$this->{{ id }}{{ primary_key:uppercase }}); 65 | } 66 | {% endif %} 67 | 68 | {% if notifications.teams %} 69 | /** 70 | * @throws CouldNotSendNotification 71 | */ 72 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 73 | { 74 | return MicrosoftTeamsMessage::create() 75 | ->to(config('services.microsoft_teams.webhook_url')) 76 | ->type('success') 77 | ->title('{{ class }} deleted successfully') 78 | ->content( 79 | $this->getMicrosoftTeamsContent('A new {{ class }} has been deleted', '{{ primary_key }}', $this->{{ id }}{{ primary_key:uppercase }}) 80 | ); 81 | } 82 | {% endif %} 83 | 84 | {% if notifications.slack %} 85 | public function toSlack($notifiable): SlackMessage 86 | { 87 | return (new SlackMessage) 88 | ->text('A new {{ class }} has been deleted') 89 | ->headerBlock('A new {{ class }} has been deleted') 90 | ->sectionBlock($this->getSlackSectionBlock(primaryKey: true)); 91 | } 92 | {% endif %} 93 | 94 | {% if notifications.database %} 95 | public function toArray($notifiable): array 96 | { 97 | return array_merge( 98 | [ 99 | 'notification_message' => '{{ class }} deleted successfully', 100 | ], 101 | $this->getDataAsArray() 102 | ); 103 | } 104 | {% endif %} 105 | } 106 | -------------------------------------------------------------------------------- /stubs/notifications.deletion_failed.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 62 | ->subject('{{ class }} deletion failed') 63 | ->greeting('Hello,') 64 | ->line('{{ class }} deletion created') 65 | ->line('- {{ primary_key }}: '.$this->{{ id }}{{ primary_key:uppercase }}); 66 | } 67 | {% endif %} 68 | 69 | {% if notifications.teams %} 70 | /** 71 | * @throws CouldNotSendNotification 72 | */ 73 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 74 | { 75 | return MicrosoftTeamsMessage::create() 76 | ->to(config('services.microsoft_teams.webhook_url')) 77 | ->type('error') 78 | ->title('{{ class }} deletion failed') 79 | ->content( 80 | $this->getMicrosoftTeamsContent('{{ class }} deletion failed', '{{ primary_key }}', $this->{{ id }}{{ primary_key:uppercase }}) 81 | ); 82 | } 83 | {% endif %} 84 | 85 | {% if notifications.slack %} 86 | public function toSlack($notifiable): SlackMessage 87 | { 88 | return (new SlackMessage) 89 | ->text('A new {{ class }} has been deleted') 90 | ->headerBlock('A new {{ class }} has been deleted') 91 | ->sectionBlock(function (SectionBlock $block) { 92 | $block->text($this->failure); 93 | }) 94 | ->sectionBlock($this->getSlackSectionBlock(primaryKey: true)); 95 | } 96 | {% endif %} 97 | 98 | {% if notifications.database %} 99 | public function toArray($notifiable): array 100 | { 101 | return array_merge( 102 | [ 103 | 'notification_message' => '{{ class }} deletion failed', 104 | ], 105 | $this->getDataAsArray() 106 | ); 107 | } 108 | {% endif %} 109 | } 110 | -------------------------------------------------------------------------------- /stubs/notifications.update_failed.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 65 | ->subject('{{ class }} update failed') 66 | ->greeting('Hello,') 67 | ->line('{{ class }} update failed') 68 | ->line('- {{ primary_key }}: '.$this->{{ id }}{{ primary_key:uppercase }}) 69 | ->lines(Arr::map($this->getDataAsArray(), function ($value, $key) { 70 | return '- '.$key.': '.$value; 71 | })); 72 | } 73 | {% endif %} 74 | 75 | {% if notifications.teams %} 76 | /** 77 | * @throws CouldNotSendNotification 78 | */ 79 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 80 | { 81 | return MicrosoftTeamsMessage::create() 82 | ->to(config('services.microsoft_teams.webhook_url')) 83 | ->type('error') 84 | ->title('{{ class }} update failed') 85 | ->content('{{ class }} update failed'); 86 | } 87 | {% endif %} 88 | 89 | {% if notifications.slack %} 90 | public function toSlack($notifiable): SlackMessage 91 | { 92 | return (new SlackMessage) 93 | ->text('{{ class }} update failed') 94 | ->headerBlock('{{ class }} update failed') 95 | ->sectionBlock(function (SectionBlock $block) { 96 | $block->text($this->failure); 97 | }) 98 | ->sectionBlock($this->getSlackSectionBlock(primaryKey: true, fields: true)); 99 | } 100 | {% endif %} 101 | 102 | {% if notifications.database %} 103 | public function toArray($notifiable): array 104 | { 105 | return array_merge( 106 | [ 107 | 'notification_message' => '{{ class }} update failed', 108 | ], 109 | $this->getDataAsArray() 110 | ); 111 | } 112 | {% endif %} 113 | } 114 | -------------------------------------------------------------------------------- /stubs/notifications.updated.stub: -------------------------------------------------------------------------------- 1 | from(config('mail.from.address')) 64 | ->subject('{{ class }} updated successfully') 65 | ->greeting('Hello,') 66 | ->line('A new {{ class }} has been updated') 67 | ->line('- {{ primary_key }}: '.$this->{{ id }}{{ primary_key:uppercase }}) 68 | ->lines(Arr::map($this->getDataAsArray(), function ($value, $key) { 69 | return '- '.$key.': '.$value; 70 | })); 71 | } 72 | {% endif %} 73 | 74 | {% if notifications.teams %} 75 | /** 76 | * @throws CouldNotSendNotification 77 | */ 78 | public function toMicrosoftTeams($notifiable): MicrosoftTeamsMessage 79 | { 80 | return MicrosoftTeamsMessage::create() 81 | ->to(config('services.microsoft_teams.webhook_url')) 82 | ->type('success') 83 | ->title('{{ class }} updated successfully') 84 | ->content( 85 | $this->getMicrosoftTeamsContent('A new {{ class }} has been updated', '{{ primary_key }}', $this->{{ id }}{{ primary_key:uppercase }}) 86 | ); 87 | } 88 | {% endif %} 89 | 90 | {% if notifications.slack %} 91 | public function toSlack($notifiable): SlackMessage 92 | { 93 | return (new SlackMessage) 94 | ->text('A new {{ class }} has been updated') 95 | ->headerBlock('A new {{ class }} has been updated') 96 | ->sectionBlock($this->getSlackSectionBlock(primaryKey: true, fields: true)); 97 | } 98 | {% endif %} 99 | 100 | {% if notifications.database %} 101 | public function toArray($notifiable): array 102 | { 103 | return array_merge( 104 | [ 105 | 'notification_message' => '{{ class }} updated successfully', 106 | ], 107 | $this->getDataAsArray() 108 | ); 109 | } 110 | {% endif %} 111 | } 112 | -------------------------------------------------------------------------------- /stubs/projection.stub: -------------------------------------------------------------------------------- 1 | where('{{ primary_key }}', '=', ${{ primary_key }})->get()->firstOrFail(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /stubs/projector.stub: -------------------------------------------------------------------------------- 1 | writeable()->create([ 36 | {% if uuid %} 37 | '{{ primary_key }}' => $event->{{ id }}{{ primary_key:uppercase }}, 38 | {% endif %} 39 | {{ properties:projector }} 40 | ]); 41 | 42 | {% if notifications %} 43 | Notification::send(new AnonymousNotifiable, new {{ class }}CreatedNotification( 44 | {{ id }}Data: $event->{{ id }}Data->toArray() 45 | )); 46 | {% endif %} 47 | } catch (Exception $e) { 48 | {% if !failed_events %} 49 | Log::error('Unable to create {{ id }}', [ 50 | 'error' => $e->getMessage(), 51 | 'event' => $event, 52 | ]); 53 | {% endif %} 54 | 55 | {% if failed_events %} 56 | event(new {{ class }}CreationFailed( 57 | {{ id }}Data: $event->{{ id }}Data, 58 | failure: $e->getMessage(), 59 | createdAt: now() 60 | )); 61 | {% endif %} 62 | 63 | {% if notifications and !failed_events %} 64 | Notification::send(new AnonymousNotifiable, new {{ class }}CreationFailedNotification( 65 | {{ id }}Data: $event->{{ id }}Data->toArray(), 66 | failure: $e->getMessage() 67 | )); 68 | {% endif %} 69 | } 70 | } 71 | 72 | {% if failed_events %} 73 | public function on{{ class }}CreationFailed({{ class }}CreationFailed $event): void 74 | { 75 | Log::error('Unable to create {{ id }}', [ 76 | 'error' => $event->failure, 77 | 'data' => $event->{{ id }}Data, 78 | ]); 79 | {% endif %} 80 | 81 | {% if notifications and failed_events %} 82 | Notification::send(new AnonymousNotifiable, new {{ class }}CreationFailedNotification( 83 | {{ id }}Data: $event->{{ id }}Data->toArray(), 84 | failure: $event->failure 85 | )); 86 | {% endif %} 87 | {% if failed_events %} 88 | } 89 | {% endif %} 90 | 91 | public function on{{ class }}Updated({{ class }}Updated $event): void 92 | { 93 | try { 94 | ${{ id }} = {{ class }}::{{ primary_key }}($event->{{ id }}{{ primary_key:uppercase }}); 95 | 96 | ${{ id }}->writeable()->update([ 97 | {{ properties:projector }} 98 | ]); 99 | 100 | {% if notifications %} 101 | Notification::send(new AnonymousNotifiable, new {{ class }}UpdatedNotification( 102 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 103 | {{ id }}Data: $event->{{ id }}Data->toArray() 104 | )); 105 | {% endif %} 106 | } catch (Exception $e) { 107 | {% if !failed_events %} 108 | Log::error('Unable to update {{ id }}', [ 109 | 'error' => $e->getMessage(), 110 | 'event' => $event, 111 | ]); 112 | {% endif %} 113 | 114 | {% if failed_events %} 115 | event(new {{ class }}UpdateFailed( 116 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 117 | {{ id }}Data: $event->{{ id }}Data, 118 | failure: $e->getMessage(), 119 | createdAt: now() 120 | )); 121 | {% endif %} 122 | 123 | {% if notifications and !failed_events %} 124 | Notification::send(new AnonymousNotifiable, new {{ class }}UpdateFailedNotification( 125 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 126 | {{ id }}Data: $event->{{ id }}Data->toArray(), 127 | failure: $e->getMessage() 128 | )); 129 | {% endif %} 130 | } 131 | } 132 | 133 | {% if failed_events %} 134 | public function on{{ class }}UpdateFailed({{ class }}UpdateFailed $event): void 135 | { 136 | Log::error('Unable to update {{ id }}', [ 137 | 'error' => $event->failure, 138 | 'data' => $event->{{ id }}Data, 139 | ]); 140 | {% endif %} 141 | 142 | {% if notifications and failed_events %} 143 | Notification::send(new AnonymousNotifiable, new {{ class }}UpdateFailedNotification( 144 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 145 | {{ id }}Data: $event->{{ id }}Data->toArray(), 146 | failure: $event->failure 147 | )); 148 | {% endif %} 149 | {% if failed_events %} 150 | } 151 | {% endif %} 152 | 153 | public function on{{ class }}Deleted({{ class }}Deleted $event): void 154 | { 155 | try { 156 | ${{ id }} = {{ class }}::{{ primary_key }}($event->{{ id }}{{ primary_key:uppercase }}); 157 | 158 | ${{ id }}->writeable()->delete(); 159 | 160 | {% if notifications %} 161 | Notification::send(new AnonymousNotifiable, new {{ class }}DeletedNotification( 162 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }} 163 | )); 164 | {% endif %} 165 | } catch (Exception $e) { 166 | {% if !failed_events %} 167 | Log::error('Unable to delete {{ id }}', [ 168 | 'error' => $e->getMessage(), 169 | 'event' => $event, 170 | ]); 171 | {% endif %} 172 | 173 | {% if failed_events %} 174 | event(new {{ class }}DeletionFailed( 175 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 176 | failure: $e->getMessage(), 177 | createdAt: now() 178 | )); 179 | {% endif %} 180 | 181 | {% if notifications and !failed_events %} 182 | Notification::send(new AnonymousNotifiable, new {{ class }}DeletionFailedNotification( 183 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 184 | failure: $e->getMessage() 185 | )); 186 | {% endif %} 187 | } 188 | } 189 | 190 | {% if failed_events %} 191 | public function on{{ class }}DeletionFailed({{ class }}DeletionFailed $event): void 192 | { 193 | Log::error('Unable to delete {{ id }}', [ 194 | 'error' => $event->failure, 195 | ]); 196 | {% endif %} 197 | 198 | {% if notifications and failed_events %} 199 | Notification::send(new AnonymousNotifiable, new {{ class }}DeletionFailedNotification( 200 | {{ id }}{{ primary_key:uppercase }}: $event->{{ id }}{{ primary_key:uppercase }}, 201 | failure: $event->failure 202 | )); 203 | {% endif %} 204 | {% if failed_events %} 205 | } 206 | {% endif %} 207 | } 208 | -------------------------------------------------------------------------------- /stubs/reactor.stub: -------------------------------------------------------------------------------- 1 | fakeData(); 64 | 65 | (new Create{{ class }})($data); 66 | 67 | /** @var {{ class }} $record */ 68 | $record = {{ class }}::query()->first(); 69 | $this->assertNotEmpty($record); 70 | {{ test:assert($data, $record) }} 71 | 72 | {% if notifications %} 73 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}CreatedNotification::class); 74 | {% endif %} 75 | {% if notifications and failed_events %} 76 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}CreationFailedNotification::class); 77 | {% endif %} 78 | } 79 | 80 | #[Test] 81 | public function cannot_create_{{ id }}_model_with_invalid_data() 82 | { 83 | {% if notifications %} 84 | Notification::fake(); 85 | {% endif %} 86 | 87 | $data = $this->fakeInvalidData(); 88 | 89 | (new Create{{ class }})($data); 90 | 91 | $this->assertNull({{ class }}::query()->first()); 92 | 93 | {% if notifications %} 94 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}CreatedNotification::class); 95 | {% endif %} 96 | {% if notifications and failed_events %} 97 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}CreationFailedNotification::class); 98 | {% endif %} 99 | } 100 | 101 | #[Test] 102 | public function can_update_{{ id }}_model() 103 | { 104 | {% if notifications %} 105 | Notification::fake(); 106 | {% endif %} 107 | 108 | $data = $this->fakeData(); 109 | 110 | (new Create{{ class }})($data); 111 | 112 | /** @var {{ class }} $record */ 113 | $record = {{ class }}::query()->first(); 114 | $this->assertNotEmpty($record); 115 | {{ test:assert($data, $record) }} 116 | 117 | $updateData = $this->fakeData(); 118 | 119 | (new Update{{ class }})($record->{{ primary_key }}, $updateData); 120 | 121 | $updatedRecord = {{ class }}::{{ primary_key }}($record->{{ primary_key }}); 122 | $this->assertNotEmpty($updatedRecord); 123 | {{ test:assert($updateData, $updatedRecord) }} 124 | 125 | {% if notifications %} 126 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}UpdatedNotification::class); 127 | {% endif %} 128 | {% if notifications and failed_events %} 129 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}UpdateFailedNotification::class); 130 | {% endif %} 131 | } 132 | 133 | #[Test] 134 | public function cannot_update_non_existing_{{ id }}_model() 135 | { 136 | {% if notifications %} 137 | Notification::fake(); 138 | {% endif %} 139 | 140 | {% if uuid %} 141 | $primaryKey = Uuid::uuid4()->toString(); 142 | {% endif %} 143 | {% if !uuid %} 144 | $primaryKey = rand(111111, 99999999); 145 | {% endif %} 146 | 147 | $updateData = $this->fakeData(); 148 | 149 | (new Update{{ class }})($primaryKey, $updateData); 150 | 151 | {% if notifications %} 152 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}UpdatedNotification::class); 153 | {% endif %} 154 | {% if notifications and failed_events %} 155 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}UpdateFailedNotification::class); 156 | {% endif %} 157 | } 158 | 159 | #[Test] 160 | public function cannot_update_{{ id }}_model_with_invalid_data() 161 | { 162 | {% if notifications %} 163 | Notification::fake(); 164 | {% endif %} 165 | 166 | $data = $this->fakeData(); 167 | 168 | (new Create{{ class }})($data); 169 | 170 | /** @var {{ class }} $record */ 171 | $record = {{ class }}::query()->first(); 172 | $this->assertNotEmpty($record); 173 | {{ test:assert($data, $record) }} 174 | 175 | $updateData = $this->fakeInvalidData(); 176 | 177 | (new Update{{ class }})($record->{{ primary_key }}, $updateData); 178 | 179 | $updatedRecord = {{ class }}::{{ primary_key }}($record->{{ primary_key }}); 180 | $this->assertNotEmpty($updatedRecord); 181 | {{ test:assert($updateData, $updatedRecord) }} 182 | 183 | {% if notifications %} 184 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}UpdatedNotification::class); 185 | {% endif %} 186 | {% if notifications and failed_events %} 187 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}UpdateFailedNotification::class); 188 | {% endif %} 189 | } 190 | 191 | #[Test] 192 | public function can_delete_{{ id }}_model() 193 | { 194 | {% if notifications %} 195 | Notification::fake(); 196 | {% endif %} 197 | 198 | $data = $this->fakeData(); 199 | 200 | (new Create{{ class }})($data); 201 | 202 | /** @var {{ class }} $record */ 203 | $record = {{ class }}::query()->first(); 204 | $this->assertNotNull($record); 205 | 206 | (new Delete{{ class }})($record->{{ primary_key }}); 207 | 208 | $this->assertNull({{ class }}::query()->where('{{ primary_key }}', $record->{{ primary_key }})->first()); 209 | 210 | {% if notifications %} 211 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}DeletedNotification::class); 212 | {% endif %} 213 | {% if notifications and failed_events %} 214 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}DeletionFailedNotification::class); 215 | {% endif %} 216 | } 217 | 218 | #[Test] 219 | public function cannot_delete_non_existing_{{ id }}_model() 220 | { 221 | {% if notifications %} 222 | Notification::fake(); 223 | {% endif %} 224 | 225 | {% if uuid %} 226 | $primaryKey = Uuid::uuid4()->toString(); 227 | {% endif %} 228 | {% if !uuid %} 229 | $primaryKey = rand(111111, 99999999); 230 | {% endif %} 231 | (new Delete{{ class }})($primaryKey); 232 | 233 | $this->assertNull({{ class }}::query()->where('{{ primary_key }}', $primaryKey)->first()); 234 | 235 | {% if notifications %} 236 | Notification::assertNotSentTo(new AnonymousNotifiable, {{ class }}DeletedNotification::class); 237 | {% endif %} 238 | {% if notifications and failed_events %} 239 | Notification::assertSentTo(new AnonymousNotifiable, {{ class }}DeletionFailedNotification::class); 240 | {% endif %} 241 | } 242 | } -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | # - Workbench\App\Providers\WorkbenchServiceProvider 3 | 4 | migrations: 5 | - workbench/database/migrations 6 | 7 | seeders: 8 | - Workbench\Database\Seeders\DatabaseSeeder 9 | 10 | workbench: 11 | start: '/' 12 | install: true 13 | health: false 14 | discovers: 15 | web: true 16 | api: false 17 | commands: false 18 | components: false 19 | views: false 20 | build: [] 21 | assets: [] 22 | sync: [] 23 | -------------------------------------------------------------------------------- /tests/Concerns/CreatesMockMigration.php: -------------------------------------------------------------------------------- 1 | 'uuid', 23 | ], 24 | ): ?string { 25 | $this->withoutMockingConsoleOutput() 26 | ->artisan('make:migration', ['name' => 'create_'.Str::plural($tableName).'_table']); 27 | 28 | $output = Artisan::output(); 29 | $this->mockConsoleOutput = true; 30 | 31 | if (preg_match('/INFO\s*Migration\s*\[(.*)] created successfully./', $output, $matches)) { 32 | $migration = $matches[1]; 33 | 34 | // Load file 35 | $migrationFile = File::get(base_path($migration)); 36 | 37 | // Parse file and inject properties 38 | $newCode = (new ModifyMigration($migrationFile, $modelProperties, $options))->modify(); 39 | 40 | // Save file with new properties 41 | File::put(base_path($migration), $newCode); 42 | 43 | return realpath(base_path($migration)); 44 | } 45 | 46 | return null; 47 | } 48 | 49 | protected function createMockUpdateMigration( 50 | string $tableName, 51 | array $updateProperties = [], 52 | array $dropProperties = [], 53 | array $renameProperties = [], 54 | ?string $migrationName = null, 55 | ): ?string { 56 | $tableName = Str::plural($tableName); 57 | if (! $migrationName) { 58 | $migrationName = 'update_'.$tableName.'_table'; 59 | } 60 | $this->withoutMockingConsoleOutput() 61 | ->artisan('make:migration', ['name' => $migrationName]); 62 | 63 | $output = Artisan::output(); 64 | $this->mockConsoleOutput = true; 65 | 66 | if (preg_match('/INFO\s*Migration\s*\[(.*)] created successfully./', $output, $matches)) { 67 | $migration = $matches[1]; 68 | 69 | // Load file 70 | $migrationFile = File::get(base_path($migration)); 71 | 72 | // Check if migration contains Schema::table 73 | $containsSchemaTable = str_contains($migrationFile, "Schema::table('$tableName', function (Blueprint \$table) {"); 74 | 75 | $indent = Str::repeat(' ', 4); 76 | $upInject = []; 77 | $downInject = []; 78 | 79 | // Inject update table and properties 80 | if (! $containsSchemaTable) { 81 | $upInject[] = "Schema::table('$tableName', function (Blueprint \$table) {"; 82 | $downInject[] = "Schema::table('$tableName', function (Blueprint \$table) {"; 83 | } 84 | 85 | // Update 86 | foreach ($updateProperties as $name => $type) { 87 | $nullable = Str::startsWith($type, '?') ? '->nullable()' : ''; 88 | $type = $nullable ? Str::after($type, '?') : $type; 89 | $upInject[] = "$indent\$table->".$this->builtInTypeToColumnType($type)."('$name')$nullable;"; 90 | $downInject[] = "$indent\$table->dropColumn('$name');"; 91 | } 92 | 93 | // Drop 94 | foreach ($dropProperties as $name) { 95 | if (is_array($name)) { 96 | $name = implode(', ', array_map(fn ($v) => "'$v'", $name)); 97 | $upInject[] = "$indent\$table->dropColumn([$name]);"; 98 | } else { 99 | $upInject[] = "$indent\$table->dropColumn('$name');"; 100 | } 101 | } 102 | 103 | // Rename 104 | foreach ($renameProperties as $oldName => $newName) { 105 | $upInject[] = "$indent\$table->renameColumn('$oldName', '$newName');"; 106 | } 107 | 108 | if (! $containsSchemaTable) { 109 | $upInject[] = '});'; 110 | $upInject[] = "\n"; 111 | } 112 | 113 | $upInject = implode("\n", Arr::map($upInject, fn ($line) => "$indent$indent$line")); 114 | if ($containsSchemaTable) { 115 | $pattern = "/public function up\(\): void\n\s*{\n\s*Schema::table\('.*',\s*function\s*\(Blueprint\s*\\\$table\)\s*{\n\s*\/\/\n/"; 116 | $replacement = "public function up(): void\n$indent{\n$indent{$indent}Schema::table('animals', function (Blueprint \$table) {\n$upInject\n"; 117 | } else { 118 | $pattern = "/public function up\(\): void\n\s*{\n\s*\/\/\n/"; 119 | $replacement = "public function up(): void\n$indent{\n$upInject"; 120 | } 121 | 122 | $newCode = preg_replace( 123 | $pattern, 124 | $replacement, 125 | $migrationFile 126 | ); 127 | 128 | // Add drop columns to down section 129 | if (! $containsSchemaTable) { 130 | $downInject[] = '});'; 131 | $downInject[] = "\n"; 132 | } 133 | $downInject = implode("\n", Arr::map($downInject, fn ($line) => "$indent$indent$line")); 134 | if ($containsSchemaTable) { 135 | $pattern = "/public function down\(\): void\n\s*{\n\s*Schema::table\('.*',\s*function\s*\(Blueprint\s*\\\$table\)\s*{\n\s*\/\/\n/"; 136 | $replacement = "public function down(): void\n$indent{\n$indent{$indent}Schema::table('animals', function (Blueprint \$table) {\n$downInject\n"; 137 | } else { 138 | $pattern = "/public function down\(\): void\n\s*{\n\s*\/\/\n/"; 139 | $replacement = "public function down(): void\n$indent{\n$downInject"; 140 | } 141 | 142 | $newCode = preg_replace( 143 | $pattern, 144 | $replacement, 145 | $newCode 146 | ); 147 | 148 | // Save file with new properties 149 | File::put(base_path($migration), $newCode); 150 | 151 | return realpath(base_path($migration)); 152 | } 153 | 154 | return null; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/Concerns/WithMockPackages.php: -------------------------------------------------------------------------------- 1 | andReturnUsing(function ($class) { 16 | return ! ($class === EventSourcingServiceProvider::class) && class_exists($class); 17 | }); 18 | } 19 | 20 | protected function mockMicrosoftTeamsPackage(): void 21 | { 22 | PHPMockery::mock('Albertoarena\LaravelEventSourcingGenerator\Console\Commands', 'class_exists') 23 | ->andReturnUsing(function ($class) { 24 | return $class === 'NotificationChannels\MicrosoftTeams\MicrosoftTeamsChannel' || class_exists($class); 25 | }); 26 | } 27 | 28 | protected function hideMicrosoftTeamsPackage(): void 29 | { 30 | PHPMockery::mock('Albertoarena\LaravelEventSourcingGenerator\Console\Commands', 'class_exists') 31 | ->andReturnUsing(function ($class) { 32 | return ! ($class === 'NotificationChannels\MicrosoftTeams\MicrosoftTeamsChannel') && class_exists($class); 33 | }); 34 | } 35 | 36 | protected function mockSlackPackage(): void 37 | { 38 | PHPMockery::mock('Albertoarena\LaravelEventSourcingGenerator\Console\Commands', 'class_exists') 39 | ->andReturnUsing(function ($class) { 40 | return $class === 'Illuminate\Notifications\Slack\SlackMessage' || class_exists($class); 41 | }); 42 | } 43 | 44 | protected function hideSlackPackage(): void 45 | { 46 | PHPMockery::mock('Albertoarena\LaravelEventSourcingGenerator\Console\Commands', 'class_exists') 47 | ->andReturnUsing(function ($class) { 48 | return ! ($class === 'Illuminate\Notifications\Slack\SlackMessage') && class_exists($class); 49 | }); 50 | } 51 | 52 | protected function hidePhpunitPackage(): void 53 | { 54 | PHPMockery::mock('Albertoarena\LaravelEventSourcingGenerator\Console\Commands', 'class_exists') 55 | ->andReturnUsing(function ($class) { 56 | return ! ($class === TestCase::class) && class_exists($class); 57 | }); 58 | } 59 | 60 | protected function withNotificationsTable(): void 61 | { 62 | Artisan::call('make:notifications-table'); 63 | Artisan::call('migrate'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Domain/Migrations/Contracts/MigrationOptionInterface.php: -------------------------------------------------------------------------------- 1 | addVisitor( 28 | new BlueprintClassModifyNodeVisitor( 29 | $this->injectProperties, 30 | $this->options 31 | ) 32 | ); 33 | 34 | return $traverser; 35 | } 36 | 37 | /** 38 | * WARNING!! 39 | * This is not intended to be used in production but only for test purposes. 40 | * It will modify the original migration. 41 | * 42 | * @throws ParserFailedException 43 | */ 44 | public function modify(): string 45 | { 46 | $statements = $this->getStatements(); 47 | 48 | $this->getTraverser()->traverse($statements); 49 | 50 | return (new PrettyPrinter\Standard)->prettyPrintFile($statements); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Domain/PhpParser/Traversers/BlueprintClassModifyNodeVisitor.php: -------------------------------------------------------------------------------- 1 | createChainedMethodCall($chain), 46 | $name, 47 | Arr::map($args, function ($arg) { 48 | return new Node\Arg( 49 | new Node\Scalar\String_($arg) 50 | ); 51 | }) 52 | ); 53 | } 54 | 55 | protected function createMethodCall(string $type, ?string $variableName = null, array $args = []): Node\Expr\MethodCall 56 | { 57 | return new Node\Expr\MethodCall( 58 | new Node\Expr\Variable('table'), 59 | new Node\Identifier($this->builtInTypeToColumnType($type)), 60 | array_merge($variableName ? [ 61 | new Node\Arg( 62 | new Node\Scalar\String_($variableName) 63 | ), 64 | ] : [], $args) 65 | ); 66 | } 67 | 68 | protected function getMethodCallArgsByType(string $type): array 69 | { 70 | return match ($type) { 71 | 'enum', 'set' => [ 72 | new Node\Arg( 73 | new Node\Expr\Array_([ 74 | new Node\ArrayItem( 75 | new Node\Scalar\String_(Str::random(6)) 76 | ), 77 | new Node\ArrayItem( 78 | new Node\Scalar\String_(Str::random(6)) 79 | ), 80 | new Node\ArrayItem( 81 | new Node\Scalar\String_(Str::random(6)) 82 | ), 83 | ]) 84 | ), 85 | ], 86 | default => [] 87 | }; 88 | } 89 | 90 | protected function handleInjectProperties(Node\Expr\Closure $closure): self 91 | { 92 | // Inject properties 93 | if ($this->injectProperties) { 94 | // Leave timestamps as the last item 95 | $timestampsExpr = null; 96 | /** @var Node\Stmt\Expression $lastStatement */ 97 | $lastStatement = Arr::last($closure->stmts); 98 | if ($lastStatement->expr instanceof Node\Expr\MethodCall) { 99 | if ($lastStatement->expr->name->name === 'timestamps') { 100 | $timestampsExpr = array_pop($closure->stmts); 101 | } 102 | } 103 | 104 | foreach ($this->injectProperties as $variableName => $type) { 105 | $nullable = false; 106 | if (Str::startsWith($type, '?')) { 107 | $nullable = true; 108 | $type = Str::after($type, '?'); 109 | } 110 | 111 | // Exclude non supported methods 112 | if (in_array($type, BlueprintUnsupportedInterface::SKIPPED_METHODS, true)) { 113 | continue; 114 | } 115 | 116 | if ($nullable) { 117 | // Handle nullable expression 118 | $newExpression = new Node\Stmt\Expression( 119 | new Node\Expr\MethodCall( 120 | $this->createMethodCall($type, $variableName, $this->getMethodCallArgsByType($type)), 121 | 'nullable' 122 | ) 123 | ); 124 | } else { 125 | // Handle normal expression 126 | $newExpression = new Node\Stmt\Expression( 127 | $this->createMethodCall($type, $variableName, $this->getMethodCallArgsByType($type)) 128 | ); 129 | } 130 | 131 | $closure->stmts[] = $newExpression; 132 | } 133 | 134 | if ($timestampsExpr) { 135 | $closure->stmts[] = $timestampsExpr; 136 | } 137 | } 138 | 139 | return $this; 140 | } 141 | 142 | protected function getPrimaryKey(string|array|null $primaryKey): array 143 | { 144 | $primaryKeyArgs = []; 145 | 146 | // Handle array primary key, e.g. ['bigIncrements' => 'id'] --> $table->bigIncrements('id'); 147 | if (is_array($primaryKey)) { 148 | $primaryKeyArgs = array_values($primaryKey)[0]; 149 | if (! is_array($primaryKeyArgs)) { 150 | $primaryKeyArgs = [$primaryKeyArgs]; 151 | } 152 | $primaryKey = array_keys($primaryKey)[0]; 153 | } 154 | 155 | return [$primaryKey, $primaryKeyArgs]; 156 | } 157 | 158 | /** 159 | * @throws ParserFailedException 160 | * @throws Exception 161 | */ 162 | protected function handleReplacements(Node\Expr\Closure $closure): void 163 | { 164 | // Get primary key 165 | $primaryKey = $this->options[MigrationOptionInterface::PRIMARY_KEY] ?? null; 166 | if ($primaryKey) { 167 | 168 | // Handle array primary key, e.g. ['bigIncrements' => 'id'] --> $table->bigIncrements('id'); 169 | [$primaryKey, $primaryKeyArgs] = $this->getPrimaryKey($primaryKey); 170 | 171 | // Update primary key 172 | /** @var Node\Stmt\Expression $firstStatement */ 173 | $firstStatement = Arr::first($closure->stmts); 174 | if ($firstStatement->expr instanceof Node\Expr\MethodCall) { 175 | if ($primaryKey && $firstStatement->expr->name->name !== $primaryKey) { 176 | if ($primaryKey === 'uuid') { 177 | // Handle uuid primary key, e.g. 'uuid' --> $table->uuid('uuid')->primary(); 178 | $firstStatement->expr = $this->createChainedMethodCall([ 179 | ['name' => 'uuid', 'args' => $primaryKeyArgs], 180 | ['name' => 'primary'], 181 | ]); 182 | } else { 183 | $firstStatement->expr->name->name = $primaryKey; 184 | if ($primaryKeyArgs) { 185 | foreach ($primaryKeyArgs as $primaryKeyArg) { 186 | if (is_array($primaryKeyArg)) { 187 | // Primary composite keys 188 | $newArg = new Node\Arg( 189 | new Node\Expr\Array_(Arr::map($primaryKeyArg, function ($arg) { 190 | return new Node\ArrayItem( 191 | new Node\Scalar\String_($arg) 192 | ); 193 | })) 194 | ); 195 | } else { 196 | $newArg = new Node\Arg( 197 | new Node\Scalar\String_($primaryKeyArg), 198 | ); 199 | 200 | } 201 | $firstStatement->expr->args[] = $newArg; 202 | } 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | // Inject custom method calls 210 | $injects = $this->options[MigrationOptionInterface::INJECTS] ?? []; 211 | foreach ($injects as $inject) { 212 | $chain = Arr::map($inject, fn ($value, $key) => ['name' => $key, 'args' => $value]); 213 | $newExpression = new Node\Stmt\Expression( 214 | $this->createChainedMethodCall($chain) 215 | ); 216 | 217 | $closure->stmts[] = $newExpression; 218 | } 219 | 220 | // Inject soft deletes 221 | $softDeletes = $this->options[MigrationOptionInterface::SOFT_DELETES] ?? false; 222 | if ($softDeletes) { 223 | $closure->stmts[] = new Node\Stmt\Expression( 224 | $this->createMethodCall(is_string($softDeletes) ? $softDeletes : 'softDeletes') 225 | ); 226 | } 227 | } 228 | 229 | /** 230 | * @throws ParserFailedException 231 | */ 232 | public function enterNode(Node $node): ?Node 233 | { 234 | return $this->enterSchemaUpNode( 235 | $node, 236 | new EnterNode( 237 | function (Node\Stmt\Expression $expression) { 238 | if ($expression->expr instanceof Node\Expr\StaticCall) { 239 | if ($expression->expr->class->name === 'Schema') { 240 | if ($expression->expr->name->name === 'create') { 241 | // Look for Blueprint table definition 242 | foreach ($expression->expr->args as $arg) { 243 | if ($arg->value instanceof Node\Expr\Closure) { 244 | if ($arg->value->params && $arg->value->params[0] instanceof Node\Param) { 245 | if ($arg->value->params[0]->type->name === 'Blueprint') { 246 | // Inject properties and handle replacements 247 | $this->handleInjectProperties($arg->value) 248 | ->handleReplacements($arg->value); 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | ) 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /tests/Mocks/MockFilesystem.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function () { 27 | File::cleanDirectory(app_path()); 28 | File::cleanDirectory(database_path('migrations')); 29 | File::cleanDirectory(base_path('tests/Unit')); 30 | File::cleanDirectory(base_path('storage/logs')); 31 | if (File::exists(base_path('src'))) { 32 | File::cleanDirectory(base_path('src')); 33 | File::deleteDirectory(base_path('src')); 34 | } 35 | }); 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | parent::tearDown(); 41 | 42 | Mockery::close(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/MakeEventSourcingDomainCommandAggregatesTest.php: -------------------------------------------------------------------------------- 1 | 'string', 27 | 'age' => 'int', 28 | ]; 29 | 30 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--aggregate' => true]) 31 | ->expectsQuestion('Which is the name of the domain?', $model) 32 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 33 | // Properties 34 | ->expectsQuestion('Do you want to specify model properties?', true) 35 | ->expectsQuestion('Property name? (exit to quit)', 'name') 36 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 37 | ->expectsQuestion('Property name? (exit to quit)', 'age') 38 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 39 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 40 | // Options 41 | ->expectsQuestion('Do you want to use uuid as model primary key?', true) 42 | ->expectsQuestion('Do you want to create a Reactor class?', true) 43 | // Confirmation 44 | ->expectsOutput('Your choices:') 45 | ->expectsTable( 46 | ['Option', 'Choice'], 47 | [ 48 | ['Model', $model], 49 | ['Domain', $model], 50 | ['Namespace', 'Domain'], 51 | ['Path', 'Domain/'.$model.'/'.$model], 52 | ['Use migration', 'no'], 53 | ['Primary key', 'uuid'], 54 | ['Create Aggregate class', 'yes'], 55 | ['Create Reactor class', 'yes'], 56 | ['Create PHPUnit tests', 'no'], 57 | ['Create failed events', 'no'], 58 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 59 | ['Notifications', 'no'], 60 | ] 61 | ) 62 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 63 | // Result 64 | ->expectsOutputToContain('INFO Domain ['.$model.'] with model ['.$model.'] created successfully.') 65 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 66 | ->assertSuccessful(); 67 | 68 | $this->assertDomainGenerated($model, modelProperties: $properties); 69 | } 70 | 71 | #[Test] 72 | public function it_can_create_a_model_and_domain_without_aggregate() 73 | { 74 | $model = 'Animal'; 75 | 76 | $properties = [ 77 | 'name' => 'string', 78 | 'age' => 'int', 79 | ]; 80 | 81 | $this->artisan('make:event-sourcing-domain', ['model' => $model]) 82 | ->expectsQuestion('Which is the name of the domain?', $model) 83 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 84 | // Properties 85 | ->expectsQuestion('Do you want to specify model properties?', true) 86 | ->expectsQuestion('Property name? (exit to quit)', 'name') 87 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 88 | ->expectsQuestion('Property name? (exit to quit)', 'age') 89 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 90 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 91 | // Options 92 | ->expectsQuestion('Do you want to use uuid as model primary key?', true) 93 | ->expectsQuestion('Do you want to create an Aggregate class?', false) 94 | ->expectsQuestion('Do you want to create a Reactor class?', true) 95 | // Confirmation 96 | ->expectsOutput('Your choices:') 97 | ->expectsTable( 98 | ['Option', 'Choice'], 99 | [ 100 | ['Model', $model], 101 | ['Domain', $model], 102 | ['Namespace', 'Domain'], 103 | ['Path', 'Domain/'.$model.'/'.$model], 104 | ['Use migration', 'no'], 105 | ['Primary key', 'uuid'], 106 | ['Create Aggregate class', 'no'], 107 | ['Create Reactor class', 'yes'], 108 | ['Create PHPUnit tests', 'no'], 109 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 110 | ['Notifications', 'no'], 111 | ] 112 | ) 113 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 114 | // Result 115 | ->expectsOutputToContain('INFO Domain ['.$model.'] with model ['.$model.'] created successfully.') 116 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 117 | ->assertSuccessful(); 118 | 119 | $this->assertDomainGenerated($model, createAggregate: false, modelProperties: $properties); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/MakeEventSourcingDomainCommandFailuresTest.php: -------------------------------------------------------------------------------- 1 | 'string', 29 | 'age' => 'int', 30 | ]; 31 | 32 | $this->artisan('make:event-sourcing-domain', ['model' => $model]) 33 | ->expectsQuestion('Which is the name of the domain?', $model) 34 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 35 | // Properties 36 | ->expectsQuestion('Do you want to specify model properties?', true) 37 | ->expectsQuestion('Property name? (exit to quit)', 'name') 38 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 39 | ->expectsQuestion('Property name? (exit to quit)', 'age') 40 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 41 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 42 | // Options 43 | ->expectsQuestion('Do you want to use uuid as model primary key?', true) 44 | ->expectsQuestion('Do you want to create an Aggregate class?', true) 45 | ->expectsQuestion('Do you want to create a Reactor class?', true) 46 | // Confirmation 47 | ->expectsOutput('Your choices:') 48 | ->expectsTable( 49 | ['Option', 'Choice'], 50 | [ 51 | ['Model', $model], 52 | ['Domain', $model], 53 | ['Namespace', 'Domain'], 54 | ['Path', 'Domain/'.$model.'/'.$model], 55 | ['Use migration', 'no'], 56 | ['Primary key', 'uuid'], 57 | ['Create Aggregate class', 'yes'], 58 | ['Create Reactor class', 'yes'], 59 | ['Create PHPUnit tests', 'no'], 60 | ['Create failed events', 'no'], 61 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 62 | ['Notifications', 'no'], 63 | ] 64 | ) 65 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'no') 66 | // Result 67 | ->expectsOutputToContain('WARN Aborted!') 68 | ->assertFailed(); 69 | } 70 | 71 | #[RunInSeparateProcess] 72 | #[Test] 73 | public function it_cannot_create_a_model_and_domain_without_spatie_event_sourcing_installed() 74 | { 75 | $this->hideSpatiePackage(); 76 | 77 | $model = 'Animal'; 78 | 79 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $model, '--verbose' => 1]) 80 | ->expectsOutputToContain('ERROR Spatie Event Sourcing package has not been installed. Run what follows:') 81 | ->expectsOutputToContain('ERROR composer require spatie/laravel-event-sourcing') 82 | ->assertFailed(); 83 | 84 | $this->assertFalse( 85 | File::exists(app_path('Domain/'.$model.'.php')) 86 | ); 87 | } 88 | 89 | #[Test] 90 | public function it_cannot_create_a_model_if_already_exists() 91 | { 92 | $model = 'Animal'; 93 | 94 | // Create domain structure 95 | File::makeDirectory(app_path('Domain')); 96 | File::makeDirectory(app_path("Domain/$model")); 97 | File::makeDirectory(app_path("Domain/$model/Actions")); 98 | File::put(app_path("Domain/$model/Actions/Create$model.php"), "artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $model]) 101 | ->expectsOutputToContain('ERROR Model already exists.') 102 | ->assertFailed(); 103 | } 104 | 105 | #[Test] 106 | public function it_cannot_create_a_model_with_reserved_name() 107 | { 108 | $model = 'Array'; 109 | $domain = 'Animal'; 110 | 111 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $domain]) 112 | ->expectsOutputToContain('The model "'.$model.'" is reserved by PHP.') 113 | ->assertFailed(); 114 | } 115 | 116 | #[Test] 117 | public function it_cannot_create_a_domain_with_reserved_name() 118 | { 119 | $model = 'Animal'; 120 | $domain = 'Array'; 121 | 122 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $domain]) 123 | ->expectsOutputToContain('The domain "'.$domain.'" is reserved by PHP.') 124 | ->assertFailed(); 125 | } 126 | 127 | #[Test] 128 | public function it_cannot_create_a_domain_with_reserved_namespace() 129 | { 130 | $model = 'Animal'; 131 | $namespace = 'Array'; 132 | 133 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $model, '--namespace' => $namespace]) 134 | ->expectsOutputToContain('The namespace "'.$namespace.'" is reserved by PHP.') 135 | ->assertFailed(); 136 | } 137 | 138 | #[Test] 139 | public function it_cannot_create_a_domain_with_invalid_primary_key() 140 | { 141 | $model = 'Animal'; 142 | 143 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--primary-key' => 'hello']) 144 | ->expectsOutputToContain('The primary key "hello" is not valid (please specify uuid or id)') 145 | ->assertFailed(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/MakeEventSourcingDomainCommandReactorsTest.php: -------------------------------------------------------------------------------- 1 | 'string', 27 | 'age' => 'int', 28 | ]; 29 | 30 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--reactor' => true]) 31 | ->expectsQuestion('Which is the name of the domain?', $model) 32 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 33 | // Properties 34 | ->expectsQuestion('Do you want to specify model properties?', true) 35 | ->expectsQuestion('Property name? (exit to quit)', 'name') 36 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 37 | ->expectsQuestion('Property name? (exit to quit)', 'age') 38 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 39 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 40 | // Options 41 | ->expectsQuestion('Do you want to use uuid as model primary key?', true) 42 | ->expectsQuestion('Do you want to create an Aggregate class?', true) 43 | // Confirmation 44 | ->expectsOutput('Your choices:') 45 | ->expectsTable( 46 | ['Option', 'Choice'], 47 | [ 48 | ['Model', $model], 49 | ['Domain', $model], 50 | ['Namespace', 'Domain'], 51 | ['Path', 'Domain/'.$model.'/'.$model], 52 | ['Use migration', 'no'], 53 | ['Primary key', 'uuid'], 54 | ['Create Aggregate class', 'yes'], 55 | ['Create Reactor class', 'yes'], 56 | ['Create PHPUnit tests', 'no'], 57 | ['Create failed events', 'no'], 58 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 59 | ['Notifications', 'no'], 60 | ] 61 | ) 62 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 63 | // Result 64 | ->expectsOutputToContain('INFO Domain ['.$model.'] with model ['.$model.'] created successfully.') 65 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 66 | ->assertSuccessful(); 67 | 68 | $this->assertDomainGenerated($model, modelProperties: $properties); 69 | } 70 | 71 | #[Test] 72 | public function it_can_create_a_model_and_domain_without_reactor() 73 | { 74 | $model = 'Animal'; 75 | 76 | $properties = [ 77 | 'name' => 'string', 78 | 'age' => 'int', 79 | ]; 80 | 81 | $this->artisan('make:event-sourcing-domain', ['model' => $model]) 82 | ->expectsQuestion('Which is the name of the domain?', $model) 83 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 84 | // Properties 85 | ->expectsQuestion('Do you want to specify model properties?', true) 86 | ->expectsQuestion('Property name? (exit to quit)', 'name') 87 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 88 | ->expectsQuestion('Property name? (exit to quit)', 'age') 89 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 90 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 91 | // Options 92 | ->expectsQuestion('Do you want to use uuid as model primary key?', true) 93 | ->expectsQuestion('Do you want to create an Aggregate class?', true) 94 | ->expectsQuestion('Do you want to create a Reactor class?', false) 95 | // Confirmation 96 | ->expectsOutput('Your choices:') 97 | ->expectsTable( 98 | ['Option', 'Choice'], 99 | [ 100 | ['Model', $model], 101 | ['Domain', $model], 102 | ['Namespace', 'Domain'], 103 | ['Path', 'Domain/'.$model.'/'.$model], 104 | ['Use migration', 'no'], 105 | ['Primary key', 'uuid'], 106 | ['Create Aggregate class', 'yes'], 107 | ['Create Reactor class', 'no'], 108 | ['Create PHPUnit tests', 'no'], 109 | ['Create failed events', 'no'], 110 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 111 | ['Notifications', 'no'], 112 | ] 113 | ) 114 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 115 | // Result 116 | ->expectsOutputToContain('INFO Domain ['.$model.'] with model ['.$model.'] created successfully.') 117 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 118 | ->assertSuccessful(); 119 | 120 | $this->assertDomainGenerated($model, createReactor: false, modelProperties: $properties); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/MakeEventSourcingDomainCommandUnitTestsTest.php: -------------------------------------------------------------------------------- 1 | 'string', 30 | 'age' => 'int', 31 | 'number_of_bones' => '?int', 32 | ]; 33 | 34 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $domain, '--primary-key' => 'uuid', '--unit-test' => true]) 35 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 36 | // Properties 37 | ->expectsQuestion('Do you want to specify model properties?', true) 38 | ->expectsQuestion('Property name? (exit to quit)', 'name') 39 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 40 | ->expectsQuestion('Property name? (exit to quit)', 'age') 41 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 42 | ->expectsQuestion('Property name? (exit to quit)', 'number_of_bones') 43 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', '?int') 44 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 45 | // Options 46 | ->expectsQuestion('Do you want to create an Aggregate class?', true) 47 | ->expectsQuestion('Do you want to create a Reactor class?', true) 48 | // Confirmation 49 | ->expectsOutput('Your choices:') 50 | ->expectsTable( 51 | ['Option', 'Choice'], 52 | [ 53 | ['Model', $model], 54 | ['Domain', $domain], 55 | ['Namespace', 'Domain'], 56 | ['Path', 'Domain/'.$domain.'/'.$model], 57 | ['Use migration', 'no'], 58 | ['Primary key', 'uuid'], 59 | ['Create Aggregate class', 'yes'], 60 | ['Create Reactor class', 'yes'], 61 | ['Create PHPUnit tests', 'yes'], 62 | ['Create failed events', 'no'], 63 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 64 | ['Notifications', 'no'], 65 | ] 66 | ) 67 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 68 | // Result 69 | ->expectsOutputToContain('INFO Domain ['.$domain.'] with model ['.$model.'] created successfully.') 70 | ->doesntExpectOutputToContain('WARN PHPUnit package has not been installed. Run what follows:') 71 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 72 | ->assertSuccessful(); 73 | 74 | $this->assertDomainGenerated($model, domain: $domain, modelProperties: $properties, createUnitTest: true); 75 | } 76 | 77 | #[Test] 78 | public function it_can_create_a_model_and_domain_with_unit_tests_using_id_as_primary_key() 79 | { 80 | $model = 'Tiger'; 81 | $domain = 'Animal'; 82 | 83 | $properties = [ 84 | 'name' => 'string', 85 | 'age' => 'int', 86 | 'number_of_bones' => '?int', 87 | ]; 88 | 89 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $domain, '--primary-key' => 'id', '--unit-test' => true]) 90 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 91 | // Properties 92 | ->expectsQuestion('Do you want to specify model properties?', true) 93 | ->expectsQuestion('Property name? (exit to quit)', 'name') 94 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 95 | ->expectsQuestion('Property name? (exit to quit)', 'age') 96 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 97 | ->expectsQuestion('Property name? (exit to quit)', 'number_of_bones') 98 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', '?int') 99 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 100 | // Options 101 | ->expectsQuestion('Do you want to create a Reactor class?', true) 102 | // Confirmation 103 | ->expectsOutput('Your choices:') 104 | ->expectsTable( 105 | ['Option', 'Choice'], 106 | [ 107 | ['Model', $model], 108 | ['Domain', $domain], 109 | ['Namespace', 'Domain'], 110 | ['Path', 'Domain/'.$domain.'/'.$model], 111 | ['Use migration', 'no'], 112 | ['Primary key', 'id'], 113 | ['Create Aggregate class', 'no'], 114 | ['Create Reactor class', 'yes'], 115 | ['Create PHPUnit tests', 'yes'], 116 | ['Create failed events', 'no'], 117 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 118 | ['Notifications', 'no'], 119 | ] 120 | ) 121 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 122 | // Result 123 | ->expectsOutputToContain('INFO Domain ['.$domain.'] with model ['.$model.'] created successfully.') 124 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 125 | ->assertSuccessful(); 126 | 127 | $this->assertDomainGenerated($model, domain: $domain, useUuid: false, modelProperties: $properties, createUnitTest: true); 128 | } 129 | 130 | /** 131 | * @throws Exception 132 | */ 133 | #[Test] 134 | public function it_can_create_a_model_and_domain_with_migration_argument_using_all_blueprint_column_types_and_unit_test() 135 | { 136 | $properties = [ 137 | 'bool_field' => 'bool', 138 | 'boolean_field' => 'boolean', 139 | 'big_integer_field' => 'bigInteger', 140 | 'integer_field' => 'int', 141 | 'foreign_id_field' => 'foreignId', 142 | 'medium_integer_field' => 'mediumInteger', 143 | 'small_integer_field' => 'smallInteger', 144 | 'tiny_integer_field' => 'tinyInteger', 145 | 'json_field' => 'json', 146 | 'string_field' => 'string', 147 | 'datetime_tz_field' => 'dateTimeTz', 148 | 'datetime_field' => 'dateTime', 149 | 'timestamp_tz_field' => 'timestampTz', 150 | 'timestamp_field' => 'timestamp', 151 | 'timestamps_tz_field' => 'timestampsTz', 152 | 'time_tz_field' => 'timeTz', 153 | 'time_field' => 'time', 154 | 'char_field' => 'char', 155 | 'enum_field' => 'enum', 156 | 'foreign_uuid_field' => 'foreignUuid', 157 | 'ip_address_field' => 'ipAddress', 158 | 'long_text_field' => 'longText', 159 | 'mac_address_field' => 'macAddress', 160 | 'medium_text_field' => 'mediumText', 161 | 'remember_token_field' => 'rememberToken', 162 | 'text_field' => 'text', 163 | 'tiny_text_field' => 'tinyText', 164 | 'date_field' => 'date', 165 | 'nullable_timestamps_field' => 'nullableTimestamps', 166 | 'nullable_string_field' => '?string', 167 | 'nullable_int_field' => '?int', 168 | 'nullable_float_field' => '?float', 169 | 'uuid_field' => 'uuid', 170 | ]; 171 | 172 | $options = [ 173 | ':primary' => ['bigIncrements' => 'id'], 174 | ]; 175 | 176 | $expectedPrintedProperties = array_values(Arr::map($properties, fn ($type, $model) => $this->columnTypeToBuiltInType($type)." $model")); 177 | 178 | $createMigration = basename($this->createMockCreateMigration('animal', $properties, $options)); 179 | $model = 'Animal'; 180 | 181 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--migration' => 'create_animals_table', '--reactor' => 0, '--unit-test' => true]) 182 | ->expectsQuestion('Which is the name of the domain?', $model) 183 | // Confirmation 184 | ->expectsOutput('Your choices:') 185 | ->expectsTable( 186 | ['Option', 'Choice'], 187 | [ 188 | ['Model', $model], 189 | ['Domain', $model], 190 | ['Namespace', 'Domain'], 191 | ['Path', 'Domain/'.$model.'/'.$model], 192 | ['Use migration', $createMigration], 193 | ['Primary key', 'id'], 194 | ['Create Aggregate class', 'no'], 195 | ['Model properties', implode("\n", $expectedPrintedProperties)], 196 | ['Notifications', 'no'], 197 | ] 198 | ) 199 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 200 | // Result 201 | ->expectsOutputToContain('INFO Domain ['.$model.'] with model ['.$model.'] created successfully.') 202 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 203 | ->assertSuccessful(); 204 | 205 | $this->assertDomainGenerated( 206 | $model, 207 | migration: 'create_animals_table', 208 | createAggregate: false, 209 | createReactor: false, 210 | modelProperties: $properties, 211 | createUnitTest: true 212 | ); 213 | } 214 | 215 | #[RunInSeparateProcess] 216 | #[Test] 217 | public function it_can_create_a_model_and_domain_with_unit_tests_without_phpunit_package() 218 | { 219 | $this->hidePhpunitPackage(); 220 | 221 | $model = 'Tiger'; 222 | $domain = 'Animal'; 223 | 224 | $properties = [ 225 | 'name' => 'string', 226 | 'age' => 'int', 227 | 'number_of_bones' => '?int', 228 | ]; 229 | 230 | $this->artisan('make:event-sourcing-domain', ['model' => $model, '--domain' => $domain, '--primary-key' => 'uuid', '--unit-test' => true]) 231 | ->expectsQuestion('Do you want to import properties from existing database migration?', false) 232 | // Properties 233 | ->expectsQuestion('Do you want to specify model properties?', true) 234 | ->expectsQuestion('Property name? (exit to quit)', 'name') 235 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'string') 236 | ->expectsQuestion('Property name? (exit to quit)', 'age') 237 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', 'int') 238 | ->expectsQuestion('Property name? (exit to quit)', 'number_of_bones') 239 | ->expectsQuestion('Property type? (e.g. string, int, boolean. Nullable is accepted, e.g. ?string)', '?int') 240 | ->expectsQuestion('Property name? (exit to quit)', 'exit') 241 | // Options 242 | ->expectsQuestion('Do you want to create an Aggregate class?', true) 243 | ->expectsQuestion('Do you want to create a Reactor class?', true) 244 | // Confirmation 245 | ->expectsOutput('Your choices:') 246 | ->expectsTable( 247 | ['Option', 'Choice'], 248 | [ 249 | ['Model', $model], 250 | ['Domain', $domain], 251 | ['Namespace', 'Domain'], 252 | ['Path', 'Domain/'.$domain.'/'.$model], 253 | ['Use migration', 'no'], 254 | ['Primary key', 'uuid'], 255 | ['Create Aggregate class', 'yes'], 256 | ['Create Reactor class', 'yes'], 257 | ['Create PHPUnit tests', 'yes'], 258 | ['Create failed events', 'no'], 259 | ['Model properties', implode("\n", Arr::map($properties, fn ($type, $model) => "$type $model"))], 260 | ['Notifications', 'no'], 261 | ] 262 | ) 263 | ->expectsConfirmation('Do you confirm the generation of the domain?', 'yes') 264 | // Result 265 | ->expectsOutputToContain('WARN PHPUnit package has not been installed. Run what follows:') 266 | ->expectsOutputToContain('WARN composer require phpunit/phpunit --dev') 267 | ->expectsOutputToContain('INFO Domain ['.$domain.'] with model ['.$model.'] created successfully.') 268 | ->doesntExpectOutputToContain('A file already exists (it was not overwritten)') 269 | ->assertSuccessful(); 270 | 271 | $this->assertDomainGenerated($model, domain: $domain, modelProperties: $properties, createUnitTest: true); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /tests/Unit/Domain/PhpParser/Models/MigrationCreatePropertiesTest.php: -------------------------------------------------------------------------------- 1 | add($property); 19 | 20 | $this->assertArrayHasKey('name', $properties->toArray()); 21 | $this->assertEquals('string', $properties->toArray()['name']->type->type); 22 | } 23 | 24 | #[Test] 25 | public function can_update_property(): void 26 | { 27 | $properties = new MigrationCreateProperties; 28 | $property1 = new MigrationCreateProperty('name', 'string'); 29 | $property2 = new MigrationCreateProperty('name', 'text'); 30 | 31 | $properties->add($property1); 32 | $properties->add($property2, true); 33 | 34 | $this->assertEquals('text', $properties->toArray()['name']->type->type); 35 | } 36 | 37 | #[Test] 38 | public function can_set_primary_key(): void 39 | { 40 | $properties = new MigrationCreateProperties; 41 | $primaryKeyProperty = new MigrationCreateProperty('uuid', 'string'); 42 | $property1 = new MigrationCreateProperty('name', 'string'); 43 | $property2 = new MigrationCreateProperty('surname', 'string'); 44 | 45 | $properties->add($primaryKeyProperty)->add($property1)->add($property2); 46 | 47 | $primary = $properties->primary(); 48 | 49 | $this->assertEquals('uuid', $primary->name); 50 | $this->assertEquals('string', $primary->type->type); 51 | } 52 | 53 | #[Test] 54 | public function can_set_primary_key_using_id(): void 55 | { 56 | $properties = new MigrationCreateProperties; 57 | $primaryKeyProperty = new MigrationCreateProperty('id', 'integer'); 58 | $property1 = new MigrationCreateProperty('name', 'string'); 59 | $property2 = new MigrationCreateProperty('surname', 'string'); 60 | 61 | $properties->add($primaryKeyProperty)->add($property1)->add($property2); 62 | 63 | $primary = $properties->primary(); 64 | 65 | $this->assertEquals('id', $primary->name); 66 | $this->assertEquals('integer', $primary->type->type); 67 | } 68 | 69 | #[Test] 70 | public function can_import_properties(): void 71 | { 72 | $collection = [ 73 | 'name' => 'string', 74 | 'age' => 'integer', 75 | ]; 76 | 77 | $properties = new MigrationCreateProperties; 78 | $properties->import($collection); 79 | 80 | $this->assertCount(2, $properties->toArray()); 81 | $this->assertArrayHasKey('name', $properties->toArray()); 82 | $this->assertArrayHasKey('age', $properties->toArray()); 83 | } 84 | 85 | #[Test] 86 | public function can_import_properties_and_update(): void 87 | { 88 | $collection1 = [ 89 | 'name' => 'string', 90 | 'age' => 'integer', 91 | ]; 92 | 93 | $properties = new MigrationCreateProperties; 94 | $properties->import($collection1); 95 | 96 | $this->assertCount(2, $properties->toArray()); 97 | $this->assertArrayHasKey('name', $properties->toArray()); 98 | $this->assertArrayHasKey('age', $properties->toArray()); 99 | $this->assertEquals('integer', $properties->toArray()['age']->type->type); 100 | 101 | $collection2 = [ 102 | 'age' => 'float', 103 | ]; 104 | $properties->import($collection2, reset: false); 105 | 106 | $this->assertCount(2, $properties->toArray()); 107 | $this->assertArrayHasKey('name', $properties->toArray()); 108 | $this->assertArrayHasKey('age', $properties->toArray()); 109 | $this->assertEquals('float', $properties->toArray()['age']->type->type); 110 | } 111 | 112 | #[Test] 113 | public function can_ignore_reserved_fields(): void 114 | { 115 | $properties = new MigrationCreateProperties([ 116 | new MigrationCreateProperty('id', 'integer'), 117 | new MigrationCreateProperty('uuid', 'string'), 118 | new MigrationCreateProperty('name', 'string'), 119 | ]); 120 | 121 | $filteredProperties = $properties->withoutReservedFields(); 122 | 123 | $this->assertArrayNotHasKey('id', $filteredProperties->toArray()); 124 | $this->assertArrayNotHasKey('uuid', $filteredProperties->toArray()); 125 | $this->assertArrayHasKey('name', $filteredProperties->toArray()); 126 | } 127 | 128 | #[Test] 129 | public function can_ignore_skipped_methods(): void 130 | { 131 | $properties = new MigrationCreateProperties([ 132 | new MigrationCreateProperty('name', 'string'), 133 | new MigrationCreateProperty('index', 'string'), 134 | ]); 135 | 136 | $filteredProperties = $properties->withoutSkippedMethods(); 137 | 138 | $this->assertArrayHasKey('name', $filteredProperties->toArray()); 139 | $this->assertArrayNotHasKey('index', $filteredProperties->toArray()); 140 | } 141 | 142 | #[Test] 143 | public function can_convert_to_array(): void 144 | { 145 | $property = new MigrationCreateProperty('name', 'string'); 146 | $properties = new MigrationCreateProperties; 147 | $properties->add($property); 148 | 149 | $array = $properties->toArray(); 150 | 151 | $this->assertIsArray($array); 152 | $this->assertArrayHasKey('name', $array); 153 | $this->assertInstanceOf(MigrationCreateProperty::class, $array['name']); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/Unit/Domain/PhpParser/Models/MigrationCreatePropertyTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('string', $obj->type); 16 | $this->assertFalse($obj->nullable); 17 | $this->assertEquals('string', $obj->toBuiltInType()); 18 | $this->assertEquals('string', $obj->toNormalisedBuiltInType()); 19 | $this->assertEquals('string', $obj->toProjection()); 20 | } 21 | 22 | #[Test] 23 | public function can_create_property_type_from_nullable_type() 24 | { 25 | $obj = new MigrationCreatePropertyType('?string'); 26 | $this->assertEquals('?string', $obj->type); 27 | $this->assertTrue($obj->nullable); 28 | $this->assertEquals('string', $obj->toBuiltInType()); 29 | $this->assertEquals('string', $obj->toNormalisedBuiltInType()); 30 | $this->assertEquals('string', $obj->toProjection()); 31 | } 32 | 33 | #[Test] 34 | public function can_create_property_type_from_string_with_nullable() 35 | { 36 | $obj = new MigrationCreatePropertyType('string', true); 37 | $this->assertEquals('string', $obj->type); 38 | $this->assertTrue($obj->nullable); 39 | $this->assertEquals('string', $obj->toBuiltInType()); 40 | $this->assertEquals('string', $obj->toNormalisedBuiltInType()); 41 | $this->assertEquals('string', $obj->toProjection()); 42 | } 43 | 44 | #[Test] 45 | public function can_create_property_type_from_nullable_string_with_nullable() 46 | { 47 | $obj = new MigrationCreatePropertyType('?string', true); 48 | $this->assertEquals('?string', $obj->type); 49 | $this->assertTrue($obj->nullable); 50 | $this->assertEquals('string', $obj->toBuiltInType()); 51 | $this->assertEquals('string', $obj->toNormalisedBuiltInType()); 52 | $this->assertEquals('string', $obj->toProjection()); 53 | } 54 | 55 | #[Test] 56 | public function can_create_property_type_from_nullable_custom_type() 57 | { 58 | $obj = new MigrationCreatePropertyType('?custom'); 59 | $this->assertEquals('?custom', $obj->type); 60 | $this->assertTrue($obj->nullable); 61 | $this->assertEquals('custom', $obj->toBuiltInType()); 62 | $this->assertEquals('custom', $obj->toNormalisedBuiltInType()); 63 | $this->assertEquals('custom', $obj->toProjection()); 64 | } 65 | 66 | #[Test] 67 | public function can_create_property_type_from_date() 68 | { 69 | $obj = new MigrationCreatePropertyType('date'); 70 | $this->assertEquals('date', $obj->type); 71 | $this->assertFalse($obj->nullable); 72 | $this->assertEquals('Carbon:Y-m-d', $obj->toBuiltInType()); 73 | $this->assertEquals('Carbon', $obj->toNormalisedBuiltInType()); 74 | $this->assertEquals('date:Y-m-d', $obj->toProjection()); 75 | } 76 | 77 | #[Test] 78 | public function can_create_property_type_from_time() 79 | { 80 | $obj = new MigrationCreatePropertyType('time'); 81 | $this->assertEquals('time', $obj->type); 82 | $this->assertFalse($obj->nullable); 83 | $this->assertEquals('Carbon:H:i:s', $obj->toBuiltInType()); 84 | $this->assertEquals('Carbon', $obj->toNormalisedBuiltInType()); 85 | $this->assertEquals('date:H:i:s', $obj->toProjection()); 86 | } 87 | 88 | #[Test] 89 | public function can_create_property_type_from_nullable_timestamps() 90 | { 91 | $obj = new MigrationCreatePropertyType('nullableTimestamps'); 92 | $this->assertEquals('nullableTimestamps', $obj->type); 93 | $this->assertTrue($obj->nullable); 94 | $this->assertEquals('Carbon', $obj->toBuiltInType()); 95 | $this->assertEquals('Carbon', $obj->toNormalisedBuiltInType()); 96 | $this->assertEquals('date:Y-m-d H:i:s', $obj->toProjection()); 97 | } 98 | } 99 | --------------------------------------------------------------------------------