diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 0000000..3e73900
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,30 @@
+# Project
+
+PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS.
+
+## Rules
+
+All coding standards, architecture, naming, testing, documentation, and OpenAPI conventions
+are defined in `rules/`. Read the applicable rule files before generating any code or documentation.
+
+## Commands
+
+- `make test` — run tests with coverage.
+- `make mutation-test` — run mutation testing (Infection).
+- `make review` — run lint.
+- `make help` — list all available commands.
+
+## Post-change validation
+
+After any code change, run `make review`, `make test`, and `make mutation-test`.
+If any fails, iterate on the fix while respecting all project rules until all pass.
+Never deliver code that breaks lint, tests, or leaves surviving mutants.
+
+## File formatting
+
+Every file produced or modified must:
+
+- Use **LF** line endings. Never CRLF.
+- Have no trailing whitespace on any line.
+- End with a single trailing newline.
+- Have no consecutive blank lines (max one blank line between blocks).
diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md
new file mode 100644
index 0000000..64587c9
--- /dev/null
+++ b/.claude/rules/documentation.md
@@ -0,0 +1,37 @@
+---
+description: Standards for README files and all project documentation in PHP libraries.
+paths:
+ - "**/*.md"
+---
+
+# Documentation
+
+## README
+
+1. Include an anchor-linked table of contents.
+2. Start with a concise one-line description of what the library does.
+3. Include a **badges** section (license, build status, coverage, latest version, PHP version).
+4. Provide an **Overview** section explaining the problem the library solves and its design philosophy.
+5. **Installation** section: Composer command (`composer require vendor/package`).
+6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example
+ includes a brief heading describing what it demonstrates.
+7. If the library exposes multiple entry points, strategies, or container types, document each with its own
+ subsection and example.
+8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users
+ frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`)
+ followed by a concise explanation. Only include entries that address real confusion points.
+9. **License** and **Contributing** sections at the end.
+10. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions.
+
+## Structured data
+
+1. When documenting constructors, factory methods, or configuration options with more than 3 parameters,
+ use tables with columns: Parameter, Type, Required, Description.
+2. Prefer tables to prose for any structured information.
+
+## Style
+
+1. Keep language concise and scannable.
+2. Never include placeholder content (`TODO`, `TBD`).
+3. Code examples must be syntactically correct and self-contained.
+4. Do not document `Internal/` classes or private API. Only document what consumers interact with.
diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md
new file mode 100644
index 0000000..a369ba4
--- /dev/null
+++ b/.claude/rules/github-workflows.md
@@ -0,0 +1,78 @@
+---
+description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files.
+paths:
+ - ".github/workflows/**/*.yml"
+ - ".github/workflows/**/*.yaml"
+---
+
+# Workflows
+
+Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used
+inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`.
+
+## Pre-output checklist
+
+Verify every item before producing any workflow YAML. If any item fails, revise before outputting.
+
+1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD.
+2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash
+ (e.g., `CD — Run migration`, not `CD — Run Migration`).
+3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger.
+4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both.
+5. Every input has a `description` field. Descriptions use American English and end with a period.
+6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`.
+7. Inputs are ordered: required first, then optional. Each group by **name length ascending**.
+8. Choice input options are in **alphabetical order**.
+9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**.
+10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`).
+11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`.
+12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`,
+ `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`.
+13. All other YAML property names within a block are ordered by **name length ascending**.
+14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`.
+15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`.
+16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`,
+ `package.json`). No version is hardcoded in any workflow.
+17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo,
+ not in the workflows repository.
+18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent.
+19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they
+ need.
+20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden.
+21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded.
+22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment.
+23. Third-party actions are pinned to the latest available full commit SHA with a version comment:
+ `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest
+ version before generating a workflow.
+24. First-party actions (`actions/*`) are pinned to the latest major version tag available:
+ `actions/checkout@v4`. Always check for the most recent major version before generating a workflow.
+25. Production deployments require GitHub Environments protection rules (manual approval).
+26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30
+ minutes. Adjust only with justification in a comment.
+27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid
+ redundant runs.
+28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to
+ prevent interrupted deployments.
+29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps.
+30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`.
+
+## Style
+
+- All text (workflow names, step names, input descriptions, comments) uses American English with correct
+ spelling and punctuation. Sentences and descriptions end with a period.
+
+## Callers
+
+- Callers trigger on `pull_request` targeting `main` only. No `push` trigger.
+- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`.
+- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production.
+
+## Image tagging
+
+- CD deploy builds: `-sha-` + `latest`.
+
+## Migrations
+
+- Migrations run **before** service deployment (schema first, code second).
+- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks.
+- Database credentials are fetched from SSM at runtime, never stored in workflow files.
diff --git a/.claude/rules/php-code-style.md b/.claude/rules/php-code-style.md
new file mode 100644
index 0000000..59323ba
--- /dev/null
+++ b/.claude/rules/php-code-style.md
@@ -0,0 +1,121 @@
+---
+description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries.
+paths:
+ - "src/**/*.php"
+ - "tests/**/*.php"
+---
+
+# Code style
+
+Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml`
+and are not repeated here. Refer to `rules/domain.md` for domain modeling rules.
+
+## Pre-output checklist
+
+Verify every item before producing any PHP code. If any item fails, revise before outputting.
+
+1. `declare(strict_types=1)` is present.
+2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is
+ designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without
+ `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class).
+3. All parameters, return types, and properties have explicit types.
+4. Constructor property promotion is used.
+5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks).
+ Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`,
+ `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`,
+ `assertTrue`, `expectException`, etc.).
+6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead.
+7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`.
+8. No generic identifiers exist. Use domain-specific names instead:
+ `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`,
+ `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`.
+9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection`
+ (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable
+ only for primitive configuration data, variadic pass-through, or interop at system boundaries.
+10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site
+ or extract it to a collaborator or value object.
+11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each
+ group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have
+ no body, are ordered by name length ascending.
+12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type),
+ except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`),
+ which takes precedence. The same rule applies to named arguments at call sites.
+ Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9).
+13. No O(N²) or worse complexity exists.
+14. No logic is duplicated across two or more places (DRY).
+15. No abstraction exists without real duplication or isolation need (KISS).
+16. All identifiers, comments, and documentation are written in American English.
+17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself.
+18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred.
+ Never leave silent gaps.
+19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited.
+20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports.
+21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not
+ need it, it does not exist.
+22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch,
+ first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods
+ over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5),
+ and `Collection::map` over foreach accumulation.
+23. No vertical alignment of types in parameter lists or property declarations. Use a single space between
+ type and variable name. Never pad with extra spaces to align columns:
+ `public OrderId $id` — not `public OrderId $id`.
+24. Opening brace `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and
+ closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last.
+
+## Casing conventions
+
+- Internal code (variables, methods, classes): **`camelCase`**.
+- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**.
+
+## Naming
+
+- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`.
+- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`,
+ `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**.
+ Prefer names that describe the domain operation.
+- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`.
+- Collections are always plural: `$orders`, `$lines`.
+- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`.
+
+## Comparisons
+
+1. Null checks: use `is_null($variable)`, never `$variable === null`.
+2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings
+ because `empty('0')` returns `true`.
+3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`.
+
+## American English
+
+All identifiers, enum values, comments, and error codes use American English spelling:
+`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`),
+`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`),
+`fulfill` (not `fulfil`), `color` (not `colour`).
+
+## PHPDoc
+
+- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`.
+- Never add PHPDoc to concrete classes.
+
+## Collection usage
+
+When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions.
+
+**Prohibited — `array_map` + `iterator_to_array` on a Collectible:**
+
+```php
+$names = array_map(
+ static fn(Element $element): string => $element->name(),
+ iterator_to_array($collection)
+);
+```
+
+**Correct — fluent chain with `map()` + `toArray()`:**
+
+```php
+$names = $collection
+ ->map(transformations: static fn(Element $element): string => $element->name())
+ ->toArray(keyPreservation: KeyPreservation::DISCARD);
+```
+
+The same applies to `filter()`, `reduce()`, `each()`, and all other `Collectible` operations. Chain them
+fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` function.
diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md
new file mode 100644
index 0000000..f3b0eea
--- /dev/null
+++ b/.claude/rules/php-domain.md
@@ -0,0 +1,96 @@
+---
+description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID.
+paths:
+ - "src/**/*.php"
+---
+
+# Domain modeling
+
+Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O.
+Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code.
+
+## Folder structure
+
+```
+src/
+├── .php # Primary contract for consumers
+├── .php # Main implementation or extension point
+├── .php # Public enum
+├── Contracts/ # Interfaces for data returned to consumers
+├── Internal/ # Implementation details (not part of public API)
+│ ├── .php
+│ └── Exceptions/ # Internal exception classes
+├── / # Feature-specific subdirectory when needed
+└── Exceptions/ # Public exception classes (when part of the API)
+```
+
+**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the
+`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators
+inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in
+`Internal/`, not at the root.
+
+The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them.
+Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names.
+
+## Nomenclature
+
+1. Every class, property, method, and exception name reflects the **domain concept** the library represents.
+ A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection
+ library uses `Collectible`, `Order`.
+2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`,
+ `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`,
+ `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes.
+3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically.
+4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`,
+ `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`,
+ `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`.
+
+## Value objects
+
+1. Are immutable: no setters, no mutation after construction. Operations return new instances.
+2. Compare by value, not by reference.
+3. Validate invariants in the constructor and throw on invalid input.
+4. Have no identity field.
+5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation
+ paths exist.
+
+## Exceptions
+
+1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.).
+2. Are pure: no formatted `code`/`message` for HTTP responses.
+3. Signal invariant violations only.
+4. Name after the invariant violated, never after the technical type:
+ `PrecisionOutOfRange` — not `InvalidPrecisionException`.
+ `CurrencyMismatch` — not `BadCurrencyException`.
+ `ContainerWaitTimeout` — not `TimeoutException`.
+5. Create the exception class directly with the invariant name and the appropriate native parent. The exception
+ is dedicated by definition when its name describes the specific invariant it guards.
+
+## Enums
+
+1. Are PHP backed enums.
+2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`).
+
+## Extension points
+
+1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class`
+ instead of `final readonly class`. All other classes use `final readonly class`.
+2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`)
+ as the only creation path.
+3. Internal state is injected via the constructor and stored in a `private readonly` property.
+
+## Principles
+
+- **Immutability**: all models and value objects adopt immutability. Operations return new instances.
+- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O.
+- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`.
+
+## SOLID reference
+
+| Principle | Failure signal |
+|---------------------------|---------------------------------------------|
+| S — Single responsibility | Class does two unrelated things |
+| O — Open/closed | Adding a feature requires editing internals |
+| L — Liskov substitution | Subclass throws on parent method |
+| I — Interface segregation | Interface has unused methods |
+| D — Dependency inversion | Constructor uses `new ConcreteClass()` |
diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-testing.md
new file mode 100644
index 0000000..7bd9e68
--- /dev/null
+++ b/.claude/rules/php-testing.md
@@ -0,0 +1,120 @@
+---
+description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries.
+paths:
+ - "tests/**/*.php"
+---
+
+# Testing conventions
+
+Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files.
+
+## Structure: Given/When/Then (BDD)
+
+Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception.
+
+### Happy path example
+
+```php
+public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void
+{
+ /** @Given two money instances in the same currency */
+ $ten = Money::of(amount: 1000, currency: Currency::BRL);
+ $five = Money::of(amount: 500, currency: Currency::BRL);
+
+ /** @When adding them together */
+ $total = $ten->add(other: $five);
+
+ /** @Then the result contains the sum of both amounts */
+ self::assertEquals(expected: 1500, actual: $total->amount());
+}
+```
+
+### Exception example
+
+When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this
+ordering.
+
+```php
+public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void
+{
+ /** @Given two money instances in different currencies */
+ $brl = Money::of(amount: 1000, currency: Currency::BRL);
+ $usd = Money::of(amount: 500, currency: Currency::USD);
+
+ /** @Then an exception indicating currency mismatch should be thrown */
+ $this->expectException(CurrencyMismatch::class);
+
+ /** @When trying to add money with different currencies */
+ $brl->add(other: $usd);
+}
+```
+
+Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or
+`@When` tags.
+
+## Rules
+
+1. Include exactly one `@When` per test. Two actions require two tests.
+2. Test only the public API. Never assert on private state or `Internal/` classes directly.
+3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem,
+ clock, network) when the library interacts with external resources.
+4. Name tests to describe behavior, not method names.
+5. Never include conditional logic inside tests.
+6. Include one logical concept per `@Then` block.
+7. Maintain strict independence between tests. No inherited state.
+8. For exception tests, place `@Then` (expectException) before `@When`.
+9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts
+ (e.g., `Amount`, `Invoice`, `Order`).
+10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries
+ (e.g., `ClientMock`, `ExecutionCompletedMock`).
+11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class
+ for an internal model only when the condition cannot be reached through the public API.
+12. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name.
+13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`,
+ `expectException`, etc.). Pass arguments positionally.
+
+## Test setup and fixtures
+
+1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line
+ followed by one expression or assignment. Never place multiple variable declarations or object
+ constructions under a single annotation.
+2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the
+ call site. Chain method calls when the intermediate state is not referenced elsewhere
+ (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`).
+3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers.
+ If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a
+ private method on the test class.
+4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`,
+ `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object
+ represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like
+ `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters.
+5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not
+ `/** @Given a mocked collection in test state */`. The annotation describes the domain
+ scenario, not the technical setup.
+
+## Test organization
+
+```
+tests/
+├── Models/ # Domain-specific fixtures reused across tests
+├── Mocks/ # Test doubles for system boundaries
+├── Unit/ # Unit tests for public API
+│ └── Mocks/ # Alternative location for test doubles
+├── Integration/ # Tests requiring real external resources (Docker, filesystem)
+└── bootstrap.php # Test bootstrap when needed
+```
+
+- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API.
+- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases).
+ Only present when the library interacts with infrastructure.
+- `tests/Models/`: domain-specific fixture classes reused across test files.
+- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries.
+
+## Coverage and mutation testing
+
+1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration
+ that exclude code from coverage are allowed.
+2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist`
+ or any other mechanism.
+3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor
+ the code to make it testable, do not work around the tool.
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..73e3c9a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[Makefile]
+indent_style = tab
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..28337dc
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,30 @@
+# Auto detect text files and perform LF normalization
+* text=auto eol=lf
+
+# ─── Diff drivers ────────────────────────────────────────────
+*.php diff=php
+*.md diff=markdown
+
+# ─── Force LF ────────────────────────────────────────────────
+*.sh text eol=lf
+Makefile text eol=lf
+
+# ─── Generated (skip diff and GitHub stats) ──────────────────
+composer.lock -diff linguist-generated
+
+# ─── Export ignore (excluded from dist archive) ──────────────
+/tests export-ignore
+/vendor export-ignore
+/rules export-ignore
+
+/.github export-ignore
+/.gitignore export-ignore
+/.gitattributes export-ignore
+
+/CLAUDE.md export-ignore
+/LICENSE export-ignore
+/Makefile export-ignore
+/README.md export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon.dist export-ignore
+/infection.json.dist export-ignore
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..77c2bb8
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,11 @@
+# Copilot instructions
+
+## Context
+
+PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core.
+
+## Mandatory pre-task step
+
+Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and
+`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not
+deviate from the patterns, folder structure, or naming conventions defined in them.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f0ce8fc
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,31 @@
+version: 2
+
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 0
+ labels:
+ - "php"
+ - "security"
+ - "dependencies"
+ groups:
+ php-security:
+ applies-to: security-updates
+ patterns:
+ - "*"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "build"
+ labels:
+ - "dependencies"
+ - "github-actions"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
new file mode 100644
index 0000000..d0ba49e
--- /dev/null
+++ b/.github/workflows/auto-assign.yml
@@ -0,0 +1,25 @@
+name: Auto assign issues and pull requests
+
+on:
+ issues:
+ types:
+ - opened
+ pull_request:
+ types:
+ - opened
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Assign issues and pull requests
+ uses: gustavofreze/auto-assign@2.1.0
+ with:
+ assignees: '${{ vars.ASSIGNEES }}'
+ github_token: '${{ secrets.GITHUB_TOKEN }}'
+ allow_self_assign: 'true'
+ allow_no_assignees: 'true'
+ assignment_options: 'ISSUE,PULL_REQUEST'
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..71e59e0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+name: CI
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ PHP_VERSION: '8.5'
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Validate composer.json
+ run: composer validate --no-interaction
+
+ - name: Install dependencies
+ run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction
+
+ - name: Upload vendor and composer.lock as artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: vendor-artifact
+ path: |
+ vendor
+ composer.lock
+
+ auto-review:
+ name: Auto review
+ runs-on: ubuntu-latest
+ needs: build
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run review
+ run: composer review
+
+ tests:
+ name: Tests
+ runs-on: ubuntu-latest
+ needs: auto-review
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ tools: composer:2
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run tests
+ run: composer tests
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..4c6d7f7
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,35 @@
+name: Security checks
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: "0 0 * * *"
+
+permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ "actions" ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..85fc064
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.idea
+
+vendor
+report
+
+*.lock
+.phpunit.*
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3b630e0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024-2026 Tiny Blocks
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..07acc3b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,73 @@
+PWD := $(CURDIR)
+ARCH := $(shell uname -m)
+PLATFORM :=
+
+ifeq ($(ARCH),arm64)
+ PLATFORM := --platform=linux/amd64
+endif
+
+DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine
+
+RESET := \033[0m
+GREEN := \033[0;32m
+YELLOW := \033[0;33m
+
+.DEFAULT_GOAL := help
+
+.PHONY: configure
+configure: ## Configure development environment
+ @${DOCKER_RUN} composer update --optimize-autoloader
+ @${DOCKER_RUN} composer normalize
+
+.PHONY: test
+test: ## Run all tests with coverage
+ @${DOCKER_RUN} composer tests
+
+.PHONY: test-file
+test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest)
+ @${DOCKER_RUN} composer test-file ${FILE}
+
+.PHONY: test-no-coverage
+test-no-coverage: ## Run all tests without coverage
+ @${DOCKER_RUN} composer tests-no-coverage
+
+.PHONY: review
+review: ## Run static code analysis
+ @${DOCKER_RUN} composer review
+
+.PHONY: show-reports
+show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser
+ @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html
+
+.PHONY: show-outdated
+show-outdated: ## Show outdated direct dependencies
+ @${DOCKER_RUN} composer outdated --direct
+
+.PHONY: clean
+clean: ## Remove dependencies and generated artifacts
+ @sudo chown -R ${USER}:${USER} ${PWD}
+ @rm -rf report vendor .phpunit.cache *.lock
+
+.PHONY: help
+help: ## Display this help message
+ @echo "Usage: make [target]"
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
+ @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
+ @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
+ @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')"
+ @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')"
+ @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
diff --git a/README.md b/README.md
index a7bdf22..036b9d6 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,455 @@
-# building-blocks
-Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly.
+# Building Blocks
+
+[](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE)
+
+* [Overview](#overview)
+* [Installation](#installation)
+* [How to use](#how-to-use)
+ + [Entity](#entity)
+ + [Aggregate](#aggregate)
+ + [Domain events with transactional outbox](#domain-events-with-transactional-outbox)
+ + [Event sourcing](#event-sourcing)
+ + [Snapshots](#snapshots)
+ + [Upcasting](#upcasting)
+* [FAQ](#faq)
+* [License](#license)
+* [Contributing](#contributing)
+
+## Overview
+
+The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`,
+`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox
+or an event-sourced store.
+
+It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives
+(`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers.
+
+Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not
+replace PSR-14; it defines what flows through it.
+
+## Installation
+
+```
+composer require tiny-blocks/building-blocks
+```
+
+## How to use
+
+The library exposes three styles of aggregate modeling through sibling interfaces:
+
+* `AggregateRoot` for plain DDD modeling without events.
+* `EventualAggregateRoot` for aggregates that persist state and emit events as side effects via a transactional
+ outbox.
+* `EventSourcingRoot` for aggregates whose state is derived entirely from their ordered event stream.
+
+### Entity
+
+Every entity declares an `IDENTITY` constant pointing to the property that holds its `Identity`.
+
+#### Single-field identity
+
+* `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, etc.).
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Entity\SingleIdentity;
+ use TinyBlocks\BuildingBlocks\Entity\SingleIdentityBehavior;
+
+ final readonly class OrderId implements SingleIdentity
+ {
+ use SingleIdentityBehavior;
+
+ public function __construct(public string $value)
+ {
+ }
+ }
+
+ $orderId = new OrderId(value: 'ord-1');
+ $orderId->getIdentityValue();
+ ```
+
+#### Compound identity
+
+* `CompoundIdentity`: identity composed of multiple fields treated as a tuple.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Entity\CompoundIdentity;
+ use TinyBlocks\BuildingBlocks\Entity\CompoundIdentityBehavior;
+
+ final readonly class AppointmentId implements CompoundIdentity
+ {
+ use CompoundIdentityBehavior;
+
+ public function __construct(
+ public string $tenantId,
+ public string $appointmentId
+ ) {
+ }
+ }
+
+ $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+ $appointmentId->getIdentityValue();
+ ```
+
+#### Identity access
+
+* `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity
+ that declares the `IDENTITY` constant.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot;
+ use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior;
+
+ final class User implements AggregateRoot
+ {
+ use AggregateRootBehavior;
+
+ private const string IDENTITY = 'userId';
+
+ private function __construct(private UserId $userId, private string $email)
+ {
+ }
+ }
+
+ $user->sameIdentityOf(other: $otherUser);
+ $user->identityEquals(other: new UserId(value: 'usr-1'));
+ ```
+
+### Aggregate
+
+`AggregateRoot` adds two pragmatic fields to Evans' aggregate: a monotonic `SequenceNumber` for optimistic concurrency
+control and a `ModelVersion` for schema evolution of the aggregate type itself.
+
+* `getSequenceNumber`: the current sequence number, starting at zero for a blank aggregate.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot;
+ use TinyBlocks\BuildingBlocks\Aggregate\AggregateRootBehavior;
+
+ final class User implements AggregateRoot
+ {
+ use AggregateRootBehavior;
+
+ private const string IDENTITY = 'userId';
+ }
+
+ $user->getSequenceNumber();
+ ```
+
+* `getModelVersion`: resolved from the optional `MODEL_VERSION` class constant, defaults to zero when absent.
+
+ ```php
+ final class Cart implements AggregateRoot
+ {
+ use AggregateRootBehavior;
+
+ private const string IDENTITY = 'cartId';
+ private const int MODEL_VERSION = 1;
+ }
+
+ $cart->getModelVersion();
+ ```
+
+* `buildAggregateName`: short class name, used as the aggregate type identifier on each `EventRecord`.
+
+ ```php
+ $user->buildAggregateName();
+ ```
+
+### Domain events with transactional outbox
+
+`EventualAggregateRoot` records domain events during the unit of work. State is the source of truth; events are
+emitted as side effects and must be delivered at-least-once.
+
+#### Declaring events
+
+* `DomainEvent`: empty marker interface. A domain event is a plain PHP object.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Event\DomainEvent;
+
+ final readonly class OrderPlaced implements DomainEvent
+ {
+ public function __construct(public string $item)
+ {
+ }
+ }
+ ```
+
+#### Emitting events from the aggregate
+
+* `pushEvent`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a
+ fully-built `EventRecord` to the recorded buffer. The `Revision` is provided on the call site, so the event class
+ stays pure.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot;
+ use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior;
+ use TinyBlocks\BuildingBlocks\Event\Revision;
+
+ final class Order implements EventualAggregateRoot
+ {
+ use EventualAggregateRootBehavior;
+
+ private const string IDENTITY = 'orderId';
+
+ private function __construct(private OrderId $orderId)
+ {
+ }
+
+ public static function place(OrderId $orderId, string $item): Order
+ {
+ $order = new Order(orderId: $orderId);
+ $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1));
+
+ return $order;
+ }
+ }
+ ```
+
+#### Draining events in the repository
+
+* `recordedEvents`: returns a fresh copy of the buffer, safe to iterate without mutating the aggregate.
+* `clearRecordedEvents`: discards the buffer, typically called after persisting the events.
+
+ ```php
+ $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book');
+
+ foreach ($order->recordedEvents() as $record) {
+ $outbox->append(record: $record);
+ }
+
+ $order->clearRecordedEvents();
+ ```
+
+### Event sourcing
+
+`EventSourcingRoot` stores no state of its own; state is derived by replaying the event stream.
+
+#### Applying events to state
+
+* `when`: protected method that records the event and immediately applies it to state by dispatching to a
+ `when` method by reflection.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
+ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior;
+ use TinyBlocks\BuildingBlocks\Event\Revision;
+ use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
+
+ final class Cart implements EventSourcingRoot
+ {
+ use EventSourcingRootBehavior;
+
+ private const string IDENTITY = 'cartId';
+
+ private CartId $cartId;
+ private array $productIds = [];
+
+ public function addProduct(string $productId): void
+ {
+ $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1));
+ }
+
+ public function applySnapshot(Snapshot $snapshot): void
+ {
+ $this->productIds = $snapshot->getAggregateState()['productIds'] ?? [];
+ }
+
+ protected function whenProductAdded(ProductAdded $event): void
+ {
+ $this->productIds[] = $event->productId;
+ }
+ }
+ ```
+
+#### Creating a blank aggregate
+
+* `blank`: factory that instantiates the aggregate without invoking its constructor. All state must come from events
+ or from a snapshot.
+
+ ```php
+ $cart = Cart::blank(identity: new CartId(value: 'cart-1'));
+ ```
+
+#### Replaying an event stream
+
+* `reconstitute`: replays an ordered stream of `EventRecord` instances, optionally starting from a snapshot to skip
+ earlier events. When a snapshot is provided, its sequence number is authoritative.
+
+ ```php
+ $cart = Cart::reconstitute(identity: new CartId(value: 'cart-1'), records: $records);
+ ```
+
+ ```php
+ $cart = Cart::reconstitute(
+ identity: new CartId(value: 'cart-1'),
+ records: $laterRecords,
+ snapshot: $snapshot
+ );
+ ```
+
+### Snapshots
+
+Snapshots let the event store skip replay of early events when reconstituting a long-lived aggregate.
+
+#### Capturing a snapshot
+
+* `Snapshot::fromAggregate`: reads all declared properties except `recordedEvents` and `sequenceNumber`. Both are
+ tracked outside `aggregateState` because the snapshot has dedicated fields for them.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
+
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+ ```
+
+#### Persisting a snapshot
+
+* `Snapshotter`: port for snapshot persistence. The `SnapshotterBehavior` trait captures the snapshot and delegates
+ storage to a concrete `persist` hook.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
+ use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter;
+ use TinyBlocks\BuildingBlocks\Snapshot\SnapshotterBehavior;
+
+ final class FileSnapshotter implements Snapshotter
+ {
+ use SnapshotterBehavior;
+
+ protected function persist(Snapshot $snapshot): void
+ {
+ file_put_contents('/var/snapshots/cart.json', $snapshot->getAggregateState());
+ }
+ }
+
+ $snapshotter = new FileSnapshotter();
+ $snapshotter->take(aggregate: $cart);
+ ```
+
+#### Deciding when to snapshot
+
+* `SnapshotCondition`: strategy for deciding whether a snapshot should be taken at a given point.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
+ use TinyBlocks\BuildingBlocks\Snapshot\SnapshotCondition;
+
+ final class EveryHundredEvents implements SnapshotCondition
+ {
+ public function shouldSnapshot(EventSourcingRoot $aggregate): bool
+ {
+ return $aggregate->getSequenceNumber()->value % 100 === 0;
+ }
+ }
+ ```
+
+### Upcasting
+
+Upcasters migrate serialized events across schema changes without touching the event classes.
+
+#### Defining an upcaster
+
+* `Upcaster`: transforms one `(type, revision)` pair forward by one step. Chains of upcasters handle multistep
+ evolution. The `SingleUpcasterBehavior` trait binds the upcaster to a specific migration via three class constants.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Upcast\SingleUpcasterBehavior;
+ use TinyBlocks\BuildingBlocks\Upcast\Upcaster;
+
+ final class ProductV1Upcaster implements Upcaster
+ {
+ use SingleUpcasterBehavior;
+
+ private const string EXPECTED_EVENT_TYPE = 'ProductAdded';
+ private const int FROM_REVISION = 1;
+ private const int TO_REVISION = 2;
+
+ protected function doUpcast(array $data): array
+ {
+ return [...$data, 'quantity' => 1];
+ }
+ }
+ ```
+
+#### Upcasting an event
+
+* `upcast`: transforms the event if it matches the expected `(type, revision)`, otherwise returns it unchanged.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Event\EventType;
+ use TinyBlocks\BuildingBlocks\Event\Revision;
+ use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent;
+
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ $upcasted = new ProductV1Upcaster()->upcast(event: $event);
+ ```
+
+#### Default values for new fields
+
+* `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field.
+
+ ```php
+ use TinyBlocks\BuildingBlocks\Upcast\DefaultValues;
+
+ $defaults = DefaultValues::get();
+ ```
+
+## FAQ
+
+### 01. Why is `DomainEvent` an empty marker interface?
+
+A domain event is a fact about something that happened in the domain. It has no technical contract beyond being that
+fact. Persistence and transport concerns (type name, revision, aggregate identity) belong to `EventRecord`, not to
+the event itself. Keeping the event pure prevents infrastructure concerns from leaking into the domain model.
+
+### 02. Why does `EventualAggregateRoot` store `EventRecord` instead of `DomainEvent`?
+
+Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type
+name. Storing raw events and wrapping them later would either duplicate that context or require a second pass.
+`pushEvent` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation.
+
+### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy?
+
+Outbox and event sourcing are mutually exclusive persistence strategies. An aggregate either persists its state and
+emits events as side effects, or persists only its events as the source of truth. A common base beyond `AggregateRoot`
+would imply the two patterns can coexist on the same aggregate, which they cannot.
+
+### 04. Why does `Revision` live on the call site instead of on the event class?
+
+Keeping `Revision` on the `pushEvent` or `when` call site makes the aggregate the author of schema evolution. The
+event class stays pure. Bumping the revision of an existing event does not require creating a new class.
+
+### 05. Why does `blank` skip the constructor?
+
+`EventSourcingRootBehavior::blank` instantiates the aggregate via reflection without invoking its constructor because
+all aggregate state in an event-sourced model must come from events or from a snapshot. Any invariants established by
+the constructor would contradict that principle. Concrete aggregates should treat their constructor as private and
+reserved for internal use.
+
+### 06. Why are `recordedEvents` and `sequenceNumber` excluded from `Snapshot::aggregateState`?
+
+`recordedEvents` belongs to the current unit of work, not to the aggregate's intrinsic state. `sequenceNumber` is
+already carried by the snapshot as a first-class field, so duplicating it inside `aggregateState` would force
+consumers to decide which copy is authoritative.
+
+### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace?
+
+Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, `MissingIdentityConstant`
+and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or
+`RuntimeException` from the PHP standard library, so consumers that catch the broad standard types continue to work;
+consumers that need precise handling can catch the specific classes.
+
+## License
+
+Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE).
+
+## Contributing
+
+Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
+contribute to the project.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..a343f8e
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,70 @@
+{
+ "name": "tiny-blocks/building-blocks",
+ "description": "Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly.",
+ "license": "MIT",
+ "type": "library",
+ "authors": [
+ {
+ "name": "Gustavo Freze de Araujo Santos",
+ "homepage": "https://github.com/gustavofreze"
+ }
+ ],
+ "homepage": "https://github.com/tiny-blocks/building-blocks",
+ "support": {
+ "issues": "https://github.com/tiny-blocks/building-blocks/issues",
+ "source": "https://github.com/tiny-blocks/building-blocks"
+ },
+ "require": {
+ "php": "^8.5",
+ "ramsey/uuid": "^4.9",
+ "tiny-blocks/collection": "^2.3",
+ "tiny-blocks/immutable-object": "^1.1",
+ "tiny-blocks/time": "^1.5",
+ "tiny-blocks/value-object": "^3.2"
+ },
+ "require-dev": {
+ "ergebnis/composer-normalize": "^2.51",
+ "infection/infection": "^0.32",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^13.1",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "autoload": {
+ "psr-4": {
+ "TinyBlocks\\BuildingBlocks\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\TinyBlocks\\BuildingBlocks\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "ergebnis/composer-normalize": true,
+ "infection/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
+ "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
+ "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
+ "review": [
+ "@phpcs",
+ "@phpstan"
+ ],
+ "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
+ "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
+ "tests": [
+ "@test",
+ "@mutation-test"
+ ],
+ "tests-no-coverage": [
+ "@test-no-coverage"
+ ]
+ }
+}
diff --git a/infection.json.dist b/infection.json.dist
new file mode 100644
index 0000000..ee435dd
--- /dev/null
+++ b/infection.json.dist
@@ -0,0 +1,24 @@
+{
+ "logs": {
+ "text": "report/infection/logs/infection-text.log",
+ "summary": "report/infection/logs/infection-summary.log"
+ },
+ "tmpDir": "report/infection/",
+ "minMsi": 100,
+ "timeout": 30,
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "phpUnit": {
+ "configDir": "",
+ "customPath": "./vendor/bin/phpunit"
+ },
+ "mutators": {
+ "@default": true,
+ "ProtectedVisibility": false
+ },
+ "minCoveredMsi": 100,
+ "testFramework": "phpunit"
+}
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..6115fe6
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,14 @@
+parameters:
+ paths:
+ - src
+ level: 9
+ tmpDir: report/phpstan
+ ignoreErrors:
+ - '#mixed given#'
+ - '#iterable type#'
+ - '#of new static#'
+ - '#generic interface#'
+ - '#destructuring on mixed#'
+ - identifier: trait.unused
+ - '#expects int<1, max>#'
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..40c80a2
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+ src
+
+
+
+
+
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php
new file mode 100644
index 0000000..ddbc59c
--- /dev/null
+++ b/src/Aggregate/AggregateRoot.php
@@ -0,0 +1,64 @@
+External references must target the root; invariants apply to the whole cluster; transactions never
+ * straddle aggregate boundaries. This interface adds two pragmatic fields absent from Evans:
+ *
+ *
+ *
sequenceNumber for optimistic offline locking.
+ *
modelVersion for aggregate schema evolution.
+ *
+ *
+ *
Three sibling variants build on this base: plain aggregates without events, {@see EventualAggregateRoot}
+ * for the transactional outbox pattern, and {@see EventSourcingRoot} for the event-sourced style.
+ *
+ * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
+ * (Addison-Wesley, 2003), Chapter 6 "Aggregates".
+ * @see Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002),
+ * "Optimistic Offline Lock", source of sequenceNumber.
+ * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), source of
+ * modelVersion.
+ */
+interface AggregateRoot extends Entity
+{
+ /**
+ * Returns the aggregate's current sequence number.
+ *
+ *
The initial value is 0. The first recorded event increments it to 1,
+ * and each subsequent event advances it by one. Persistence adapters compare the stored value against
+ * the in-memory one to detect concurrent modifications.
+ *
+ * @return SequenceNumber The current sequence number.
+ */
+ public function getSequenceNumber(): SequenceNumber;
+
+ /**
+ * Returns the schema version of this aggregate type.
+ *
+ *
Resolved from the optional MODEL_VERSION class constant, defaults to 0
+ * when the constant is not declared. Used by consumers to migrate aggregate schemas when loading older
+ * persisted state.
+ *
+ * @return SequenceNumber The declared model version, or 0 when undefined.
+ */
+ public function getModelVersion(): SequenceNumber;
+
+ /**
+ * Returns the short class name of this aggregate.
+ *
+ *
Used as the aggregate type identifier on each produced
+ * {@see \TinyBlocks\BuildingBlocks\Event\EventRecord}.
+ *
+ * @return string The short class name.
+ */
+ public function buildAggregateName(): string;
+}
diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php
new file mode 100644
index 0000000..6cb166b
--- /dev/null
+++ b/src/Aggregate/AggregateRootBehavior.php
@@ -0,0 +1,70 @@
+sequenceNumber ?? SequenceNumber::initial();
+ }
+
+ public function getModelVersion(): SequenceNumber
+ {
+ if (!defined('static::MODEL_VERSION')) {
+ return new SequenceNumber(value: 0);
+ }
+
+ return new SequenceNumber(value: static::MODEL_VERSION);
+ }
+
+ public function buildAggregateName(): string
+ {
+ return new ReflectionClass(objectOrClass: static::class)->getShortName();
+ }
+
+ protected function nextSequenceNumber(): void
+ {
+ $this->sequenceNumber = $this->getSequenceNumber()->next();
+ }
+
+ protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord
+ {
+ return new EventRecord(
+ id: Uuid::uuid4(),
+ type: EventType::fromEvent(event: $event),
+ event: $event,
+ identity: $this->getIdentity(),
+ revision: $revision,
+ occurredOn: Instant::now(),
+ snapshotData: $this->generateSnapshotData(),
+ aggregateType: $this->buildAggregateName(),
+ sequenceNumber: $this->getSequenceNumber()
+ );
+ }
+
+ protected function generateSnapshotData(): SnapshotData
+ {
+ $state = get_object_vars($this);
+ unset($state['recordedEvents']);
+
+ return new SnapshotData(data: $state);
+ }
+}
diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php
new file mode 100644
index 0000000..7e19e9c
--- /dev/null
+++ b/src/Aggregate/EventSourcingRoot.php
@@ -0,0 +1,73 @@
+The event store is the source of truth; aggregate state is a projection. Instances are created
+ * through {@see blank()} and populated by replaying events via {@see reconstitute()}, optionally starting
+ * from a {@see Snapshot} to skip earlier events.
+ *
+ *
Sibling of {@see EventualAggregateRoot}, not a parent. The default implementation instantiates
+ * aggregates via reflection without invoking the constructor, so implementations must derive all state
+ * from events or from a snapshot, never from constructor logic.
+ *
+ * @see Greg Young, CQRS Documents (2010), "Event Sourcing".
+ * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8,
+ * "Event Sourcing" section.
+ */
+interface EventSourcingRoot extends AggregateRoot
+{
+ /**
+ * Returns the events recorded during the current unit of work.
+ *
+ * @return EventRecords The events awaiting append to the event store.
+ */
+ public function recordedEvents(): EventRecords;
+
+ /**
+ * Creates a blank aggregate with the given identity and no recorded events.
+ *
+ *
The constructor is not invoked. All state must come from events or from a snapshot.
+ *
+ * @param Identity $identity The identity to assign to the new aggregate.
+ * @return static A new aggregate in its initial state.
+ * @throws MissingIdentityConstant When the IDENTITY class constant is not defined.
+ */
+ public static function blank(Identity $identity): static;
+
+ /**
+ * Reconstitutes an aggregate by replaying an ordered stream of event records.
+ *
+ *
When a snapshot is provided, the aggregate state is first restored from it and the snapshot's
+ * sequence number is taken as authoritative. Only events recorded after the snapshot need to be
+ * replayed.
+ *
+ * @param Identity $identity The identity of the aggregate.
+ * @param iterable $records The event stream to replay, ordered by sequence number.
+ * @param Snapshot|null $snapshot Optional snapshot to restore from before replay.
+ * @return static The reconstituted aggregate.
+ * @throws MissingIdentityConstant When the IDENTITY class constant is not defined.
+ */
+ public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;
+
+ /**
+ * Restores aggregate state from the given snapshot.
+ *
+ *
Implementations read {@see Snapshot::getAggregateState()} and copy the relevant fields into
+ * their own properties. The sequence number is applied automatically by
+ * reconstitute(); implementations should not touch it.
+ *
+ * @param Snapshot $snapshot The snapshot to restore from.
+ */
+ public function applySnapshot(Snapshot $snapshot): void;
+}
diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php
new file mode 100644
index 0000000..fa02b38
--- /dev/null
+++ b/src/Aggregate/EventSourcingRootBehavior.php
@@ -0,0 +1,78 @@
+recordedEvents ?? EventRecords::createFromEmpty();
+
+ return EventRecords::createFrom(elements: $records);
+ }
+
+ public static function blank(Identity $identity): static
+ {
+ if (!defined('static::IDENTITY')) {
+ throw new MissingIdentityConstant(className: static::class);
+ }
+
+ $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
+ new ReflectionProperty($aggregate, static::IDENTITY)->setValue(objectOrValue: $aggregate, value: $identity);
+ $aggregate->sequenceNumber = SequenceNumber::initial();
+ $aggregate->recordedEvents = EventRecords::createFromEmpty();
+
+ return $aggregate;
+ }
+
+ public static function reconstitute(
+ Identity $identity,
+ iterable $records,
+ ?Snapshot $snapshot = null
+ ): static {
+ $aggregate = static::blank(identity: $identity);
+
+ if (!is_null($snapshot)) {
+ $aggregate->applySnapshot(snapshot: $snapshot);
+ $aggregate->sequenceNumber = $snapshot->getSequenceNumber();
+ }
+
+ foreach ($records as $record) {
+ $aggregate->applyEvent(record: $record);
+ }
+
+ return $aggregate;
+ }
+
+ protected function when(DomainEvent $event, Revision $revision): void
+ {
+ $this->nextSequenceNumber();
+ $record = $this->buildEventRecord(event: $event, revision: $revision);
+ $this->applyEvent(record: $record);
+ $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())->add($record);
+ }
+
+ protected function applyEvent(EventRecord $record): void
+ {
+ $methodName = 'when' . new ReflectionClass(objectOrClass: $record->event)->getShortName();
+ $this->{$methodName}($record->event);
+ $this->sequenceNumber = $record->sequenceNumber;
+ }
+}
diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php
new file mode 100644
index 0000000..2a8fc9f
--- /dev/null
+++ b/src/Aggregate/EventualAggregateRoot.php
@@ -0,0 +1,43 @@
+State is persisted as the source of truth; events are emitted as side effects and delivered
+ * at-least-once to external consumers. The repository is expected to drain
+ * recordedEvents() after persisting the aggregate state and then call
+ * clearRecordedEvents() to reset the buffer for the next unit of work.
+ *
+ *
Sibling of {@see EventSourcingRoot}, not a parent. Outbox and event sourcing are mutually exclusive
+ * persistence strategies: an aggregate either persists its state and emits events as side effects, or
+ * persists only its events as the source of truth.
+ *
+ * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8
+ * "Domain Events".
+ * @see Chris Richardson, Microservices Patterns (Manning, 2018), Chapter 3, "Transactional Outbox".
+ */
+interface EventualAggregateRoot extends AggregateRoot
+{
+ /**
+ * Returns a copy of the events recorded since the last clear.
+ *
+ *
Always returns a fresh copy: external mutation of the returned collection does not leak into the
+ * aggregate's internal buffer.
+ *
+ * @return EventRecords A snapshot of the recorded events, safe to iterate and mutate.
+ */
+ public function recordedEvents(): EventRecords;
+
+ /**
+ * Discards all recorded events.
+ *
+ *
Typically called by the repository after the events have been persisted to the outbox.
+ */
+ public function clearRecordedEvents(): void;
+}
diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php
new file mode 100644
index 0000000..8a30a79
--- /dev/null
+++ b/src/Aggregate/EventualAggregateRootBehavior.php
@@ -0,0 +1,35 @@
+recordedEvents ?? EventRecords::createFromEmpty();
+
+ return EventRecords::createFrom(elements: $records);
+ }
+
+ public function clearRecordedEvents(): void
+ {
+ $this->recordedEvents = EventRecords::createFromEmpty();
+ }
+
+ protected function pushEvent(DomainEvent $event, Revision $revision): void
+ {
+ $this->nextSequenceNumber();
+ $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
+ ->add($this->buildEventRecord(event: $event, revision: $revision));
+ }
+}
diff --git a/src/Entity/CompoundIdentity.php b/src/Entity/CompoundIdentity.php
new file mode 100644
index 0000000..df8d82c
--- /dev/null
+++ b/src/Entity/CompoundIdentity.php
@@ -0,0 +1,21 @@
+Pragmatic extension for the common case of identifiers that require more than one field to be
+ * unique (for example (tenantId, appointmentId) in multi-tenant contexts). Not a concept
+ * from Evans.
+ *
+ *
All declared properties participate in the identity: getIdentityValue() returns them
+ * as an associative array keyed by property name.
+ */
+interface CompoundIdentity extends Identity, ValueObject
+{
+}
diff --git a/src/Entity/CompoundIdentityBehavior.php b/src/Entity/CompoundIdentityBehavior.php
new file mode 100644
index 0000000..55bf559
--- /dev/null
+++ b/src/Entity/CompoundIdentityBehavior.php
@@ -0,0 +1,17 @@
+An entity is distinguished not by its attributes but by a thread of identity that remains stable
+ * across distinct representations and lifecycle transitions. Two entities are equal when their
+ * identities are equal, regardless of attribute differences.
+ *
+ *
Concrete entities declare the IDENTITY class constant pointing to the property that
+ * holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.
+ *
+ * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
+ * (Addison-Wesley, 2003), Chapter 5 "Entities (a.k.a. Reference Objects)".
+ */
+interface Entity
+{
+ /**
+ * Returns the Identity that uniquely identifies this entity.
+ *
+ * @return Identity The identity instance held by this entity.
+ * @throws MissingIdentityConstant When the IDENTITY class constant is not defined.
+ * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist.
+ */
+ public function getIdentity(): Identity;
+
+ /**
+ * Returns the name of the property that holds this entity's Identity.
+ *
+ * @return string The property name, resolved from the IDENTITY class constant.
+ * @throws MissingIdentityConstant When the IDENTITY class constant is not defined.
+ * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist.
+ */
+ public function getIdentityName(): string;
+
+ /**
+ * Returns the raw value of this entity's identity.
+ *
+ *
The shape of the returned value depends on the kind of identity held: a scalar for
+ * {@see SingleIdentity}, an associative array for {@see CompoundIdentity}.
+ *
+ * @return mixed The raw identity value.
+ */
+ public function getIdentityValue(): mixed;
+
+ /**
+ * Checks whether this entity and the given one share the same identity.
+ *
+ * @param Entity $other The entity whose identity will be compared.
+ * @return bool True when both entities hold equal identities.
+ */
+ public function sameIdentityOf(Entity $other): bool;
+
+ /**
+ * Checks whether the given Identity is equal to this entity's identity.
+ *
+ * @param Identity $other The identity to compare against.
+ * @return bool True when the given identity equals this entity's identity.
+ */
+ public function identityEquals(Identity $other): bool;
+}
diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php
new file mode 100644
index 0000000..5cccb42
--- /dev/null
+++ b/src/Entity/EntityBehavior.php
@@ -0,0 +1,46 @@
+{$this->getIdentityName()};
+ }
+
+ public function getIdentityValue(): mixed
+ {
+ return $this->getIdentity()->getIdentityValue();
+ }
+
+ public function sameIdentityOf(Entity $other): bool
+ {
+ return $this->identityEquals(other: $other->getIdentity());
+ }
+
+ public function identityEquals(Identity $other): bool
+ {
+ return $this->getIdentity() == $other;
+ }
+}
diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php
new file mode 100644
index 0000000..d6f1255
--- /dev/null
+++ b/src/Entity/Identity.php
@@ -0,0 +1,31 @@
+Identity is the stable thread that allows an Entity to be recognized across distinct representations
+ * and lifecycle states. Being {@see Immutable}, it cannot change once constructed: a new identity must be
+ * created for a new entity.
+ *
+ *
Implementations are expected to also be value objects for equality purposes. See the two shipped
+ * variants: {@see SingleIdentity} for scalar-backed identifiers and {@see CompoundIdentity} for
+ * multi-field tuples.
+ *
+ * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
+ * (Addison-Wesley, 2003), Chapter 5 "Entities".
+ */
+interface Identity extends Immutable
+{
+ /**
+ * Returns the raw value of this identity.
+ *
+ * @return mixed A scalar value for single-field identities, an associative array for composite ones.
+ */
+ public function getIdentityValue(): mixed;
+}
diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php
new file mode 100644
index 0000000..e9d0c9b
--- /dev/null
+++ b/src/Entity/SingleIdentity.php
@@ -0,0 +1,21 @@
+Pragmatic extension for the common case of identifiers backed by a single field (UUID string,
+ * auto-increment integer, slug, etc.). Not a concept from Evans: the book makes no distinction between
+ * single-value and composite identities.
+ *
+ *
Implementations should declare exactly one property holding the scalar value; the default trait
+ * reads it by reflection and returns it from getIdentityValue().
+ */
+interface SingleIdentity extends Identity, ValueObject
+{
+}
diff --git a/src/Entity/SingleIdentityBehavior.php b/src/Entity/SingleIdentityBehavior.php
new file mode 100644
index 0000000..4430a9e
--- /dev/null
+++ b/src/Entity/SingleIdentityBehavior.php
@@ -0,0 +1,19 @@
+A Domain Event is a fact that domain experts care about. It has no technical contract beyond being
+ * that fact: persistence and transport concerns such as type name, revision, or envelope metadata belong
+ * to {@see EventRecord}, not here. Keeping the event pure prevents infrastructure concerns from leaking
+ * into the domain model.
+ *
+ *
Being a plain PHP object, any implementation is compatible with PSR-14
+ * (Psr\EventDispatcher\EventDispatcherInterface) without additional adaptation.
+ *
+ * @see Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), Chapter 8
+ * "Domain Events".
+ */
+interface DomainEvent
+{
+}
diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php
new file mode 100644
index 0000000..d8f19e8
--- /dev/null
+++ b/src/Event/EventRecord.php
@@ -0,0 +1,29 @@
+getShortName());
+ }
+
+ public static function fromString(string $value): EventType
+ {
+ return new EventType(value: $value);
+ }
+}
diff --git a/src/Event/Revision.php b/src/Event/Revision.php
new file mode 100644
index 0000000..51f0fce
--- /dev/null
+++ b/src/Event/Revision.php
@@ -0,0 +1,21 @@
+value + 1);
+ }
+
+ public function isAfter(SequenceNumber $other): bool
+ {
+ return $this->value > $other->value;
+ }
+}
diff --git a/src/Event/SnapshotData.php b/src/Event/SnapshotData.php
new file mode 100644
index 0000000..dded14b
--- /dev/null
+++ b/src/Event/SnapshotData.php
@@ -0,0 +1,27 @@
+data;
+ }
+
+ public function toJson(int $flags = JSON_PRESERVE_ZERO_FRACTION): string
+ {
+ return json_encode(value: $this->data, flags: $flags | JSON_THROW_ON_ERROR);
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Internal/Exceptions/InvalidEventType.php
new file mode 100644
index 0000000..edc61c3
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidEventType.php
@@ -0,0 +1,17 @@
+ does not match the required pattern <%s>.', $value, $pattern)
+ );
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Internal/Exceptions/InvalidRevision.php
new file mode 100644
index 0000000..df12692
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidRevision.php
@@ -0,0 +1,17 @@
+.', $value)
+ );
+ }
+}
diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php
new file mode 100644
index 0000000..8258fcf
--- /dev/null
+++ b/src/Internal/Exceptions/InvalidSequenceNumber.php
@@ -0,0 +1,17 @@
+.', $value)
+ );
+ }
+}
diff --git a/src/Internal/Exceptions/MissingIdentityConstant.php b/src/Internal/Exceptions/MissingIdentityConstant.php
new file mode 100644
index 0000000..3327ea7
--- /dev/null
+++ b/src/Internal/Exceptions/MissingIdentityConstant.php
@@ -0,0 +1,15 @@
+.', $className));
+ }
+}
diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Internal/Exceptions/MissingIdentityProperty.php
new file mode 100644
index 0000000..2faebea
--- /dev/null
+++ b/src/Internal/Exceptions/MissingIdentityProperty.php
@@ -0,0 +1,21 @@
+ referenced by IDENTITY constant does not exist in <%s>.',
+ $propertyName,
+ $className
+ )
+ );
+ }
+}
diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php
new file mode 100644
index 0000000..bdaac1c
--- /dev/null
+++ b/src/Snapshot/Snapshot.php
@@ -0,0 +1,71 @@
+getProperties() as $property) {
+ if (!in_array(needle: $property->getName(), haystack: ['recordedEvents', 'sequenceNumber'], strict: true)) {
+ $aggregateState[$property->getName()] = $property->getValue($aggregate);
+ }
+ }
+
+ return new Snapshot(
+ type: $aggregate->buildAggregateName(),
+ createdAt: Instant::now(),
+ aggregateId: $aggregate->getIdentityValue(),
+ aggregateState: $aggregateState,
+ sequenceNumber: $aggregate->getSequenceNumber()
+ );
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function getCreatedAt(): Instant
+ {
+ return $this->createdAt;
+ }
+
+ public function getAggregateId(): mixed
+ {
+ return $this->aggregateId;
+ }
+
+ public function getAggregateState(): array
+ {
+ return $this->aggregateState;
+ }
+
+ public function getSequenceNumber(): SequenceNumber
+ {
+ return $this->sequenceNumber;
+ }
+}
diff --git a/src/Snapshot/SnapshotCondition.php b/src/Snapshot/SnapshotCondition.php
new file mode 100644
index 0000000..d1ddbf7
--- /dev/null
+++ b/src/Snapshot/SnapshotCondition.php
@@ -0,0 +1,26 @@
+Typical implementations check the aggregate's sequence number against a threshold (for example, take
+ * a snapshot every N events) or combine sequence checks with a time-based policy. Keeping the
+ * decision behind a strategy lets consumers mix and match policies per aggregate type without branching
+ * inside the snapshotter.
+ */
+interface SnapshotCondition
+{
+ /**
+ * Decides whether a snapshot of the given aggregate should be taken now.
+ *
+ * @param EventSourcingRoot $aggregate The aggregate under evaluation.
+ * @return bool True when a snapshot should be taken.
+ */
+ public function shouldSnapshot(EventSourcingRoot $aggregate): bool;
+}
diff --git a/src/Snapshot/Snapshotter.php b/src/Snapshot/Snapshotter.php
new file mode 100644
index 0000000..ce67677
--- /dev/null
+++ b/src/Snapshot/Snapshotter.php
@@ -0,0 +1,29 @@
+Infrastructure adapters implement this interface to store snapshots in whatever backend is
+ * appropriate: a relational database table keyed by aggregate id, a document store, object storage, or
+ * even an in-process cache. The domain layer depends only on this contract and remains unaware of the
+ * underlying mechanism.
+ *
+ *
The shipped {@see SnapshotterBehavior} trait captures the snapshot via {@see Snapshot::fromAggregate}
+ * and delegates the storage step to a concrete persist() hook, leaving adapters with only
+ * the storage concern to implement.
+ */
+interface Snapshotter
+{
+ /**
+ * Captures and persists a snapshot of the given aggregate.
+ *
+ * @param EventSourcingRoot $aggregate The aggregate to snapshot.
+ */
+ public function take(EventSourcingRoot $aggregate): void;
+}
diff --git a/src/Snapshot/SnapshotterBehavior.php b/src/Snapshot/SnapshotterBehavior.php
new file mode 100644
index 0000000..0ca59f6
--- /dev/null
+++ b/src/Snapshot/SnapshotterBehavior.php
@@ -0,0 +1,17 @@
+persist(snapshot: Snapshot::fromAggregate(aggregate: $aggregate));
+ }
+
+ abstract protected function persist(Snapshot $snapshot): void;
+}
diff --git a/src/Upcast/DefaultValues.php b/src/Upcast/DefaultValues.php
new file mode 100644
index 0000000..f8c32c1
--- /dev/null
+++ b/src/Upcast/DefaultValues.php
@@ -0,0 +1,19 @@
+ 0,
+ 'bool' => false,
+ 'array' => [],
+ 'float' => 0.0,
+ 'string' => ''
+ ];
+ }
+}
diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php
new file mode 100644
index 0000000..12da42f
--- /dev/null
+++ b/src/Upcast/IntermediateEvent.php
@@ -0,0 +1,40 @@
+type,
+ revision: $revision,
+ serializedEvent: $this->serializedEvent
+ );
+ }
+
+ public function withSerializedEvent(array $serializedEvent): IntermediateEvent
+ {
+ return new IntermediateEvent(
+ type: $this->type,
+ revision: $this->revision,
+ serializedEvent: $serializedEvent
+ );
+ }
+}
diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php
new file mode 100644
index 0000000..f19511f
--- /dev/null
+++ b/src/Upcast/SingleUpcasterBehavior.php
@@ -0,0 +1,27 @@
+type->value !== static::EXPECTED_EVENT_TYPE) {
+ return $event;
+ }
+
+ if ($event->revision->value !== static::FROM_REVISION) {
+ return $event;
+ }
+
+ return $event
+ ->withSerializedEvent(serializedEvent: $this->doUpcast(data: $event->serializedEvent))
+ ->withRevision(revision: new Revision(value: static::TO_REVISION));
+ }
+
+ abstract protected function doUpcast(array $data): array;
+}
diff --git a/src/Upcast/Upcaster.php b/src/Upcast/Upcaster.php
new file mode 100644
index 0000000..70c3e5a
--- /dev/null
+++ b/src/Upcast/Upcaster.php
@@ -0,0 +1,32 @@
+A single upcaster advances exactly one (type, revision) pair forward by one step.
+ * Chains of upcasters handle multistep schema evolution: each upcaster in the chain either transforms
+ * the event when the type and revision match, or returns it unchanged. This preserves compatibility with
+ * historical events already stored at older revisions without requiring retroactive event rewrites.
+ *
+ *
The shipped {@see SingleUpcasterBehavior} trait binds an upcaster to a specific
+ * (EXPECTED_EVENT_TYPE, FROM_REVISION, TO_REVISION) triple through class constants and
+ * delegates the payload transformation to an abstract doUpcast() hook.
+ *
+ * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017),
+ * "Basic Type Based Conversion" and "Upcasting".
+ */
+interface Upcaster
+{
+ /**
+ * Transforms the given intermediate event if it matches the expected type and revision.
+ *
+ * @param IntermediateEvent $event The event to transform.
+ * @return IntermediateEvent The transformed event, or the input unchanged when this upcaster does
+ * not apply.
+ */
+ public function upcast(IntermediateEvent $event): IntermediateEvent;
+}
diff --git a/tests/Aggregate/AggregateRootBehaviorTest.php b/tests/Aggregate/AggregateRootBehaviorTest.php
new file mode 100644
index 0000000..0bfaab2
--- /dev/null
+++ b/tests/Aggregate/AggregateRootBehaviorTest.php
@@ -0,0 +1,74 @@
+getSequenceNumber();
+
+ /** @Then it is zero */
+ self::assertSame(0, $sequenceNumber->value);
+ }
+
+ public function testGetModelVersionReflectsDeclaredConstant(): void
+ {
+ /** @Given an aggregate declaring MODEL_VERSION = 1 */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+
+ /** @When retrieving the model version */
+ $version = $cart->getModelVersion();
+
+ /** @Then the version reflects the constant */
+ self::assertSame(1, $version->value);
+ }
+
+ public function testGetModelVersionDefaultsToZeroWhenUndefined(): void
+ {
+ /** @Given an aggregate without MODEL_VERSION constant */
+ $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen');
+
+ /** @When retrieving the model version */
+ $version = $order->getModelVersion();
+
+ /** @Then the default is zero */
+ self::assertSame(0, $version->value);
+ }
+
+ public function testBuildAggregateNameForEventSourcedAggregate(): void
+ {
+ /** @Given a Cart aggregate */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+
+ /** @When building the aggregate name */
+ $name = $cart->buildAggregateName();
+
+ /** @Then it matches the short class name */
+ self::assertSame('Cart', $name);
+ }
+
+ public function testBuildAggregateNameForOutboxAggregate(): void
+ {
+ /** @Given an Order aggregate */
+ $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'lamp');
+
+ /** @When building the aggregate name */
+ $name = $order->buildAggregateName();
+
+ /** @Then it matches the short class name */
+ self::assertSame('Order', $name);
+ }
+}
diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php
new file mode 100644
index 0000000..b6c4251
--- /dev/null
+++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php
@@ -0,0 +1,289 @@
+getSequenceNumber()->value);
+ }
+
+ public function testBlankAggregateStartsWithEmptyDomainState(): void
+ {
+ /** @Given a cart identity */
+ $cartId = new CartId(value: 'cart-1b');
+
+ /** @When creating a blank cart */
+ $cart = Cart::blank(identity: $cartId);
+
+ /** @Then the aggregate's domain state is empty */
+ self::assertSame([], $cart->getProductIds());
+ }
+
+ public function testBlankAggregateCarriesTheGivenIdentity(): void
+ {
+ /** @Given a cart identity */
+ $cartId = new CartId(value: 'cart-1c');
+
+ /** @When creating a blank cart */
+ $cart = Cart::blank(identity: $cartId);
+
+ /** @Then the aggregate exposes the given identity */
+ self::assertSame($cartId, $cart->getIdentity());
+ }
+
+ public function testBlankAggregateStartsWithNoRecordedEvents(): void
+ {
+ /** @Given a cart identity */
+ $cartId = new CartId(value: 'cart-1d');
+
+ /** @When creating a blank cart */
+ $cart = Cart::blank(identity: $cartId);
+
+ /** @Then the recorded events buffer is empty */
+ self::assertTrue($cart->recordedEvents()->isEmpty());
+ }
+
+ public function testDomainOperationAppliesStateFromEmittedEvent(): void
+ {
+ /** @Given a blank cart */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+
+ /** @When adding a product */
+ $cart->addProduct(productId: 'prod-1');
+
+ /** @Then the domain state reflects the event */
+ self::assertSame(['prod-1'], $cart->getProductIds());
+ }
+
+ public function testDomainOperationAdvancesSequenceNumber(): void
+ {
+ /** @Given a blank cart */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+
+ /** @And two products added in sequence */
+ $cart->addProduct(productId: 'prod-1');
+ $cart->addProduct(productId: 'prod-2');
+
+ /** @When retrieving the sequence number */
+ $sequenceNumber = $cart->getSequenceNumber();
+
+ /** @Then the sequence number equals the number of events */
+ self::assertSame(2, $sequenceNumber->value);
+ }
+
+ public function testDomainOperationAppendsToRecordedEvents(): void
+ {
+ /** @Given a blank cart */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-4'));
+
+ /** @When adding a product */
+ $cart->addProduct(productId: 'prod-1');
+
+ /** @Then one event is recorded */
+ self::assertSame(1, $cart->recordedEvents()->count());
+ }
+
+ public function testFirstRecordedEventCarriesEnvelopeMetadata(): void
+ {
+ /** @Given a blank cart with a known identity */
+ $cartId = new CartId(value: 'cart-5');
+
+ /** @And a product added to the cart */
+ $cart = Cart::blank(identity: $cartId);
+ $cart->addProduct(productId: 'prod-abc');
+
+ /** @When inspecting the first recorded record */
+ $record = $cart->recordedEvents()->first();
+
+ /** @Then the envelope carries the expected metadata */
+ self::assertSame('ProductAdded', $record->type->value);
+ self::assertSame(1, $record->revision->value);
+ self::assertSame(1, $record->sequenceNumber->value);
+ self::assertSame('Cart', $record->aggregateType);
+ self::assertInstanceOf(ProductAdded::class, $record->event);
+ self::assertSame($cartId, $record->identity);
+ self::assertSame('prod-abc', $record->event->productId);
+ }
+
+ public function testReconstituteReplaysEventsInOrder(): void
+ {
+ /** @Given a cart with two products added */
+ $cartId = new CartId(value: 'cart-6');
+ $original = Cart::blank(identity: $cartId);
+ $original->addProduct(productId: 'prod-1');
+ $original->addProduct(productId: 'prod-2');
+
+ /** @When reconstituting from the event stream */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+
+ /** @Then the replayed state preserves event order */
+ self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds());
+ }
+
+ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream(): void
+ {
+ /** @Given a cart that received products in a distinctive order */
+ $cartId = new CartId(value: 'cart-6b');
+ $original = Cart::blank(identity: $cartId);
+ $original->addProduct(productId: 'zebra');
+ $original->addProduct(productId: 'apple');
+ $original->addProduct(productId: 'mango');
+
+ /** @When reconstituting from the event stream */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+
+ /** @Then the replayed state preserves the exact insertion order */
+ self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->getProductIds());
+ }
+
+ public function testReconstituteAdvancesSequenceNumberToLastEvent(): void
+ {
+ /** @Given a cart with two products added */
+ $cartId = new CartId(value: 'cart-6c');
+ $original = Cart::blank(identity: $cartId);
+ $original->addProduct(productId: 'prod-1');
+ $original->addProduct(productId: 'prod-2');
+
+ /** @When reconstituting from the event stream */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+
+ /** @Then the sequence number equals the last event's */
+ self::assertSame(2, $reconstituted->getSequenceNumber()->value);
+ }
+
+ public function testReconstituteWithEmptyStreamYieldsBlankState(): void
+ {
+ /** @Given a cart identity and no events */
+ $cartId = new CartId(value: 'cart-7');
+
+ /** @When reconstituting with no events */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: []);
+
+ /** @Then the state matches a blank aggregate */
+ self::assertSame([], $reconstituted->getProductIds());
+ }
+
+ public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): void
+ {
+ /** @Given a cart identity and no events */
+ $cartId = new CartId(value: 'cart-7b');
+
+ /** @When reconstituting with no events */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: []);
+
+ /** @Then the sequence number remains at the initial value */
+ self::assertSame(0, $reconstituted->getSequenceNumber()->value);
+ }
+
+ public function testReconstituteFromSnapshotRestoresDomainState(): void
+ {
+ /** @Given a cart with one product and a snapshot taken at that point */
+ $cartId = new CartId(value: 'cart-8');
+ $cart = Cart::blank(identity: $cartId);
+ $cart->addProduct(productId: 'prod-snapshot');
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @When reconstituting from the snapshot only */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+
+ /** @Then the domain state is fully restored */
+ self::assertSame(['prod-snapshot'], $reconstituted->getProductIds());
+ }
+
+ public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void
+ {
+ /** @Given a cart with one product and a snapshot taken at that point */
+ $cartId = new CartId(value: 'cart-8b');
+ $cart = Cart::blank(identity: $cartId);
+ $cart->addProduct(productId: 'prod-snapshot');
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @When reconstituting from the snapshot only */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+
+ /** @Then the sequence number matches the snapshot's */
+ self::assertSame(1, $reconstituted->getSequenceNumber()->value);
+ }
+
+ public function testReconstituteCombinesSnapshotWithLaterEvents(): void
+ {
+ /** @Given a cart snapshotted after one product, then more events after the snapshot */
+ $cartId = new CartId(value: 'cart-8c');
+ $cart = Cart::blank(identity: $cartId);
+ $cart->addProduct(productId: 'prod-1');
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+ $cart->addProduct(productId: 'prod-2');
+ $laterRecords = $cart->recordedEvents()->filter(
+ predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber())
+ );
+
+ /** @When reconstituting from the snapshot and the later records */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
+
+ /** @Then the full state is restored */
+ self::assertSame(['prod-1', 'prod-2'], $reconstituted->getProductIds());
+ }
+
+ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void
+ {
+ /** @Given a cart snapshotted after one product, then more events after the snapshot */
+ $cartId = new CartId(value: 'cart-8d');
+ $cart = Cart::blank(identity: $cartId);
+ $cart->addProduct(productId: 'prod-1');
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+ $cart->addProduct(productId: 'prod-2');
+ $laterRecords = $cart->recordedEvents()->filter(
+ predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber())
+ );
+
+ /** @When reconstituting from the snapshot and the later records */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
+
+ /** @Then the sequence number reflects the last applied event */
+ self::assertSame(2, $reconstituted->getSequenceNumber()->value);
+ }
+
+ public function testBlankThrowsWhenIdentityConstantIsMissing(): void
+ {
+ /** @Given an event-sourced aggregate class omitting the IDENTITY constant */
+ /** @Then a MissingIdentityConstant exception carrying the class name is thrown */
+ $this->expectException(MissingIdentityConstant::class);
+ $this->expectExceptionMessage(CartWithoutIdentityConstant::class);
+
+ /** @When creating a blank aggregate */
+ CartWithoutIdentityConstant::blank(identity: new CartId(value: 'cart-missing'));
+ }
+
+ public function testReconstitutedAggregateHasNoRecordedEvents(): void
+ {
+ /** @Given a cart with one recorded event */
+ $cartId = new CartId(value: 'cart-9');
+ $original = Cart::blank(identity: $cartId);
+ $original->addProduct(productId: 'prod-1');
+
+ /** @When reconstituting from that event stream */
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+
+ /** @Then the reconstituted aggregate has no fresh recorded events */
+ self::assertTrue($reconstituted->recordedEvents()->isEmpty());
+ }
+}
diff --git a/tests/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Aggregate/EventualAggregateRootBehaviorTest.php
new file mode 100644
index 0000000..2db78e8
--- /dev/null
+++ b/tests/Aggregate/EventualAggregateRootBehaviorTest.php
@@ -0,0 +1,161 @@
+getSequenceNumber();
+
+ /** @Then the sequence number is 1 */
+ self::assertSame(1, $sequenceNumber->value);
+ }
+
+ public function testSequenceNumberAdvancesOnEverySubsequentEvent(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'DHL');
+
+ /** @When retrieving the sequence number */
+ $sequenceNumber = $order->getSequenceNumber();
+
+ /** @Then the sequence number reflects every emitted event */
+ self::assertSame(2, $sequenceNumber->value);
+ }
+
+ public function testRecordedEventsCountMatchesEmittedEvents(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-3'), item: 'lamp');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'FedEx');
+
+ /** @When retrieving recorded events */
+ $records = $order->recordedEvents();
+
+ /** @Then the count matches the number of events */
+ self::assertSame(2, $records->count());
+ }
+
+ public function testFirstRecordedEventCarriesPlacementMetadata(): void
+ {
+ /** @Given an identity for the placed order */
+ $orderId = new OrderId(value: 'ord-4');
+
+ /** @And a placed order emitting an OrderPlaced event */
+ $order = Order::place(orderId: $orderId, item: 'chair');
+
+ /** @When inspecting the first recorded record */
+ $record = $order->recordedEvents()->first();
+
+ /** @Then the envelope carries the placement metadata */
+ self::assertSame('OrderPlaced', $record->type->value);
+ self::assertSame(1, $record->revision->value);
+ self::assertSame(1, $record->sequenceNumber->value);
+ self::assertSame('Order', $record->aggregateType);
+ self::assertInstanceOf(OrderPlaced::class, $record->event);
+ self::assertSame($orderId, $record->identity);
+ self::assertSame('chair', $record->event->item);
+ }
+
+ public function testSecondRecordedEventCarriesShippingMetadata(): void
+ {
+ /** @Given a placed order */
+ $order = Order::place(orderId: new OrderId(value: 'ord-4b'), item: 'chair');
+
+ /** @And a shipping event emitted after placement */
+ $order->ship(carrier: 'UPS');
+
+ /** @When inspecting the last recorded record */
+ $record = $order->recordedEvents()->last();
+
+ /** @Then the envelope carries the shipping metadata */
+ self::assertSame('OrderShipped', $record->type->value);
+ self::assertSame(2, $record->sequenceNumber->value);
+ self::assertInstanceOf(OrderShipped::class, $record->event);
+ self::assertSame('UPS', $record->event->carrier);
+ }
+
+ public function testClearRecordedEventsResetsTheBuffer(): void
+ {
+ /** @Given an order with recorded events */
+ $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'desk');
+
+ /** @When clearing the buffer */
+ $order->clearRecordedEvents();
+
+ /** @Then no events remain */
+ self::assertTrue($order->recordedEvents()->isEmpty());
+ }
+
+ public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
+ {
+ /** @Given an order with one recorded event */
+ $order = Order::place(orderId: new OrderId(value: 'ord-6'), item: 'mug');
+
+ /** @And an external mutation applied to the first retrieved copy */
+ $order->recordedEvents()->add($order->recordedEvents()->first());
+
+ /** @When retrieving the recorded events again */
+ $secondCopy = $order->recordedEvents();
+
+ /** @Then the aggregate's own buffer is unaffected by the external mutation */
+ self::assertSame(1, $secondCopy->count());
+ }
+
+ public function testRecordedEventsIsEmptyAfterClear(): void
+ {
+ /** @Given an order that was placed and immediately cleared */
+ $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle');
+
+ /** @And the buffer cleared */
+ $order->clearRecordedEvents();
+
+ /** @When retrieving recorded events */
+ $records = $order->recordedEvents();
+
+ /** @Then the collection is empty */
+ self::assertTrue($records->isEmpty());
+ }
+
+ public function testSnapshotDataCapturesDomainStateOnEveryEvent(): void
+ {
+ /** @Given an order that transitioned to 'placed' */
+ $order = Order::place(orderId: new OrderId(value: 'ord-9'), item: 'tray');
+
+ /** @When inspecting the snapshot data carried by the event record */
+ $state = $order->recordedEvents()->first()->snapshotData->toArray();
+
+ /** @Then the domain status field is captured with its current value */
+ self::assertSame('placed', $state['status']);
+ }
+
+ public function testSnapshotDataOmitsTransientRecordedEventsBuffer(): void
+ {
+ /** @Given an order that emits a placement event */
+ $order = Order::place(orderId: new OrderId(value: 'ord-10'), item: 'tray');
+
+ /** @When inspecting the persistable state attached to the record */
+ $state = $order->recordedEvents()->first()->snapshotData->toArray();
+
+ /** @Then the recording buffer is not part of the persisted state */
+ self::assertArrayNotHasKey('recordedEvents', $state);
+ }
+}
diff --git a/tests/Entity/CompoundIdentityBehaviorTest.php b/tests/Entity/CompoundIdentityBehaviorTest.php
new file mode 100644
index 0000000..11ad0c3
--- /dev/null
+++ b/tests/Entity/CompoundIdentityBehaviorTest.php
@@ -0,0 +1,68 @@
+getIdentityValue();
+
+ /** @Then both fields are returned in an associative array */
+ self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
+ }
+
+ public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void
+ {
+ /** @Given two compound identities with identical field values */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a matching counterpart */
+ $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are considered equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseWhenTenantDiffers(): void
+ {
+ /** @Given two compound identities differing on the tenant */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a counterpart with a different tenant */
+ $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+
+ public function testEqualsReturnsFalseWhenAppointmentDiffers(): void
+ {
+ /** @Given two compound identities differing on the appointment */
+ $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @And a counterpart with a different appointment */
+ $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php
new file mode 100644
index 0000000..38da3fd
--- /dev/null
+++ b/tests/Entity/EntityBehaviorTest.php
@@ -0,0 +1,154 @@
+getIdentity();
+
+ /** @Then the same identity instance is returned */
+ self::assertSame($orderId, $identity);
+ }
+
+ public function testGetIdentityNameReturnsPropertyName(): void
+ {
+ /** @Given an order aggregate */
+ $order = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen');
+
+ /** @When retrieving the identity property name */
+ $name = $order->getIdentityName();
+
+ /** @Then it matches the IDENTITY constant value */
+ self::assertSame('orderId', $name);
+ }
+
+ public function testGetIdentityValueReturnsScalarForSingleIdentity(): void
+ {
+ /** @Given an order whose identity is a single-value identifier */
+ $order = Order::place(orderId: new OrderId(value: 'ord-42'), item: 'pen');
+
+ /** @When retrieving the identity value */
+ $value = $order->getIdentityValue();
+
+ /** @Then the raw scalar is returned */
+ self::assertSame('ord-42', $value);
+ }
+
+ public function testGetIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void
+ {
+ /** @Given a compound identity */
+ $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1');
+
+ /** @When retrieving the identity value */
+ $value = $appointmentId->getIdentityValue();
+
+ /** @Then an associative array with all fields is returned */
+ self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value);
+ }
+
+ public function testSameIdentityOfReturnsTrueForAggregatesWithEqualIdentity(): void
+ {
+ /** @Given two orders sharing the same identity value */
+ $first = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book');
+
+ /** @And a second order with the same identity value */
+ $second = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen');
+
+ /** @When comparing their identities */
+ $result = $first->sameIdentityOf(other: $second);
+
+ /** @Then the comparison yields true */
+ self::assertTrue($result);
+ }
+
+ public function testSameIdentityOfReturnsFalseForAggregatesWithDifferentIdentity(): void
+ {
+ /** @Given two orders with different identities */
+ $first = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'book');
+
+ /** @And a second order with a different identity */
+ $second = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen');
+
+ /** @When comparing their identities */
+ $result = $first->sameIdentityOf(other: $second);
+
+ /** @Then the comparison yields false */
+ self::assertFalse($result);
+ }
+
+ public function testIdentityEqualsReturnsTrueForEqualIdentity(): void
+ {
+ /** @Given an order and an identity with the same value */
+ $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'lamp');
+
+ /** @And a separately constructed identity of equal value */
+ $sameIdentity = new OrderId(value: 'ord-5');
+
+ /** @When comparing the identity */
+ $result = $order->identityEquals(other: $sameIdentity);
+
+ /** @Then the comparison yields true */
+ self::assertTrue($result);
+ }
+
+ public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void
+ {
+ /** @Given an order and an identity with a different value */
+ $order = Order::place(orderId: new OrderId(value: 'ord-5'), item: 'lamp');
+
+ /** @And a different identity value */
+ $otherIdentity = new OrderId(value: 'ord-9');
+
+ /** @When comparing the identity */
+ $result = $order->identityEquals(other: $otherIdentity);
+
+ /** @Then the comparison yields false */
+ self::assertFalse($result);
+ }
+
+ public function testShipThrowsWhenIdentityConstantIsMissing(): void
+ {
+ /** @Given an aggregate whose class omits the IDENTITY constant */
+ $order = new OrderWithoutIdentityConstant();
+
+ /** @Then a MissingIdentityConstant exception carrying the class name is thrown */
+ $this->expectException(MissingIdentityConstant::class);
+ $this->expectExceptionMessage(OrderWithoutIdentityConstant::class);
+
+ /** @When shipping the order and indirectly reaching identity resolution */
+ $order->ship();
+ }
+
+ public function testShipThrowsWhenIdentityPropertyIsMissing(): void
+ {
+ /** @Given an aggregate whose IDENTITY points to a non-existent property */
+ $order = new OrderWithMissingIdentityProperty();
+
+ /** @Then a MissingIdentityProperty exception carrying the property name is thrown */
+ $this->expectException(MissingIdentityProperty::class);
+ $this->expectExceptionMessage('nonExistentProperty');
+
+ /** @When shipping the order and indirectly reaching identity resolution */
+ $order->ship();
+ }
+}
diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Entity/SingleIdentityBehaviorTest.php
new file mode 100644
index 0000000..aea3603
--- /dev/null
+++ b/tests/Entity/SingleIdentityBehaviorTest.php
@@ -0,0 +1,53 @@
+getIdentityValue();
+
+ /** @Then the scalar value is returned as-is */
+ self::assertSame('ord-1', $value);
+ }
+
+ public function testEqualsReturnsTrueForIdenticalSingleIdentities(): void
+ {
+ /** @Given two single identities with the same value */
+ $first = new OrderId(value: 'ord-1');
+
+ /** @And a matching counterpart */
+ $second = new OrderId(value: 'ord-1');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are considered equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentSingleIdentities(): void
+ {
+ /** @Given two single identities with different values */
+ $first = new OrderId(value: 'ord-1');
+
+ /** @And a distinct counterpart */
+ $second = new OrderId(value: 'ord-2');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php
new file mode 100644
index 0000000..d61f8c5
--- /dev/null
+++ b/tests/Event/EventRecordTest.php
@@ -0,0 +1,141 @@
+ 'placed']);
+ $sequenceNumber = new SequenceNumber(value: 1);
+
+ /** @When constructing the EventRecord */
+ $record = new EventRecord(
+ id: $id,
+ type: $eventType,
+ event: $placedEvent,
+ identity: $orderId,
+ revision: $revision,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @Then each public field is accessible with the expected value */
+ self::assertSame($id, $record->id);
+ self::assertSame($eventType, $record->type);
+ self::assertSame($placedEvent, $record->event);
+ self::assertSame($orderId, $record->identity);
+ self::assertSame($revision, $record->revision);
+ self::assertSame($occurredOn, $record->occurredOn);
+ self::assertSame($snapshotData, $record->snapshotData);
+ self::assertSame('Order', $record->aggregateType);
+ self::assertSame($sequenceNumber, $record->sequenceNumber);
+ }
+
+ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void
+ {
+ /** @Given shared values for two EventRecord instances */
+ $id = Uuid::uuid4();
+ $orderId = new OrderId(value: 'ord-1');
+ $placedEvent = new OrderPlaced(item: 'book');
+ $eventType = EventType::fromString(value: 'OrderPlaced');
+ $revision = new Revision(value: 1);
+ $occurredOn = Instant::now();
+ $snapshotData = new SnapshotData(data: []);
+ $sequenceNumber = new SequenceNumber(value: 1);
+
+ /** @And two records constructed from those identical values */
+ $first = new EventRecord(
+ id: $id,
+ type: $eventType,
+ event: $placedEvent,
+ identity: $orderId,
+ revision: $revision,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+ $second = new EventRecord(
+ id: $id,
+ type: $eventType,
+ event: $placedEvent,
+ identity: $orderId,
+ revision: $revision,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
+ {
+ /** @Given shared values except the identifier */
+ $orderId = new OrderId(value: 'ord-1');
+ $placedEvent = new OrderPlaced(item: 'book');
+ $eventType = EventType::fromString(value: 'OrderPlaced');
+ $revision = new Revision(value: 1);
+ $occurredOn = Instant::now();
+ $snapshotData = new SnapshotData(data: []);
+ $sequenceNumber = new SequenceNumber(value: 1);
+
+ /** @And two records with different UUIDs */
+ $first = new EventRecord(
+ id: Uuid::uuid4(),
+ type: $eventType,
+ event: $placedEvent,
+ identity: $orderId,
+ revision: $revision,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+ $second = new EventRecord(
+ id: Uuid::uuid4(),
+ type: $eventType,
+ event: $placedEvent,
+ identity: $orderId,
+ revision: $revision,
+ occurredOn: $occurredOn,
+ snapshotData: $snapshotData,
+ aggregateType: 'Order',
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php
new file mode 100644
index 0000000..2f0c6d4
--- /dev/null
+++ b/tests/Event/EventRecordsTest.php
@@ -0,0 +1,80 @@
+isEmpty();
+
+ /** @Then the collection is empty */
+ self::assertTrue($result);
+ }
+
+ public function testAddingARecordYieldsACollectionOfOneElement(): void
+ {
+ /** @Given an empty EventRecords collection */
+ $records = EventRecords::createFromEmpty();
+
+ /** @And a freshly built event record */
+ $record = new EventRecord(
+ id: Uuid::uuid4(),
+ type: EventType::fromString(value: 'OrderPlaced'),
+ event: new OrderPlaced(item: 'book'),
+ identity: new OrderId(value: 'ord-1'),
+ revision: new Revision(value: 1),
+ occurredOn: Instant::now(),
+ snapshotData: new SnapshotData(data: []),
+ aggregateType: 'Order',
+ sequenceNumber: new SequenceNumber(value: 1)
+ );
+
+ /** @When adding the record */
+ $updated = $records->add($record);
+
+ /** @Then the count is one */
+ self::assertSame(1, $updated->count());
+ }
+
+ public function testFirstElementRoundTripsTheAddedRecord(): void
+ {
+ /** @Given a record added to an empty EventRecords collection */
+ $record = new EventRecord(
+ id: Uuid::uuid4(),
+ type: EventType::fromString(value: 'OrderPlaced'),
+ event: new OrderPlaced(item: 'book'),
+ identity: new OrderId(value: 'ord-1'),
+ revision: new Revision(value: 1),
+ occurredOn: Instant::now(),
+ snapshotData: new SnapshotData(data: []),
+ aggregateType: 'Order',
+ sequenceNumber: new SequenceNumber(value: 1)
+ );
+ $records = EventRecords::createFromEmpty()->add($record);
+
+ /** @When retrieving the first element */
+ $first = $records->first();
+
+ /** @Then it matches the record added */
+ self::assertSame($record, $first);
+ }
+}
diff --git a/tests/Event/EventTypeTest.php b/tests/Event/EventTypeTest.php
new file mode 100644
index 0000000..7f7b63c
--- /dev/null
+++ b/tests/Event/EventTypeTest.php
@@ -0,0 +1,114 @@
+value);
+ }
+
+ public function testFromStringAcceptsValidPascalCase(): void
+ {
+ /** @Given a valid PascalCase string */
+ /** @When creating an EventType from the string */
+ $eventType = EventType::fromString(value: 'OrderShipped');
+
+ /** @Then the value is stored */
+ self::assertSame('OrderShipped', $eventType->value);
+ }
+
+ public function testEqualsReturnsTrueForSameValue(): void
+ {
+ /** @Given two event types with the same name */
+ $first = EventType::fromString(value: 'OrderPlaced');
+
+ /** @And a matching counterpart */
+ $second = EventType::fromString(value: 'OrderPlaced');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentValues(): void
+ {
+ /** @Given two event types with different names */
+ $first = EventType::fromString(value: 'OrderPlaced');
+
+ /** @And a distinct counterpart */
+ $second = EventType::fromString(value: 'OrderShipped');
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+
+ #[DataProvider('invalidPatterns')]
+ public function testConstructorRejectsValuesNotMatchingPattern(string $invalidValue): void
+ {
+ /** @Given a value that violates the event-type pattern */
+ /** @Then an InvalidEventType exception mentioning the pattern is thrown */
+ $this->expectException(InvalidEventType::class);
+ $this->expectExceptionMessage('does not match the required pattern');
+
+ /** @When constructing with the invalid value */
+ new EventType(value: $invalidValue);
+ }
+
+ public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): void
+ {
+ /** @Given consumer code catching the PHP-standard InvalidArgumentException */
+ /** @Then InvalidEventType is caught by the standard exception type */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When constructing with an invalid value */
+ new EventType(value: 'invalid');
+ }
+
+ public function testInvalidEventTypeCarriesTheOffendingValue(): void
+ {
+ /** @Given a consumer inspecting the exception message */
+ /** @Then the offending value is included in the message */
+ $this->expectException(InvalidEventType::class);
+ $this->expectExceptionMessage('lowercaseStart');
+
+ /** @When constructing with an invalid value */
+ new EventType(value: 'lowercaseStart');
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidPatterns(): array
+ {
+ return [
+ 'lowercase start' => ['orderPlaced'],
+ 'contains spaces' => ['Order Placed'],
+ 'empty string' => [''],
+ 'contains underscore' => ['Order_Placed'],
+ 'single character' => ['O']
+ ];
+ }
+}
diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php
new file mode 100644
index 0000000..b20f308
--- /dev/null
+++ b/tests/Event/RevisionTest.php
@@ -0,0 +1,109 @@
+value);
+ }
+
+ public function testRevisionStoresAHigherValidValue(): void
+ {
+ /** @Given a larger valid revision value */
+ /** @When constructing the revision */
+ $revision = new Revision(value: 42);
+
+ /** @Then the value is stored verbatim */
+ self::assertSame(42, $revision->value);
+ }
+
+ public function testEqualsReturnsTrueForSameRevision(): void
+ {
+ /** @Given two revisions with the same value */
+ $first = new Revision(value: 2);
+
+ /** @And a matching counterpart */
+ $second = new Revision(value: 2);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentRevisions(): void
+ {
+ /** @Given two revisions with different values */
+ $first = new Revision(value: 1);
+
+ /** @And a distinct counterpart */
+ $second = new Revision(value: 2);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+
+ #[DataProvider('invalidValues')]
+ public function testConstructorRejectsNonPositiveValue(int $invalidValue): void
+ {
+ /** @Given a value that violates the revision invariant */
+ /** @Then an InvalidRevision exception carrying the invalid value is thrown */
+ $this->expectException(InvalidRevision::class);
+ $this->expectExceptionMessage((string) $invalidValue);
+
+ /** @When constructing with a non-positive value */
+ new Revision(value: $invalidValue);
+ }
+
+ public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void
+ {
+ /** @Given consumer code catching the PHP-standard InvalidArgumentException */
+ /** @Then InvalidRevision is caught by the standard exception type */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When constructing with an invalid value */
+ new Revision(value: 0);
+ }
+
+ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void
+ {
+ /** @Given a consumer inspecting the exception message */
+ /** @Then the message mentions the minimum allowed value */
+ $this->expectException(InvalidRevision::class);
+ $this->expectExceptionMessage('greater than or equal to 1');
+
+ /** @When constructing with an invalid value */
+ new Revision(value: 0);
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidValues(): array
+ {
+ return [
+ 'zero' => [0],
+ 'negative one' => [-1],
+ 'negative ten' => [-10]
+ ];
+ }
+}
diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php
new file mode 100644
index 0000000..9d9782f
--- /dev/null
+++ b/tests/Event/SequenceNumberTest.php
@@ -0,0 +1,177 @@
+value);
+ }
+
+ public function testFirstYieldsOne(): void
+ {
+ /** @Given the first-sequence factory */
+ /** @When requesting the first sequence number */
+ $sequenceNumber = SequenceNumber::first();
+
+ /** @Then the value is one */
+ self::assertSame(1, $sequenceNumber->value);
+ }
+
+ public function testNextYieldsTheFollowingValue(): void
+ {
+ /** @Given a sequence number of 5 */
+ $sequenceNumber = new SequenceNumber(value: 5);
+
+ /** @When advancing to the next */
+ $next = $sequenceNumber->next();
+
+ /** @Then the value is 6 */
+ self::assertSame(6, $next->value);
+ }
+
+ public function testNextDoesNotMutateTheSource(): void
+ {
+ /** @Given a sequence number of 5 */
+ $sequenceNumber = new SequenceNumber(value: 5);
+
+ /** @When advancing */
+ $sequenceNumber->next();
+
+ /** @Then the original is unchanged */
+ self::assertSame(5, $sequenceNumber->value);
+ }
+
+ public function testIsAfterReturnsTrueWhenStrictlyGreater(): void
+ {
+ /** @Given a larger sequence number */
+ $larger = new SequenceNumber(value: 10);
+
+ /** @And a smaller counterpart */
+ $smaller = new SequenceNumber(value: 5);
+
+ /** @When checking if the larger is after the smaller */
+ $result = $larger->isAfter(other: $smaller);
+
+ /** @Then the result is true */
+ self::assertTrue($result);
+ }
+
+ public function testIsAfterReturnsFalseWhenEqual(): void
+ {
+ /** @Given two equal sequence numbers */
+ $first = new SequenceNumber(value: 3);
+
+ /** @And a counterpart with the same value */
+ $second = new SequenceNumber(value: 3);
+
+ /** @When checking if one is strictly after the other */
+ $result = $first->isAfter(other: $second);
+
+ /** @Then the result is false */
+ self::assertFalse($result);
+ }
+
+ public function testIsAfterReturnsFalseWhenStrictlySmaller(): void
+ {
+ /** @Given a smaller sequence number */
+ $smaller = new SequenceNumber(value: 2);
+
+ /** @And a larger counterpart */
+ $larger = new SequenceNumber(value: 8);
+
+ /** @When checking if the smaller is after the larger */
+ $result = $smaller->isAfter(other: $larger);
+
+ /** @Then the result is false */
+ self::assertFalse($result);
+ }
+
+ public function testEqualsReturnsTrueForSameValue(): void
+ {
+ /** @Given two sequence numbers with the same value */
+ $first = new SequenceNumber(value: 7);
+
+ /** @And a matching counterpart */
+ $second = new SequenceNumber(value: 7);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentValues(): void
+ {
+ /** @Given two sequence numbers with different values */
+ $first = new SequenceNumber(value: 1);
+
+ /** @And a distinct counterpart */
+ $second = new SequenceNumber(value: 2);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+
+ #[DataProvider('negativeValues')]
+ public function testConstructorRejectsNegativeValue(int $negativeValue): void
+ {
+ /** @Given a value that violates the sequence-number invariant */
+ /** @Then an InvalidSequenceNumber exception carrying the invalid value is thrown */
+ $this->expectException(InvalidSequenceNumber::class);
+ $this->expectExceptionMessage((string) $negativeValue);
+
+ /** @When constructing with a negative value */
+ new SequenceNumber(value: $negativeValue);
+ }
+
+ public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException(): void
+ {
+ /** @Given consumer code catching the PHP-standard InvalidArgumentException */
+ /** @Then InvalidSequenceNumber is caught by the standard exception type */
+ $this->expectException(InvalidArgumentException::class);
+
+ /** @When constructing with a negative value */
+ new SequenceNumber(value: -1);
+ }
+
+ public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): void
+ {
+ /** @Given a consumer inspecting the exception message */
+ /** @Then the message mentions the minimum allowed value */
+ $this->expectException(InvalidSequenceNumber::class);
+ $this->expectExceptionMessage('greater than or equal to 0');
+
+ /** @When constructing with a negative value */
+ new SequenceNumber(value: -1);
+ }
+
+ /**
+ * @return array
+ */
+ public static function negativeValues(): array
+ {
+ return [
+ 'minus one' => [-1],
+ 'minus ten' => [-10]
+ ];
+ }
+}
diff --git a/tests/Event/SnapshotDataTest.php b/tests/Event/SnapshotDataTest.php
new file mode 100644
index 0000000..8295140
--- /dev/null
+++ b/tests/Event/SnapshotDataTest.php
@@ -0,0 +1,103 @@
+ 'placed', 'amount' => 100]);
+
+ /** @When converting to array */
+ $result = $snapshotData->toArray();
+
+ /** @Then the original data is returned */
+ self::assertSame(['status' => 'placed', 'amount' => 100], $result);
+ }
+
+ public function testToJsonProducesValidJson(): void
+ {
+ /** @Given snapshot data with a simple payload */
+ $snapshotData = new SnapshotData(data: ['status' => 'shipped']);
+
+ /** @When converting to JSON */
+ $json = $snapshotData->toJson();
+
+ /** @Then the result is valid JSON */
+ self::assertSame('{"status":"shipped"}', $json);
+ }
+
+ public function testToJsonPreservesZeroFractionOnFloats(): void
+ {
+ /** @Given snapshot data with a float value */
+ $snapshotData = new SnapshotData(data: ['amount' => 1.0]);
+
+ /** @When converting to JSON with default flags */
+ $json = $snapshotData->toJson();
+
+ /** @Then the float zero fraction is preserved */
+ self::assertSame('{"amount":1.0}', $json);
+ }
+
+ public function testToJsonHonorsAdditionalFlags(): void
+ {
+ /** @Given snapshot data with a nested payload */
+ $snapshotData = new SnapshotData(data: ['amount' => 1.0]);
+
+ /** @When converting to JSON with an additional pretty-print flag */
+ $json = $snapshotData->toJson(flags: JSON_PRESERVE_ZERO_FRACTION | JSON_PRETTY_PRINT);
+
+ /** @Then the output reflects the requested formatting */
+ self::assertStringContainsString("\n", $json);
+ self::assertStringContainsString('"amount": 1.0', $json);
+ }
+
+ public function testToJsonThrowsForNonSerializableValue(): void
+ {
+ /** @Given snapshot data containing a non-JSON-serializable value */
+ $snapshotData = new SnapshotData(data: ['infinity' => INF]);
+
+ /** @Then a JsonException is thrown */
+ $this->expectException(JsonException::class);
+
+ /** @When converting to JSON */
+ $snapshotData->toJson();
+ }
+
+ public function testEqualsReturnsTrueForIdenticalPayloads(): void
+ {
+ /** @Given two snapshot data instances with identical payloads */
+ $first = new SnapshotData(data: ['status' => 'placed']);
+
+ /** @And a matching counterpart */
+ $second = new SnapshotData(data: ['status' => 'placed']);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentPayloads(): void
+ {
+ /** @Given two snapshot data instances with different payloads */
+ $first = new SnapshotData(data: ['status' => 'placed']);
+
+ /** @And a distinct counterpart */
+ $second = new SnapshotData(data: ['status' => 'shipped']);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Models/AppointmentId.php b/tests/Models/AppointmentId.php
new file mode 100644
index 0000000..d19aff7
--- /dev/null
+++ b/tests/Models/AppointmentId.php
@@ -0,0 +1,17 @@
+ */
+ private array $productIds = [];
+
+ public function addProduct(string $productId): void
+ {
+ $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1));
+ }
+
+ public function applySnapshot(Snapshot $snapshot): void
+ {
+ $state = $snapshot->getAggregateState();
+ $this->productIds = $state['productIds'] ?? [];
+ }
+
+ /**
+ * @return list
+ */
+ public function getProductIds(): array
+ {
+ return $this->productIds;
+ }
+
+ protected function whenProductAdded(ProductAdded $event): void
+ {
+ $this->productIds[] = $event->productId;
+ }
+}
diff --git a/tests/Models/CartId.php b/tests/Models/CartId.php
new file mode 100644
index 0000000..65e2eae
--- /dev/null
+++ b/tests/Models/CartId.php
@@ -0,0 +1,17 @@
+getSequenceNumber()->value % 2 === 0;
+ }
+}
diff --git a/tests/Models/FileSnapshotter.php b/tests/Models/FileSnapshotter.php
new file mode 100644
index 0000000..20bca88
--- /dev/null
+++ b/tests/Models/FileSnapshotter.php
@@ -0,0 +1,26 @@
+latest;
+ }
+
+ protected function persist(Snapshot $snapshot): void
+ {
+ $this->latest = $snapshot;
+ }
+}
diff --git a/tests/Models/Order.php b/tests/Models/Order.php
new file mode 100644
index 0000000..d4f919f
--- /dev/null
+++ b/tests/Models/Order.php
@@ -0,0 +1,42 @@
+status = 'placed';
+ $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1));
+
+ return $order;
+ }
+
+ public function ship(string $carrier): void
+ {
+ $this->status = 'shipped';
+ $this->pushEvent(event: new OrderShipped(carrier: $carrier), revision: new Revision(value: 1));
+ }
+
+ public function getStatus(): string
+ {
+ return $this->status;
+ }
+}
diff --git a/tests/Models/OrderId.php b/tests/Models/OrderId.php
new file mode 100644
index 0000000..4e6c583
--- /dev/null
+++ b/tests/Models/OrderId.php
@@ -0,0 +1,17 @@
+pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1));
+ }
+}
diff --git a/tests/Models/OrderWithoutIdentityConstant.php b/tests/Models/OrderWithoutIdentityConstant.php
new file mode 100644
index 0000000..cd73ce8
--- /dev/null
+++ b/tests/Models/OrderWithoutIdentityConstant.php
@@ -0,0 +1,19 @@
+pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1));
+ }
+}
diff --git a/tests/Models/ProductAdded.php b/tests/Models/ProductAdded.php
new file mode 100644
index 0000000..073f9d5
--- /dev/null
+++ b/tests/Models/ProductAdded.php
@@ -0,0 +1,14 @@
+ 1];
+ }
+}
diff --git a/tests/Snapshot/SnapshotConditionTest.php b/tests/Snapshot/SnapshotConditionTest.php
new file mode 100644
index 0000000..fc52258
--- /dev/null
+++ b/tests/Snapshot/SnapshotConditionTest.php
@@ -0,0 +1,67 @@
+shouldSnapshot(aggregate: $cart);
+
+ /** @Then the condition holds because zero is divisible by two */
+ self::assertTrue($result);
+ }
+
+ public function testConditionDoesNotHoldAfterOneEvent(): void
+ {
+ /** @Given a cart advanced to sequence number one */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+ $cart->addProduct(productId: 'prod-1');
+
+ /** @When asking the condition whether to snapshot */
+ $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart);
+
+ /** @Then the condition does not hold */
+ self::assertFalse($result);
+ }
+
+ public function testConditionHoldsAgainAfterTwoEvents(): void
+ {
+ /** @Given a cart advanced to sequence number two */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+ $cart->addProduct(productId: 'prod-1');
+ $cart->addProduct(productId: 'prod-2');
+
+ /** @When asking the condition whether to snapshot */
+ $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart);
+
+ /** @Then the condition holds again at the next even step */
+ self::assertTrue($result);
+ }
+
+ public function testConditionDoesNotHoldAfterThreeEvents(): void
+ {
+ /** @Given a cart advanced to sequence number three */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-4'));
+ $cart->addProduct(productId: 'prod-1');
+ $cart->addProduct(productId: 'prod-2');
+ $cart->addProduct(productId: 'prod-3');
+
+ /** @When asking the condition whether to snapshot */
+ $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart);
+
+ /** @Then the condition does not hold at an odd step */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php
new file mode 100644
index 0000000..8ad8fbf
--- /dev/null
+++ b/tests/Snapshot/SnapshotTest.php
@@ -0,0 +1,178 @@
+addProduct(productId: 'prod-1');
+
+ /** @When taking a snapshot */
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @Then the type matches the aggregate's short class name */
+ self::assertSame('Cart', $snapshot->getType());
+ }
+
+ public function testFromAggregateCapturesAggregateId(): void
+ {
+ /** @Given a cart with a known identity */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-id-42'));
+
+ /** @When taking a snapshot */
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @Then the aggregate id reflects the identity value */
+ self::assertSame('cart-id-42', $snapshot->getAggregateId());
+ }
+
+ public function testFromAggregateCapturesSequenceNumber(): void
+ {
+ /** @Given a cart with two events applied */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+ $cart->addProduct(productId: 'prod-1');
+ $cart->addProduct(productId: 'prod-2');
+
+ /** @When taking a snapshot */
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @Then the sequence number is captured */
+ self::assertSame(2, $snapshot->getSequenceNumber()->value);
+ }
+
+ public function testFromAggregateCapturesCreatedAt(): void
+ {
+ /** @Given a blank cart */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+
+ /** @When taking a snapshot */
+ $snapshot = Snapshot::fromAggregate(aggregate: $cart);
+
+ /** @Then the createdAt timestamp is set */
+ self::assertInstanceOf(Instant::class, $snapshot->getCreatedAt());
+ }
+
+ public function testFromAggregateCarriesDomainFieldsInState(): void
+ {
+ /** @Given a cart with a product added */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-4'));
+ $cart->addProduct(productId: 'prod-x');
+
+ /** @When taking a snapshot */
+ $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+
+ /** @Then the state carries the domain fields */
+ self::assertSame(['prod-x'], $state['productIds']);
+ }
+
+ public function testFromAggregateStateOmitsRecordedEventsBuffer(): void
+ {
+ /** @Given a cart with a product added */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-5'));
+ $cart->addProduct(productId: 'prod-x');
+
+ /** @When taking a snapshot */
+ $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+
+ /** @Then the transient recording buffer is not part of the persisted state */
+ self::assertArrayNotHasKey('recordedEvents', $state);
+ }
+
+ public function testFromAggregateStateOmitsSequenceNumber(): void
+ {
+ /** @Given a cart with a product added */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-6'));
+ $cart->addProduct(productId: 'prod-x');
+
+ /** @When taking a snapshot */
+ $state = Snapshot::fromAggregate(aggregate: $cart)->getAggregateState();
+
+ /** @Then the sequence number is not duplicated into the state */
+ self::assertArrayNotHasKey('sequenceNumber', $state);
+ }
+
+ public function testRoundTripThroughSnapshotRestoresDomainState(): void
+ {
+ /** @Given a cart with a product added */
+ $cartId = new CartId(value: 'cart-7');
+ $original = Cart::blank(identity: $cartId);
+ $original->addProduct(productId: 'prod-roundtrip');
+
+ /** @When taking a snapshot and reconstituting a fresh aggregate from it */
+ $snapshot = Snapshot::fromAggregate(aggregate: $original);
+ $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+
+ /** @Then the reconstituted aggregate carries the same domain state */
+ self::assertSame(['prod-roundtrip'], $reconstituted->getProductIds());
+ }
+
+ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
+ {
+ /** @Given shared fields for two snapshots */
+ $sequenceNumber = new SequenceNumber(value: 1);
+ $createdAt = Instant::now();
+
+ /** @And two snapshots built from those identical fields */
+ $first = new Snapshot(
+ type: 'Cart',
+ createdAt: $createdAt,
+ aggregateId: 'cart-1',
+ aggregateState: ['productIds' => []],
+ sequenceNumber: $sequenceNumber
+ );
+ $second = new Snapshot(
+ type: 'Cart',
+ createdAt: $createdAt,
+ aggregateId: 'cart-1',
+ aggregateState: ['productIds' => []],
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void
+ {
+ /** @Given two snapshots that differ only by type */
+ $sequenceNumber = new SequenceNumber(value: 1);
+ $createdAt = Instant::now();
+
+ /** @And the two snapshots constructed accordingly */
+ $first = new Snapshot(
+ type: 'Cart',
+ createdAt: $createdAt,
+ aggregateId: 'cart-1',
+ aggregateState: [],
+ sequenceNumber: $sequenceNumber
+ );
+ $second = new Snapshot(
+ type: 'Order',
+ createdAt: $createdAt,
+ aggregateId: 'cart-1',
+ aggregateState: [],
+ sequenceNumber: $sequenceNumber
+ );
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Snapshot/SnapshotterBehaviorTest.php
new file mode 100644
index 0000000..9f9520b
--- /dev/null
+++ b/tests/Snapshot/SnapshotterBehaviorTest.php
@@ -0,0 +1,69 @@
+addProduct(productId: 'prod-1');
+ $snapshotter = new FileSnapshotter();
+
+ /** @When taking a snapshot */
+ $snapshotter->take(aggregate: $cart);
+
+ /** @Then a Snapshot is persisted */
+ self::assertInstanceOf(Snapshot::class, $snapshotter->lastSnapshot());
+ }
+
+ public function testPersistedSnapshotReflectsTheAggregateType(): void
+ {
+ /** @Given a cart and a fresh snapshotter */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-2'));
+ $snapshotter = new FileSnapshotter();
+
+ /** @When taking a snapshot */
+ $snapshotter->take(aggregate: $cart);
+
+ /** @Then the persisted snapshot carries the aggregate's type */
+ self::assertSame('Cart', $snapshotter->lastSnapshot()->getType());
+ }
+
+ public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void
+ {
+ /** @Given a cart advanced to sequence number 2 */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-3'));
+ $cart->addProduct(productId: 'prod-1');
+ $cart->addProduct(productId: 'prod-2');
+ $snapshotter = new FileSnapshotter();
+
+ /** @When taking a snapshot */
+ $snapshotter->take(aggregate: $cart);
+
+ /** @Then the persisted snapshot carries the aggregate's sequence number */
+ self::assertSame(2, $snapshotter->lastSnapshot()->getSequenceNumber()->value);
+ }
+
+ public function testPersistedSnapshotReflectsTheAggregateIdentity(): void
+ {
+ /** @Given a cart with a known identity */
+ $cart = Cart::blank(identity: new CartId(value: 'cart-4'));
+ $snapshotter = new FileSnapshotter();
+
+ /** @When taking a snapshot */
+ $snapshotter->take(aggregate: $cart);
+
+ /** @Then the persisted snapshot carries the aggregate id */
+ self::assertSame('cart-4', $snapshotter->lastSnapshot()->getAggregateId());
+ }
+}
diff --git a/tests/Upcast/DefaultValuesTest.php b/tests/Upcast/DefaultValuesTest.php
new file mode 100644
index 0000000..e2af751
--- /dev/null
+++ b/tests/Upcast/DefaultValuesTest.php
@@ -0,0 +1,35 @@
+ 'prod-1'];
+
+ /** @When constructing the intermediate event */
+ $event = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $serializedEvent);
+
+ /** @Then each public field is accessible */
+ self::assertSame($eventType, $event->type);
+ self::assertSame($revision, $event->revision);
+ self::assertSame($serializedEvent, $event->serializedEvent);
+ }
+
+ public function testWithRevisionOnlyReplacesTheRevision(): void
+ {
+ /** @Given an intermediate event at revision 1 */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When bumping to revision 2 */
+ $bumped = $event->withRevision(revision: new Revision(value: 2));
+
+ /** @Then the revision changes */
+ self::assertSame(2, $bumped->revision->value);
+ }
+
+ public function testWithRevisionPreservesTheTypeAndPayload(): void
+ {
+ /** @Given an intermediate event at revision 1 */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When bumping to revision 2 */
+ $bumped = $event->withRevision(revision: new Revision(value: 2));
+
+ /** @Then neither the type nor the payload are affected */
+ self::assertSame('ProductAdded', $bumped->type->value);
+ self::assertSame(['productId' => 'prod-1'], $bumped->serializedEvent);
+ }
+
+ public function testWithRevisionReturnsANewInstance(): void
+ {
+ /** @Given an intermediate event at revision 1 */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When bumping to revision 2 */
+ $bumped = $event->withRevision(revision: new Revision(value: 2));
+
+ /** @Then the source event remains untouched */
+ self::assertNotSame($event, $bumped);
+ self::assertSame(1, $event->revision->value);
+ }
+
+ public function testWithSerializedEventOnlyReplacesThePayload(): void
+ {
+ /** @Given an intermediate event with an original payload */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When replacing the serialized payload */
+ $updated = $event->withSerializedEvent(serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]);
+
+ /** @Then the payload changes */
+ self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $updated->serializedEvent);
+ }
+
+ public function testWithSerializedEventPreservesTheTypeAndRevision(): void
+ {
+ /** @Given an intermediate event with an original payload */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When replacing the serialized payload */
+ $updated = $event->withSerializedEvent(serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]);
+
+ /** @Then neither the type nor the revision are affected */
+ self::assertSame(1, $updated->revision->value);
+ self::assertSame('ProductAdded', $updated->type->value);
+ }
+
+ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void
+ {
+ /** @Given shared fields for two intermediate events */
+ $eventType = EventType::fromString(value: 'ProductAdded');
+ $revision = new Revision(value: 1);
+ $payload = ['productId' => 'prod-1'];
+
+ /** @And two intermediate events with identical values */
+ $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
+ $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are equal */
+ self::assertTrue($result);
+ }
+
+ public function testEqualsReturnsFalseForDifferentPayloads(): void
+ {
+ /** @Given two intermediate events with different payloads */
+ $eventType = EventType::fromString(value: 'ProductAdded');
+ $revision = new Revision(value: 1);
+
+ /** @And the two events constructed accordingly */
+ $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']);
+ $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']);
+
+ /** @When comparing them */
+ $result = $first->equals(other: $second);
+
+ /** @Then they are not equal */
+ self::assertFalse($result);
+ }
+}
diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Upcast/SingleUpcasterBehaviorTest.php
new file mode 100644
index 0000000..df0a744
--- /dev/null
+++ b/tests/Upcast/SingleUpcasterBehaviorTest.php
@@ -0,0 +1,78 @@
+ 'prod-1']
+ );
+
+ /** @When upcasting with ProductV1Upcaster */
+ $upcasted = new ProductV1Upcaster()->upcast(event: $event);
+
+ /** @Then the revision is bumped to the target value */
+ self::assertSame(2, $upcasted->revision->value);
+ }
+
+ public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void
+ {
+ /** @Given a ProductAdded event at revision 1 */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['productId' => 'prod-1']
+ );
+
+ /** @When upcasting with ProductV1Upcaster */
+ $upcasted = new ProductV1Upcaster()->upcast(event: $event);
+
+ /** @Then the payload is enriched with the default quantity */
+ self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $upcasted->serializedEvent);
+ }
+
+ public function testUpcastReturnsUnchangedEventForMismatchedType(): void
+ {
+ /** @Given an event whose type is not the one the upcaster handles */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'OrderPlaced'),
+ revision: new Revision(value: 1),
+ serializedEvent: ['item' => 'book']
+ );
+
+ /** @When applying the upcaster */
+ $result = new ProductV1Upcaster()->upcast(event: $event);
+
+ /** @Then the same instance is returned unchanged */
+ self::assertSame($event, $result);
+ }
+
+ public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void
+ {
+ /** @Given a ProductAdded event at revision 2, past the upcaster's FROM_REVISION */
+ $event = new IntermediateEvent(
+ type: EventType::fromString(value: 'ProductAdded'),
+ revision: new Revision(value: 2),
+ serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
+ );
+
+ /** @When applying the upcaster */
+ $result = new ProductV1Upcaster()->upcast(event: $event);
+
+ /** @Then the same instance is returned unchanged */
+ self::assertSame($event, $result);
+ }
+}