diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index 0834f336..250ba610 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -26,10 +26,10 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 6951b621..2e3df1c3 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -25,10 +25,10 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: none php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000..03b44ed3 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,71 @@ +name: Publish docs + +on: + push: + branches: + - "[0-9]+.[0-9]+.x" + paths: + - "docs/**" + release: + types: + - published + +permissions: + contents: read + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Check whether this ref belongs to the latest release of its major version + id: check + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [ "$EVENT_NAME" = "release" ]; then + current="$RELEASE_TAG" + else + current="$REF_NAME" + fi + + major="${current%%.*}" + + latest_tag=$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 1000 \ + --json tagName \ + --jq '.[].tagName' \ + | grep -E "^${major}\.[0-9]+\.[0-9]+$" \ + | sort -V \ + | tail -n1) + latest_branch="${latest_tag%.*}.x" + + if [ "$EVENT_NAME" = "release" ]; then + expected="$latest_tag" + else + expected="$latest_branch" + fi + + echo "latest release of ${major}.x: ${latest_tag:-none} (branch ${latest_branch}), current: ${current}" + + if [ -n "$latest_tag" ] && [ "$current" = "$expected" ]; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "Skipping docs deployment: ${current} is not the latest release of ${major}.x." + fi + + - name: Trigger workflow in other repo + if: steps.check.outputs.deploy == 'true' + run: | + curl -L -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + https://api.github.com/repos/patchlevel/patchlevel.dev/actions/workflows/prod-deployment.yaml/dispatches \ + -d '{"ref":"main"}' diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index a76343c8..b4ddb05b 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -22,12 +22,12 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 8e6f3f0a..7bf87c04 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -26,10 +26,10 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index d88b7ed3..afcec7bb 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -26,10 +26,10 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 15fe285e..dd2ab725 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -37,10 +37,10 @@ jobs: operating-system: "windows-latest" steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.0" + uses: "shivammathur/setup-php@2.37.2" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/release-on-milestone-closed-triggering-release-event.yml b/.github/workflows/release-on-milestone-closed-triggering-release-event.yml index 8af28e8f..923ca3fe 100644 --- a/.github/workflows/release-on-milestone-closed-triggering-release-event.yml +++ b/.github/workflows/release-on-milestone-closed-triggering-release-event.yml @@ -18,7 +18,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Release" uses: "laminas/automatic-releases@v1" diff --git a/docs/configuration.md b/docs/configuration.md index 43b9d18b..dc01cdeb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,7 +2,7 @@ :::info You can find out more about event sourcing in the library -[documentation](https://event-sourcing.patchlevel.io/latest/). +[documentation](/docs/event-sourcing/latest). This documentation is limited to bundle integration and configuration. ::: @@ -33,9 +33,9 @@ Make sure the directories containing your aggregates, events and headers are not ::: :::tip -If you want to learn more about [aggregates](https://event-sourcing.patchlevel.io/latest/aggregate/), -[events](https://event-sourcing.patchlevel.io/latest/events/) -or [custom headers](https://event-sourcing.patchlevel.io/latest/message/#custom-headers), +If you want to learn more about [aggregates](/docs/event-sourcing/latest/aggregate), +[events](/docs/event-sourcing/latest/events) +or [custom headers](/docs/event-sourcing/latest/message/#custom-headers), read the library documentation. ::: @@ -229,7 +229,7 @@ All schema relevant commands are removed if you activate this option. You should ::: :::tip -If you want to learn more about store, read the [library documentation](https://event-sourcing.patchlevel.io/latest/store/). +If you want to learn more about store, read the [library documentation](/docs/event-sourcing/latest/store). ::: ### Kernel Reset @@ -284,7 +284,7 @@ patchlevel_event_sourcing: :::tip You can find out more about subscriptions in the library -[documentation](https://event-sourcing.patchlevel.io/latest/subscription/). +[documentation](/docs/event-sourcing/latest/subscription). ::: ### Store @@ -313,6 +313,51 @@ If you are using the [doctrine-test-bundle](https://github.com/dmaicher/doctrine you can use the `static_in_memory` store for testing. ::: +### Retry Strategies + +If a subscriber throws an error, the subscription engine can retry it later instead of leaving it in an error state. +You can define one or more named retry strategies and choose which one is used by default. + +```yaml +patchlevel_event_sourcing: + subscription: + retry_strategies: + default: + type: clock_based + options: + base_delay: 5 + delay_factor: 2 + max_attempts: 5 + no_retry: + type: no_retry + default_retry_strategy: default +``` +The following strategy types are available: + +- `clock_based`: retries with an increasing delay based on the clock. Configurable via `base_delay` (seconds), + `delay_factor` and `max_attempts`. +- `no_retry`: never retries. +- `custom`: use your own strategy. You need to set the `service` id to a service implementing the + `Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy` interface. + +```yaml +patchlevel_event_sourcing: + subscription: + retry_strategies: + my_strategy: + type: custom + service: my_retry_strategy_service + default_retry_strategy: my_strategy +``` +:::note +If you don't configure anything, a `default` (`clock_based`) and a `no_retry` strategy are registered and `default` is used. +::: + +:::tip +You can select the retry strategy per subscriber. If you want to learn more about retry strategies, read the +[library documentation](/docs/event-sourcing/latest/subscription/#retry-strategy). +::: + ### Sync Subscriptions By default, all subscriptions are processed asynchronously by a worker @@ -397,6 +442,21 @@ patchlevel_event_sourcing: This works only before each http requests and not if you use the console commands. ::: +You can restrict the setup to specific subscribers with `ids` and `groups`. +With `exclude_url` you can define a regex for urls that should not trigger the auto setup. +By default the symfony internal routes (`^/_(wdt|profiler|error)`) are excluded. + +```yaml +patchlevel_event_sourcing: + subscription: + auto_setup: + ids: + - 'profile_projection' + groups: + - 'default' + exclude_url: '^/_(wdt|profiler|error)' +``` + ### Rebuild After File Change If you want to rebuild the subscription engine after a file change, you can activate this option. @@ -415,6 +475,17 @@ This works only before each http requests and not if you use the console command This is using the cache system to store the latest file change time. You can change the cache pool with the `cache_pool` option. ::: +With `exclude_url` you can define a regex for urls that should not trigger the rebuild. +By default the symfony internal routes (`^/_(wdt|profiler|error)`) are excluded. + +```yaml +patchlevel_event_sourcing: + subscription: + rebuild_after_file_change: + cache_pool: cache.app + exclude_url: '^/_(wdt|profiler|error)' +``` + ### Gap Detection Depending on the database you are using for the eventstore it may be happening that your subscriptions are skipping some @@ -478,9 +549,20 @@ patchlevel_event_sourcing: service: command.bus ``` :::note -You can find out more about the command bus and the aggregate handlers [here](https://event-sourcing.patchlevel.io/latest/command_bus/). +You can find out more about the command bus and the aggregate handlers [here](/docs/event-sourcing/latest/command-bus). ::: +### Register Aggregate Handlers + +By default the aggregate command handlers are automatically registered for the configured messenger bus. +If you want to register them yourself, you can disable this behaviour. + +```yaml +patchlevel_event_sourcing: + command_bus: + service: command.bus + register_aggregate_handlers: false +``` ### Instant Retry You can define the default instant retry configuration for the command bus. @@ -495,7 +577,7 @@ patchlevel_event_sourcing: - Patchlevel\EventSourcing\Repository\AggregateOutdated ``` :::note -You can find out more about instant retry [here](https://event-sourcing.patchlevel.io/latest/command_bus/#instant-retry). +You can find out more about instant retry [here](/docs/event-sourcing/latest/command-bus/#instant-retry). ::: ## Query Bus @@ -518,7 +600,7 @@ patchlevel_event_sourcing: service: query.bus ``` :::note -You can find out more about the query bus [here](https://event-sourcing.patchlevel.io/latest/query_bus/). +You can find out more about the query bus [here](/docs/event-sourcing/latest/query-bus). ::: ## Event Bus @@ -531,7 +613,7 @@ patchlevel_event_sourcing: event_bus: ~ ``` :::note -Default is the patchlevel [event bus](https://event-sourcing.patchlevel.io/latest/event_bus/). +Default is the patchlevel [event bus](/docs/event-sourcing/latest/event-bus). ::: ### Patchlevel (Default) Event Bus @@ -642,6 +724,24 @@ patchlevel_event_sourcing: default: service: event_sourcing.cache ``` +You can also choose the store type. The following types are available: + +- `psr6` *default* +- `psr16` +- `custom` + +```yaml +patchlevel_event_sourcing: + snapshot_stores: + default: + type: psr16 + service: event_sourcing.cache +``` +:::note +If you use the `custom` type, the `service` has to implement the +`Patchlevel\EventSourcing\Snapshot\Adapter\SnapshotAdapter` interface. +::: + Finally, you have to tell the aggregate that it should use this snapshot store. ```php @@ -659,9 +759,29 @@ final class Profile extends BasicAggregateRoot } ``` :::note -You can find out more about snapshots [here](https://event-sourcing.patchlevel.io/latest/snapshots/). +You can find out more about snapshots [here](/docs/event-sourcing/latest/snapshots). ::: +## Hydrator + +### Default Lazy + +You can enable lazy hydration by default. This means that values are only hydrated when they are accessed. + +```yaml +patchlevel_event_sourcing: + hydrator: + default_lazy: true +``` +### Lifecycle + +You can enable the lifecycle extension to run lifecycle hooks during hydration. + +```yaml +patchlevel_event_sourcing: + hydrator: + lifecycle: true +``` ## Cryptography You can use the library to encrypt and decrypt sensitive data. @@ -682,7 +802,7 @@ patchlevel_event_sourcing: algorithm: 'aes-256-gcm' ``` :::note -You can find out more about sensitive data [here](https://event-sourcing.patchlevel.io/latest/sensitive-data/). +You can find out more about sensitive data [here](/docs/event-sourcing/latest/sensitive-data). ::: ## Clock diff --git a/docs/getting-started.md b/docs/getting-started.md index 5f7b72ff..f72c46f0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ We keep the example small, so we can only create hotels and let guests check in For this example we use [symfony/mailer](https://symfony.com/doc/current/mailer.html). -:::info +:::note First of all, the bundle has to be installed and configured. If you haven't already done so, see the [installation introduction](installation.md). ::: @@ -19,8 +19,8 @@ A hotel can be created with a `name` and an `id`: ```php namespace App\Hotel\Domain\Event; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Event('hotel.created')] final class HotelCreated @@ -37,8 +37,8 @@ A guest can check in by `guestName`: ```php namespace App\Hotel\Domain\Event; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Event('hotel.guest_is_checked_in')] final class GuestIsCheckedIn @@ -55,8 +55,8 @@ And also check out again: ```php namespace App\Hotel\Domain\Event; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Event('hotel.guest_is_checked_out')] final class GuestIsCheckedOut @@ -68,8 +68,9 @@ final class GuestIsCheckedOut } } ``` + :::note -You can find out more about events in the [library](https://event-sourcing.patchlevel.io/latest/events/). +You can find out more about events in the [library](/docs/event-sourcing/latest/events). ::: ## Define aggregates @@ -87,10 +88,10 @@ use App\Hotel\Domain\Event\GuestIsCheckedIn; use App\Hotel\Domain\Event\GuestIsCheckedOut; use App\Hotel\Domain\Event\HotelCreated; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; use function array_filter; use function array_values; @@ -168,8 +169,9 @@ final class Hotel extends BasicAggregateRoot } } ``` + :::note -You can find out more about aggregates in the [library](https://event-sourcing.patchlevel.io/latest/aggregate/). +You can find out more about aggregates in the [library](/docs/event-sourcing/latest/aggregate). ::: ## Define projections @@ -184,12 +186,13 @@ namespace App\Hotel\Infrastructure\Projection; use App\Hotel\Domain\Event\GuestIsCheckedIn; use App\Hotel\Domain\Event\GuestIsCheckedOut; use Doctrine\DBAL\Connection; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Teardown; -use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; +use Patchlevel\EventSourcing\Identifier\Uuid; + +use function sprintf; /** * @psalm-type GuestData = array{ @@ -199,10 +202,10 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; * check_out_date: string|null * } */ -#[Projector('guests')] +#[Projector(self::SUBSCRIBER_ID)] final class GuestProjection { - use SubscriberUtil; + private const SUBSCRIBER_ID = 'guests'; public function __construct( private Connection $db, @@ -214,7 +217,7 @@ final class GuestProjection { return $this->db->createQueryBuilder() ->select('*') - ->from($this->table()) + ->from(self::SUBSCRIBER_ID) ->where('hotel_id = :hotel_id') ->setParameter('hotel_id', $hotelId->toString()) ->fetchAllAssociative(); @@ -226,7 +229,7 @@ final class GuestProjection DateTimeImmutable $recordedOn, ): void { $this->db->insert( - $this->table(), + self::SUBSCRIBER_ID, [ 'hotel_id' => $event->hotelId->toString(), 'guest_name' => $event->guestName, @@ -242,7 +245,7 @@ final class GuestProjection DateTimeImmutable $recordedOn, ): void { $this->db->update( - $this->table(), + self::SUBSCRIBER_ID, [ 'check_out_date' => $recordedOn->format('Y-m-d H:i:s'), ], @@ -257,34 +260,31 @@ final class GuestProjection #[Setup] public function create(): void { - $this->db->executeStatement( - "CREATE TABLE {$this->table()} ( + $this->db->executeStatement(sprintf( + 'CREATE TABLE %s ( hotel_id VARCHAR(36) NOT NULL, guest_name VARCHAR(255) NOT NULL, check_in_date TIMESTAMP NOT NULL, check_out_date TIMESTAMP NULL - );", - ); + );', + self::SUBSCRIBER_ID, + )); } #[Teardown] public function drop(): void { - $this->db->executeStatement("DROP TABLE IF EXISTS {$this->table()};"); - } - - private function table(): string - { - return 'projection_' . $this->subscriberId(); + $this->db->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::SUBSCRIBER_ID)); } } ``` + :::warning autoconfigure need to be enabled, otherwise you need add the `event_sourcing.subscriber` tag. ::: :::note -You can find out more about projections in the [library](https://event-sourcing.patchlevel.io/latest/subscription/). +You can find out more about projections in the [library](/docs/event-sourcing/latest/subscription). ::: ## Processor @@ -323,12 +323,13 @@ final class SendCheckInEmailProcessor } } ``` + :::warning autoconfigure need to be enabled, otherwise you need add the `event_sourcing.subscriber` tag. ::: :::note -You can find out more about processor in the [library](https://event-sourcing.patchlevel.io/latest/subscription/) +You can find out more about processor in the [library](/docs/event-sourcing/latest/subscription) ::: ## Database setup @@ -345,8 +346,9 @@ or you can use doctrine migrations: bin/console event-sourcing:migrations:diff bin/console event-sourcing:migrations:migrate ``` + :::note -You can find out more about the cli in the [library](https://event-sourcing.patchlevel.io/latest/cli/). +You can find out more about the cli in the [library](/docs/event-sourcing/latest/cli). ::: ## Usage @@ -358,7 +360,7 @@ namespace App\Hotel\Infrastructure\Controller; use App\Hotel\Domain\Hotel; use App\Hotel\Infrastructure\Projection\GuestProjection; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Repository\Repository; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -431,5 +433,5 @@ If there are still open questions, create a ticket on Github and we will try to :::note This documentation is limited to the bundle integration. -You should also read the [library documentation](https://event-sourcing.patchlevel.io/latest/). -::: +You should also read the [library documentation](/docs/event-sourcing/latest). +::: \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 2a56f889..d3b23b14 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ for [event-sourcing](https://github.com/patchlevel/event-sourcing) library. * Everything is included in the package for event sourcing * Based on [doctrine dbal](https://github.com/doctrine/dbal) and their ecosystem * Developer experience oriented and fully typed -* Automatic [snapshot](https://event-sourcing.patchlevel.io/latest/snapshots/)-system to boost your performance -* [Split](https://event-sourcing.patchlevel.io/latest/split_stream/) big aggregates into multiple streams -* Versioned and managed lifecycle of [subscriptions](https://event-sourcing.patchlevel.io/latest/subscription/) like projections and processors -* Safe usage of [Personal Data](https://event-sourcing.patchlevel.io/latest/personal_data/) with crypto-shredding -* Smooth [upcasting](https://event-sourcing.patchlevel.io/latest/upcasting/) of old events -* Simple setup with [scheme management](https://event-sourcing.patchlevel.io/latest/store/) and [doctrine migration](https://event-sourcing.patchlevel.io/latest/store/) -* Built in [cli commands](https://event-sourcing.patchlevel.io/latest/cli/) with [symfony](https://symfony.com/) +* Automatic [snapshot](/docs/event-sourcing/latest/snapshots)-system to boost your performance +* [Split](/docs/event-sourcing/latest/split-stream) big aggregates into multiple streams +* Versioned and managed lifecycle of [subscriptions](/docs/event-sourcing/latest/subscription) like projections and processors +* Safe usage of [Sensitive Data](/docs/event-sourcing/latest/sensitive-data) with crypto-shredding +* Smooth [upcasting](/docs/event-sourcing/latest/upcasting) of old events +* Simple setup with [scheme management](/docs/event-sourcing/latest/store) and [doctrine migration](/docs/event-sourcing/latest/store) +* Built in [cli commands](/docs/event-sourcing/latest/cli) with [symfony](https://symfony.com/) * and much more... ## Installation @@ -24,13 +24,14 @@ for [event-sourcing](https://github.com/patchlevel/event-sourcing) library. ```bash composer require patchlevel/event-sourcing-bundle ``` -:::info + +:::note If you don't use the symfony flex recipe for this bundle, you need to follow this [installation documentation](installation.md). ::: :::tip -Start with the [quickstart](./getting_started.md) to get a feeling for the bundle. +Start with the [quickstart](getting-started.md) to get a feeling for the bundle. ::: ## Integration diff --git a/docs/installation.md b/docs/installation.md index f7608070..bd5f07f1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -11,6 +11,7 @@ The first thing to do is to install packet if it has not already been done. ```bash composer require patchlevel/event-sourcing-bundle ``` + :::note how to install [composer](https://getcomposer.org/doc/00-intro.md) ::: @@ -73,6 +74,7 @@ Finally, we have to fill the ENV variable with a connection url. ```dotenv EVENTSTORE_URL="pdo-pgsql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" ``` + :::note You can find out more about what a connection url looks like [here](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url). ::: @@ -116,6 +118,7 @@ For this you have to add the following configuration to the `.symfony.local.yaml workers: docker_compose: ~ ``` + :::success -You have successfully installed the bundle. Now you can start with the [quickstart](./getting_started.md) to get a feeling for the bundle. +You have successfully installed the bundle. Now you can start with the [quickstart](getting-started.md) to get a feeling for the bundle. ::: diff --git a/docs/usage.md b/docs/usage.md index b3837c35..ab0d52df 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -5,7 +5,7 @@ But we provide only examples for specific symfony features. :::info You can find out more about event sourcing in the library -[documentation](https://event-sourcing.patchlevel.io/latest/). +[documentation](/docs/event-sourcing/latest). This documentation is limited to bundle integration and configuration. ::: @@ -17,7 +17,7 @@ argument name injection. For our aggregate `Hotel` it would be `$hotelRepository ```php namespace App\Hotel\Infrastructure\Controller; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Repository\Repository; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -43,6 +43,54 @@ final class HotelController } } ``` +## Identifier Value Resolver + +The bundle registers a controller argument value resolver for identifiers. +If you type-hint a controller argument with a class that implements +`Patchlevel\EventSourcing\Identifier\Identifier`, the resolver builds it +from the matching request attribute (e.g. a route parameter with the same name) +using `fromString()`. + +```php +namespace App\Hotel\Infrastructure\Controller; + +use Patchlevel\EventSourcing\Identifier\Uuid; +use Patchlevel\EventSourcing\Repository\Repository; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\Routing\Attribute\Route; + +#[AsController] +final class HotelController +{ + public function __construct( + /** @var Repository */ + private readonly Repository $hotelRepository, + ) { + } + + #[Route('/hotel/{hotelId}')] + public function doStuffAction(Uuid $hotelId): Response + { + $hotel = $this->hotelRepository->load($hotelId); + + // ... + + return new Response(); + } +} +``` + +:::note +The name of the argument (`$hotelId`) must match the name of the request attribute +(the `{hotelId}` route parameter). If the attribute is missing or not a string, +the resolver is skipped and Symfony continues with the other value resolvers. +::: + +:::tip +This works with any of your own identifier classes, as long as they implement +`Identifier`. The library's `Patchlevel\EventSourcing\Identifier\Uuid` already does. +::: ## Subscriber A subscriber can be used to send an email when a guest is checked in: @@ -150,7 +198,7 @@ otherwise the service will be added twice. This bundle adds more Symfony specific normalizers in addition to the existing built-in normalizers. :::note -You can find the other build-in normalizers [here](https://event-sourcing.patchlevel.io/latest/normalizer/#built-in-normalizer) +You can find the other build-in normalizers [here](/docs/event-sourcing/latest/normalizer/#built-in-normalizer) ::: :::tip @@ -173,8 +221,8 @@ final class DTO } ``` :::warning -The symfony uuid don't implement the `AggregateId` interface, so it can not be used as an aggregate id directly. -Use instead the `Patchlevel\EventSourcing\Aggregate\Uuid` class. +The symfony uuid don't implement the `Identifier` interface, so it can not be used as an aggregate id directly. +Use instead the `Patchlevel\EventSourcing\Identifier\Uuid` class. ::: :::tip @@ -271,4 +319,16 @@ services: App\Message\Decorator\LoggedUserDecorator: tags: - event_sourcing.message_decorator -``` \ No newline at end of file +``` + +## Profiler + +When the kernel is in debug mode (e.g. in the `dev` environment), the bundle registers a +[Symfony Web Profiler](https://symfony.com/doc/current/profiler.html) panel for event sourcing. +It collects the messages that were dispatched during a request as well as the registered +aggregates and events, and shows them in the profiler toolbar and panel. + +:::note +This is enabled automatically and needs no configuration. It is only active when +`kernel.debug` is `true`, so it has no effect in production. +::: diff --git a/src/CommandBus/SymfonyCommandBus.php b/src/CommandBus/SymfonyCommandBus.php index 958d0f7c..47cc2fe8 100644 --- a/src/CommandBus/SymfonyCommandBus.php +++ b/src/CommandBus/SymfonyCommandBus.php @@ -8,6 +8,8 @@ use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\MessageBusInterface; +use function array_values; + final class SymfonyCommandBus implements CommandBus { public function __construct( @@ -20,7 +22,7 @@ public function dispatch(object $command): void try { $this->messageBus->dispatch($command); } catch (HandlerFailedException $e) { - throw $e->getWrappedExceptions(null, true)[0] ?? $e; + throw array_values($e->getWrappedExceptions(null, true))[0] ?? $e; } } } diff --git a/src/Resources/views/Collector/icon.svg b/src/Resources/views/Collector/icon.svg index 469b780e..1279805b 100644 --- a/src/Resources/views/Collector/icon.svg +++ b/src/Resources/views/Collector/icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/Unit/CommandBus/SymfonyCommandBusTest.php b/tests/Unit/CommandBus/SymfonyCommandBusTest.php new file mode 100644 index 00000000..f0e30e57 --- /dev/null +++ b/tests/Unit/CommandBus/SymfonyCommandBusTest.php @@ -0,0 +1,125 @@ +command = $envelope->getMessage(); + + return $envelope; + } + }; + $messageBus = new MessageBus([$middleware]); + + $commandBus = new SymfonyCommandBus($messageBus); + $commandBus->dispatch($command); + + self::assertNotNull($middleware->command); + self::assertSame($command, $middleware->command); + } + + public function testException(): void + { + $command = new CreateProfile(CustomId::fromString('1')); + + $middleware = new class implements MiddlewareInterface + { + public object|null $command = null; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $this->command = $envelope->getMessage(); + + throw new RuntimeException('test'); + } + }; + $messageBus = new MessageBus([$middleware]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^test$/'); + + $commandBus = new SymfonyCommandBus($messageBus); + $commandBus->dispatch($command); + + self::assertNotNull($middleware->command); + self::assertSame($command, $middleware->command); + } + + public function testRecursiveException(): void + { + $command = new CreateProfile(CustomId::fromString('1')); + + $middleware = new class implements MiddlewareInterface + { + public object|null $command = null; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $this->command = $envelope->getMessage(); + + throw new HandlerFailedException($envelope, [new RuntimeException('test')]); + } + }; + $messageBus = new MessageBus([$middleware]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^test$/'); + + $commandBus = new SymfonyCommandBus($messageBus); + $commandBus->dispatch($command); + + self::assertNotNull($middleware->command); + self::assertSame($command, $middleware->command); + } + + public function testRecursiveExceptionStringKey(): void + { + $command = new CreateProfile(CustomId::fromString('1')); + + $middleware = new class implements MiddlewareInterface + { + public object|null $command = null; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $this->command = $envelope->getMessage(); + + throw new HandlerFailedException($envelope, ['controller-class' => new RuntimeException('test')]); + } + }; + $messageBus = new MessageBus([$middleware]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^test$/'); + + $commandBus = new SymfonyCommandBus($messageBus); + $commandBus->dispatch($command); + + self::assertNotNull($middleware->command); + self::assertSame($command, $middleware->command); + } +} diff --git a/tests/Unit/CommandBus/SymfonyCommandtBusTest.php b/tests/Unit/CommandBus/SymfonyCommandtBusTest.php deleted file mode 100644 index c59530f5..00000000 --- a/tests/Unit/CommandBus/SymfonyCommandtBusTest.php +++ /dev/null @@ -1,84 +0,0 @@ -createMock(MessageBusInterface::class); - $messageBus - ->expects($this->once()) - ->method('dispatch') - ->with($command) - ->willReturn($envelope); - - $commandBus = new SymfonyCommandBus($messageBus); - $commandBus->dispatch($command); - } - - public function testException(): void - { - $command = new CreateProfile( - CustomId::fromString('1'), - ); - $internalException = new class extends RuntimeException { - }; - $envelope = new Envelope($command); - - $messageBus = $this->createMock(MessageBusInterface::class); - $messageBus - ->expects($this->once()) - ->method('dispatch') - ->with($command) - ->willThrowException(new HandlerFailedException($envelope, [$internalException])); - - $commandBus = new SymfonyCommandBus($messageBus); - - $this->expectException($internalException::class); - - $commandBus->dispatch($command); - } - - public function testRecursiveException(): void - { - $command = new CreateProfile( - CustomId::fromString('1'), - ); - $internalException = new class extends RuntimeException { - }; - $envelope = new Envelope($command); - - $messageBus = $this->createMock(MessageBusInterface::class); - $messageBus - ->expects($this->once()) - ->method('dispatch') - ->with($command) - ->willThrowException(new HandlerFailedException( - $envelope, - [new HandlerFailedException($envelope, [$internalException])], - )); - - $commandBus = new SymfonyCommandBus($messageBus); - $this->expectException($internalException::class); - - $commandBus->dispatch($command); - } -}