From dc955338833efcd6b8a1a318952253ec48bc5bf4 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 10:17:14 -0300
Subject: [PATCH] feat: Build out the building blocks library and project
tooling.
- Rework the aggregate roots (event-sourced and eventual) with aggregate
versioning, reflection-based reconstitution, and snapshot support.
- Rework domain events, event records, revisions, and upcasting.
- Add the Utc, Uuid, and Ordinal value objects with their dedicated
exceptions and comparison behavior.
- Add the tiny-blocks Claude skills, rules, and CLAUDE.md.
- Refresh the CI workflows, tooling config, and README.
---
.claude/CLAUDE.md | 16 -
.claude/rules/php-library-architecture.md | 10 +-
.claude/rules/php-library-code-style.md | 502 ++++++++++++++---
.claude/rules/php-library-commits.md | 111 ----
.claude/rules/php-library-documentation.md | 212 ++-----
.claude/rules/php-library-github-workflows.md | 307 ++--------
.claude/rules/php-library-modeling.md | 44 +-
.claude/rules/php-library-testing.md | 97 +++-
.claude/rules/php-library-tooling.md | 532 ++++--------------
.claude/settings.json | 232 ++++++++
.claude/skills/commit-message/SKILL.md | 119 ++++
.claude/skills/tiny-blocks-consume/SKILL.md | 68 +++
.../tiny-blocks-consume/references/catalog.md | 32 ++
.../scripts/refresh-catalog.py | 102 ++++
.claude/skills/tiny-blocks-create/SKILL.md | 158 ++++++
.../assets/config/.editorconfig | 19 +
.../assets/config/.gitattributes | 19 +
.../assets/config/.gitignore | 28 +
.../tiny-blocks-create/assets/config/Makefile | 74 +++
.../assets/config/composer.json | 70 +++
.../assets/config/infection.json.dist | 23 +
.../assets/config/phpcs.xml | 7 +
.../assets/config/phpstan.neon.dist | 6 +
.../assets/config/phpunit.xml | 39 ++
.../assets/docs/SECURITY.md | 12 +
.../github/ISSUE_TEMPLATE/bug_report.md | 29 +
.../github/ISSUE_TEMPLATE/feature_request.md | 17 +
.../assets/github/PULL_REQUEST_TEMPLATE.md | 16 +
.../assets/github/workflows/ci.yml | 105 ++++
.gitattributes | 9 +-
.github/copilot-instructions.md | 2 +-
.github/workflows/auto-assign.yml | 4 +-
.github/workflows/ci.yml | 2 +-
.gitignore | 7 +-
CLAUDE.md | 61 ++
README.md | 164 +++---
composer.json | 11 +-
infection.json.dist | 3 +-
phpstan.neon.dist | 18 +-
src/Aggregate/AggregateRootBehavior.php | 39 +-
src/Aggregate/AggregateVersion.php | 26 +-
src/Aggregate/EventSourcingRoot.php | 24 +-
src/Aggregate/EventSourcingRootBehavior.php | 13 +-
src/Aggregate/EventualAggregateRoot.php | 103 ++--
.../EventualAggregateRootBehavior.php | 49 +-
src/Aggregate/ModelVersion.php | 26 +-
src/Event/DomainEvent.php | 10 +
src/Event/EventRecord.php | 25 +-
src/Event/EventRecords.php | 5 +
src/Event/EventType.php | 12 +-
src/Event/IntegrationEventRecord.php | 8 +-
src/Event/IntegrationEventTranslators.php | 6 +-
src/Event/Revision.php | 26 +-
src/Exceptions/IncompleteAggregateState.php | 17 +
src/Exceptions/InvalidUtc.php | 17 +
src/Exceptions/InvalidUuid.php | 17 +
src/Internal/AggregateReflection.php | 64 +++
src/Internal/ClassName.php | 19 +
src/Ordinal.php | 21 +
src/OrdinalBehavior.php | 20 +
src/Snapshot/Snapshot.php | 14 +-
src/Upcast/IntermediateEvent.php | 23 +-
src/Upcast/Upcasters.php | 4 +-
src/Utc.php | 62 ++
src/Uuid.php | 81 +++
tests/Models/BaseCart.php | 38 ++
tests/Models/BaseReservation.php | 17 +
tests/Models/GuestReservation.php | 23 +
tests/Models/Order.php | 12 +-
tests/Models/OrderPlaced.php | 5 +
tests/Models/OrderShipped.php | 5 +
.../OrderWithMissingIdentityProperty.php | 2 +-
tests/Models/ProductAdded.php | 5 +
tests/Models/ProductAddedV2.php | 5 +
tests/Models/Reservation.php | 4 +-
tests/Models/ReservationBooked.php | 5 +
tests/Models/ReservationConfirmed.php | 5 +
tests/Models/SpecializedCart.php | 23 +
tests/Models/SpecializedReservation.php | 21 +
.../AggregateExtensionBehaviorTest.php | 62 ++
.../Aggregate/AggregateRootBehaviorTest.php | 21 +-
tests/Unit/Aggregate/AggregateVersionTest.php | 12 +
.../EventSourcingRootBehaviorTest.php | 40 +-
.../EventualAggregateRootBehaviorTest.php | 157 +++++-
tests/Unit/Aggregate/ModelVersionTest.php | 12 +
tests/Unit/Event/EventRecordTest.php | 104 ++--
tests/Unit/Event/EventRecordsTest.php | 16 +-
tests/Unit/Event/EventTypeTest.php | 4 +-
.../Unit/Event/IntegrationEventRecordTest.php | 14 +-
.../Event/IntegrationEventTranslatorsTest.php | 6 +-
tests/Unit/Event/RevisionTest.php | 12 +
.../Unit/Internal/AggregateReflectionTest.php | 24 +
tests/Unit/Internal/ClassNameTest.php | 24 +
tests/Unit/Snapshot/SnapshotTest.php | 10 +-
tests/Unit/Upcast/IntermediateEventTest.php | 42 +-
.../Upcast/SingleUpcasterBehaviorTest.php | 8 +-
tests/Unit/Upcast/UpcastersTest.php | 10 +-
tests/Unit/UtcTest.php | 73 +++
tests/Unit/UuidTest.php | 110 ++++
99 files changed, 3417 insertions(+), 1532 deletions(-)
delete mode 100644 .claude/CLAUDE.md
delete mode 100644 .claude/rules/php-library-commits.md
create mode 100644 .claude/settings.json
create mode 100644 .claude/skills/commit-message/SKILL.md
create mode 100644 .claude/skills/tiny-blocks-consume/SKILL.md
create mode 100644 .claude/skills/tiny-blocks-consume/references/catalog.md
create mode 100644 .claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py
create mode 100644 .claude/skills/tiny-blocks-create/SKILL.md
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.editorconfig
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitattributes
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitignore
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/Makefile
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/composer.json
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/infection.json.dist
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpcs.xml
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist
create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpunit.xml
create mode 100644 .claude/skills/tiny-blocks-create/assets/docs/SECURITY.md
create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md
create mode 100644 .claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md
create mode 100644 .claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml
create mode 100644 CLAUDE.md
create mode 100644 src/Exceptions/IncompleteAggregateState.php
create mode 100644 src/Exceptions/InvalidUtc.php
create mode 100644 src/Exceptions/InvalidUuid.php
create mode 100644 src/Internal/AggregateReflection.php
create mode 100644 src/Internal/ClassName.php
create mode 100644 src/Ordinal.php
create mode 100644 src/OrdinalBehavior.php
create mode 100644 src/Utc.php
create mode 100644 src/Uuid.php
create mode 100644 tests/Models/BaseCart.php
create mode 100644 tests/Models/BaseReservation.php
create mode 100644 tests/Models/GuestReservation.php
create mode 100644 tests/Models/SpecializedCart.php
create mode 100644 tests/Models/SpecializedReservation.php
create mode 100644 tests/Unit/Aggregate/AggregateExtensionBehaviorTest.php
create mode 100644 tests/Unit/Internal/AggregateReflectionTest.php
create mode 100644 tests/Unit/Internal/ClassNameTest.php
create mode 100644 tests/Unit/UtcTest.php
create mode 100644 tests/Unit/UuidTest.php
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
deleted file mode 100644
index a561aa6..0000000
--- a/.claude/CLAUDE.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# CLAUDE.md
-
-This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`.
-Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or
-editing content under its scope.
-
-## Rule files
-
-- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics.
-- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`.
-- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages.
-- `php-library-documentation.md` — README and Markdown documentation standards.
-- `php-library-github-workflows.md` — CI workflow structure and action pinning.
-- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity.
-- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline.
-- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc).
diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md
index 7e4be10..4be7fc3 100644
--- a/.claude/rules/php-library-architecture.md
+++ b/.claude/rules/php-library-architecture.md
@@ -38,10 +38,10 @@ outputting.
concept.
11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system
boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/`
- or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library
- exposes a port exercised against multiple third-party implementations (PSR adapters,
- framework integrations). Each `/` subdir holds tests against one specific
- implementation.
+ or `Doubles/` subdirectory exists. Vendor compatibility (driver) tests, verifying the
+ library against specific external libraries/frameworks, are optional and have no `src/`
+ counterpart. They exist only as tests, under `tests/Integration/Drivers//`,
+ grouped by vendor. Never a top-level `Drivers/` under `tests/`.
12. The `tests/Integration/` folder exists only when the library interacts with external
infrastructure (filesystem, database, network). Otherwise, the folder is absent.
@@ -68,6 +68,8 @@ tests/
│ ├── .php # test doubles at root of Unit/
│ └── .php
└── Integration/ # only present when the library interacts with infrastructure
+ ├── Drivers/ # only present when the library exposes vendor-specific drivers
+ │ └── / # tests against one specific third-party implementation
└── .php # test doubles at root of Integration/ when needed
```
diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md
index 8485df7..c44dbc1 100644
--- a/.claude/rules/php-library-code-style.md
+++ b/.claude/rules/php-library-code-style.md
@@ -8,10 +8,12 @@ paths:
# Code style
Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced
-by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in
-multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity
-rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics
-of `Internal/` live in `php-library-architecture.md`.
+by `phpcs.xml`. Four formatting rules outside `PSR-12` (single-line signatures within 120
+characters, no vertical alignment in parameter lists, vertical alignment of `=>` in multi-line
+match arms and array literals, no trailing comma in multi-line lists) are documented at the end
+of this file under "Formatting overrides". Complexity rules live in `php-library-modeling.md`.
+Folder structure, public API boundary, and the semantics of `Internal/` live in
+`php-library-architecture.md`.
## Pre-output checklist
@@ -33,6 +35,10 @@ Verify every item before producing any PHP code. If any item fails, revise befor
after named arguments. When the caller passes through a `...$variadic`, all arguments are
positional. New own-code APIs should prefer a typed collection parameter over a variadic
so named-argument call sites remain possible.
+ - Native PHP class static and instance methods (`DateTimeImmutable::createFromFormat`,
+ `DateTimeImmutable::createFromInterface`, `->setTimezone`, `->format`, and similar). Their
+ parameter names are an internal implementation detail, not a stable contract, exactly as
+ with native functions.
Native PHP **class constructors** (`parent::__construct` calls to `\Exception`,
`\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not
@@ -40,31 +46,50 @@ Verify every item before producing any PHP code. If any item fails, revise befor
the positional call would pass an argument whose value equals the parameter's default.
Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of
`parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native
- functions and enum methods, not native class instantiation.
+ functions, enum methods, and native class static and instance methods, but not native class
+ constructors (instantiation): those accept named arguments per rule 8.
5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default,
with documented exceptions for extension points and for parents that are not `readonly`.
6. 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. This
- ordering may be overridden only when the alternative carries explicit documentation value:
- grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc),
- mirroring the order of an implemented interface, or similar evident structure. The override
- must be obvious at first reading.
+ methods. Within each group, order by **member name length ascending** (count the name only,
+ without parentheses, arguments, or return type). Constants, enum cases, and methods share
+ the same name-length-ascending rule, applied within their respective groups. This mirrors
+ the rule that governs constructor parameters and named arguments (rule 7). When two names
+ have equal length, order them alphabetically. This ordering may be overridden only when the
+ alternative carries explicit documentation value: grouping by domain class with section
+ markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented
+ interface, or similar evident structure. The override must be obvious at first reading.
**At call sites** (chained method calls in production code, tests, or documentation
- examples), consecutive method invocations on the same receiver are ordered by the **visible
- width** of each call expression ascending. The body is not visible at the call site, so the
- visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and
- `->httpOnly()` come before parameterized `with*` builders for the same reason. When two
- calls have equal width, order them alphabetically by method name.
+ examples), consecutive method invocations on the same receiver are ordered by **method name
+ length ascending**, the same rule that governs member declarations. Boolean toggles such as
+ `->secure()` and `->httpOnly()` come before parameterized `with*` builders because their
+ names are shorter, not because the expression is narrower. When two method names have equal
+ length, order them alphabetically.
**Terminal methods that change the receiver type** stay at the end of the chain regardless
- of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of
- work, a `send()` that flushes a request, are terminal: the chain ends with them. The
- ordering rule applies only to consecutive calls on the same receiver type; calls that
+ of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit
+ of work, a `send()` that flushes a request, are terminal: the chain ends with them. The
+ ordering rule applies only to consecutive calls on the same receiver type. Calls that
transition to a different type are not reorderable. The same applies in reverse to the
- factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays
+ factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) stays
at its position.
+
+ **PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group
+ that overrides the name-length-ascending rule:
+
+ 1. **Lifecycle hooks** first, in PHPUnit execution order:
+ `setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually
+ defined appear. Never introduce an empty hook to satisfy the rule.
+ 2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical
+ tiebreak).
+ 3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak).
+
+ A method is a data provider if and only if its name appears as the string argument of a
+ `#[DataProvider('')]` attribute or a `@dataProvider ` docblock annotation on a
+ test method in the same class. The naming convention (`*DataProvider`) is informational
+ only. The reference is the authoritative signal. A method named `*DataProvider` that no
+ test references is dead code under rule 17, not a data provider.
7. Constructor parameters are ordered by parameter name length ascending (count the name only,
without `$` or type), except when parameters have an implicit semantic order (for example,
`$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default
@@ -74,12 +99,16 @@ Verify every item before producing any PHP code. If any item fails, revise befor
Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call
`$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes
`$collection->toArray()`. Only pass the argument when the value differs from the default.
-9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead.
+9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch
+ instead. See "Polymorphism and tell-don't-ask".
10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of
`$acc`.
11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to
`$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`,
- `$result` to `$conversionOutcome`.
+ `$result` to `$conversionOutcome`. **Exception:** a factory or constructor parameter that
+ wraps a single opaque scalar the value object exists to represent may keep `$value` when no
+ more specific meaning applies (for example, `Seconds::from(int $value)`). Where a more
+ specific meaning exists, prefer it (`$iso`, `$identifier`, `$isoDay`).
12. No raw arrays exist where a typed collection or value object is available. When data is
`Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `Collectible`). Use
`createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive
@@ -89,7 +118,8 @@ Verify every item before producing any PHP code. If any item fails, revise befor
`src/Internal/` (implementation detail by definition, where the namespace is the abstraction
boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases,
inline trivial logic at the call site or extract it to a collaborator or value object.
-14. No logic is duplicated across two or more places (DRY).
+14. No logic is duplicated across two or more places (DRY). See "Duplication" for the resolution
+ under the inheritance and private-method constraints.
15. No abstraction exists without real duplication or isolation need (KISS).
16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation
is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments
@@ -104,20 +134,50 @@ Verify every item before producing any PHP code. If any item fails, revise befor
20. All class references use `use` imports at the top of the file. Fully qualified names inline are
prohibited.
21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type,
- as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME`
- is permitted. `static` is permitted only inside extension-point classes (declared `class`
- without `final readonly`) and inside traits, where late static binding lets subclasses or
- consuming classes instantiate the correct concrete type. In every other context, use the
- class name.
+ as a return type, in `new self()` instantiation, and in static method calls
+ (`self::from(...)` → `ClassName::from(...)`). Constant access via `self::CONST_NAME` is the
+ only permitted `self::` form. `static` is permitted only inside extension-point classes
+ (declared `class` without `final readonly`) and inside traits, where late static binding lets
+ subclasses or consuming classes instantiate the correct concrete type. In every other
+ context, use the class name.
22. Always use the most current and clean syntax available in the target PHP version. Prefer
`match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion
over manual assignment, enum methods over external switch or if chains, named arguments over
positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach
- accumulation, and **unparenthesized constructor chaining** (PHP 8.4+):
- `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new`
- expression are no longer required and add visual noise.
+ accumulation, concise standard regex character classes (`\w`, `\d`, `\s`, and their negations)
+ over their explicit equivalents (`[A-Za-z0-9_]`, `[0-9]`), and **unparenthesized constructor
+ chaining** (PHP 8.4+): `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses
+ around the `new` expression are no longer required and add visual noise.
23. All identifiers, comments, and documentation use American English. See "American English" for
the spelling list.
+24. No method has more than three `return` statements. This bounds branching complexity and
+ coexists with rule 9 (no `else`): early-return guard clauses are fine, but a method that needs
+ more than three exit points is doing too much. Invariant violations `throw` a dedicated
+ exception rather than returning, so guards rarely add return points. When branching still
+ produces more than three returns, replace it with a `match` or map dispatch that resolves to a
+ single return, or extract a collaborator. When the branches turn on the runtime type of
+ polymorphic collaborator, the behavior belongs on that type instead. See "Return statements"
+ and "Polymorphism and tell-don't-ask".
+25. The string concatenation operator (`.`) is never used, in any position. A string that
+ would be assembled by concatenation, whether it embeds a value or joins two or more
+ strings, is built with `sprintf` and a `$template` variable (rule 19) instead. This
+ covers value prefixes, value suffixes, inline fragments, and plain joins. See "Format
+ strings".
+
+ **Exception:** a `const` string literal that contains no `sprintf` placeholder may
+ use `.` to split a message across lines when a single-line literal would exceed the
+ 120-character limit. In that case `sprintf` offers no benefit, since the `$template`
+ line would itself exceed the limit, and heredoc and nowdoc are not permitted in
+ constant expressions, so concatenation is the only way to honor the line length.
+ This exception is limited to placeholder-free constant literals. Runtime string
+ assembly, and any constant that interpolates a value, still uses `sprintf` with a
+ `$template`.
+26. Behavior that varies by the concrete type of type the library owns is a polymorphic method
+ on that type, never an `instanceof`, `get_class`, or enum-case branch. A value or behavior an
+ enum case owns (a token, a flag about the case's nature, a derived value) lives on the enum as
+ a predicate or vocabulary method, called at the site instead of comparing the case. Behavior
+ that depends on a collaborator's state lives on that collaborator. See "Polymorphism and
+ tell-don't-ask".
## Naming
@@ -128,7 +188,9 @@ Verify every item before producing any PHP code. If any item fails, revise befor
full banlist of generic and anemic names.
- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`.
- Collections are always plural. Examples are `$orders`, `$lines`.
-- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`.
+- A boolean method reads as a predicate, using an `is`/`has`/`can`/`was`/`should` prefix or a
+ third-person verb that reads as a yes/no question, such as `contains`, `matches`, `supports`,
+ `equals`, or `omits`.
## Class self-references
@@ -136,11 +198,13 @@ Type declarations, return types, and `new` calls inside a class use the explicit
The class name is unambiguous, survives refactors that move the method to a different class,
and reads identically inside the class body and at the call site.
-- `self` is prohibited everywhere as a type, as a return type, and in `new self()`
- instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition
- covers the forms that carry refactoring ambiguity when a method moves to a different class
- (the type-or-instantiation forms). Constant access does not have that ambiguity because the
- constant is declared in the same class body.
+- `self` is prohibited everywhere as a type, as a return type, in `new self()` instantiation,
+ and in static method calls (`self::from(...)`). Constant access via `self::CONST_NAME` is
+ **permitted** and is the only allowed `self::` form. The prohibition covers the forms that
+ carry refactoring ambiguity when a method moves to a different class (type, instantiation, and
+ static-call forms): a `self::from()` call rebinds to the wrong class if the method moves,
+ exactly like `new self()`. Constant access does not have that ambiguity because the constant is
+ declared in the same class body.
- `static` is permitted only inside extension-point classes (declared `class` without
`final readonly`) and inside traits, where late static binding is required for subclasses or
consuming classes to instantiate the correct concrete type.
@@ -225,15 +289,22 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
### When required
-- Every method of an interface, **including interfaces declared inside `src/Internal/`**.
- Interfaces define contracts. The contract is documentation by definition, regardless of
- namespace. The `Internal/` boundary applies to implementations, not to the contracts that
- internal collaborators expose to each other.
+Everything exposed on the public API for consumption carries PHPDoc per these rules.
+
+- Every method of an interface, regardless of location. Interfaces are contracts, so they carry
+ PHPDoc per these rules even when declared inside `src/Internal/`.
- Every public method of a concrete class outside `src/Internal/`. Public classes are at the
public API boundary by definition. Consumers call every public method directly, and the
PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt.
The only exception is a public method whose contract is already documented on an implemented
interface (the interface carries the docblock).
+- Every abstract method on a public class or extension point outside `src/Internal/`. Abstract
+ methods are part of the public contract consumers implement or override, so each carries PHPDoc
+ exactly as an interface method does.
+- A class-level summary docblock on every interface (including interfaces inside `src/Internal/`)
+ and on every public class or enum outside `src/Internal/`. The summary is a single line placed
+ directly above the declaration stating what the type is or does, following the same summary-line
+ rule as method docblocks. It is the class-level counterpart of the per-method PHPDoc.
### When prohibited
@@ -242,13 +313,12 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
- Private and protected methods.
- Public methods of concrete classes whose contract is already documented on an implemented
interface. The interface carries the docblock.
-- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry
- PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the
- architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An
- interface declared inside `src/Internal/` still defines a contract, and the contract is
- documented per `### When required` regardless of namespace. The prohibition covers concrete
- classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces.
-- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz`
+- Concrete classes and collaborators inside `src/Internal/`. Internal implementation types are
+ detail, not contract, and carry no PHPDoc, class-level summary included. **Interfaces are the
+ exception**: an interface declared inside `src/Internal/` is still a contract and follows the
+ interface PHPDoc rules under "When required", including the class-level summary. See
+ `php-library-architecture.md` for the architectural meaning of `Internal/`.
+- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyThenZzz`
naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in
`php-library-testing.md` describe the steps. PHPDoc documentation (summary plus
`@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures,
@@ -261,8 +331,24 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
The prohibitions above apply to **every form of PHPDoc** in the prohibited scope:
method-level docblocks, property-level docblocks, inline `@var` annotations on local variables,
and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside
-`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that
-result from the missing annotations route through `ignoreErrors` (see below).
+`tests/`, zero PHPDoc is the rule, save for the generics carve-out below. Inside `src/Internal/`,
+zero PHPDoc applies to concrete classes and collaborators, but interfaces carry PHPDoc per "When
+required", and the generics carve-out below still applies to those concrete classes. PHPStan
+errors that result from the missing annotations on the non-interface code route through
+`ignoreErrors` (see below).
+
+**Generics carve-out.** The prohibitions above are waived for PHPDoc that exists *purely to
+express generics* the native type system cannot: `@template`, `@extends`, `@implements`, and the
+`@param`/`@return`/`@var` tags whose sole purpose is to carry a type parameter (for example
+`Collection`, `iterable`, `Closure(TValue): bool`, `static`). These tags
+are permitted wherever they are necessary for generic typing, including on **constructors**, on
+**concrete classes and collaborators inside `src/Internal/`**, and as a **bare-tag block with no
+summary line** (a summary would be the prohibited descriptive form). The waiver is strict: it
+covers only the type-parameter information. Descriptive or redundant PHPDoc (summaries, prose
+`@param`/`@return` descriptions, anything restating what the signature already says) stays
+prohibited everywhere. When the only missing annotation is non-generic (a plain iterable value
+type, a mixed-origin argument), the typed-array case below still applies and routes through
+`ignoreErrors`, not PHPDoc.
The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at
`level: max` flags a missing iterable value type (`missingType.iterableValue`,
@@ -270,10 +356,9 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS
- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not
add PHPDoc.
-- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via
- `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception:
- they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved
- through the PHPDoc, never through `ignoreErrors`.
+- On a concrete class or collaborator inside **`src/Internal/`** → suppress via `ignoreErrors`.
+ Do not add PHPDoc. An interface inside `src/Internal/` is the exception: it carries PHPDoc per
+ "When required", so the typed-array information goes in the docblock, not `ignoreErrors`.
- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary,
`@param` descriptions, and the typed-array information. The bare-tag form remains
@@ -338,8 +423,7 @@ public function __construct(public array $entries)
}
```
-**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does
-not extend to interfaces; see "Correct" below for an Internal/ interface):
+**Prohibited.** PHPDoc on anything inside `src/Internal/`:
```php
namespace TinyBlocks\Http\Internal\Client;
@@ -353,26 +437,6 @@ final readonly class Url
}
```
-**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every
-method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they
-are the contract:
-
-```php
-namespace TinyBlocks\Http\Internal\Client;
-
-interface RequestResolver
-{
- /**
- * Resolves the given URL against the configured base URL.
- *
- * @param string $url The path or absolute URL to resolve.
- * @return string The absolute URL to dispatch.
- * @throws MalformedPath If the URL violates RFC 3986.
- */
- public function resolve(string $url): string;
-}
-```
-
**Correct.** Generic array type with summary and `@param` description:
```php
@@ -490,6 +554,40 @@ if ($value < 0 || $value > 16) {
}
```
+The `.` operator is never used to assemble a string. Value prefixes, value suffixes, inline
+fragments, and plain joins all go through `sprintf` with a `$template`. This holds even when
+no value is interpolated, for example when joining a directory and a file name.
+
+The sole exception is a placeholder-free `const` string literal that would exceed 120
+characters on a single line: it may use `.` to split across lines, since `sprintf` would
+not shorten the line and heredoc is unavailable in constant expressions.
+
+**Prohibited.** Concatenation to inject a value:
+
+```php
+$candidate = is_int($value) ? '@' . $value : $value;
+```
+
+**Correct.** `$template` plus `sprintf`:
+
+```php
+$template = '@%d';
+$candidate = is_int($value) ? sprintf($template, $value) : $value;
+```
+
+**Prohibited.** Concatenation to join strings:
+
+```php
+$location = $directory . '/' . $file;
+```
+
+**Correct.** A single `$template` for the join:
+
+```php
+$template = '%s/%s';
+$location = sprintf($template, $directory, $file);
+```
+
## Constructor chaining
PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in
@@ -499,7 +597,7 @@ everywhere a `new` is followed by a method call.
**Prohibited.** Parentheses around the `new` expression:
```php
-$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com'))
+$body = (new ServerRequest(uri: 'https://api.example.com', method: 'GET'))
->withHeader('Accept', 'application/json')
->getBody();
```
@@ -507,16 +605,262 @@ $body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com'))
**Correct.** No parentheses:
```php
-$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+$body = new ServerRequest(uri: 'https://api.example.com', method: 'GET')
->withHeader('Accept', 'application/json')
->getBody();
```
+## Duplication
+
+When two or more places share logic, extract it into a collaborator (a value object, or a class
+in `src/Internal/`), or move it onto a collaborator both call sites already depend on. The type
+that owns the data owns the derived behavior.
+
+A shared base class is not available: inheritance between concrete classes is prohibited (see
+"Inheritance and constructors"). A shared private helper is not available either: private methods
+on public classes are prohibited (rule 13). Composition is therefore the only mechanism, and
+leaving the duplication in place is never the resolution.
+
+**Prohibited.** The same derivation copied byte for byte into two types:
+
+```php
+final readonly class Exam
+{
+ public function __construct(public int $score) {}
+
+ public function grade(): Grade
+ {
+ return match (true) {
+ $this->score >= 90 => Grade::A,
+ $this->score >= 80 => Grade::B,
+ $this->score >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+
+final readonly class Assignment
+{
+ public function __construct(public int $score) {}
+
+ public function grade(): Grade
+ {
+ return match (true) {
+ $this->score >= 90 => Grade::A,
+ $this->score >= 80 => Grade::B,
+ $this->score >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+```
+
+**Correct.** The derivation lives once on the collaborator both types hold, and each delegates:
+
+```php
+final readonly class Score
+{
+ public function __construct(public int $value) {}
+
+ public function toGrade(): Grade
+ {
+ return match (true) {
+ $this->value >= 90 => Grade::A,
+ $this->value >= 80 => Grade::B,
+ $this->value >= 70 => Grade::C,
+ default => Grade::F
+ };
+ }
+}
+
+final readonly class Exam
+{
+ public function __construct(public Score $score) {}
+
+ public function grade(): Grade
+ {
+ return $this->score->toGrade();
+ }
+}
+
+final readonly class Assignment
+{
+ public function __construct(public Score $score) {}
+
+ public function grade(): Grade
+ {
+ return $this->score->toGrade();
+ }
+}
+```
+
+## Polymorphism and tell-don't-ask
+
+This refines rules 9 and 24. A `match` on an enum, on a scalar, or on a value condition stays
+correct. What is prohibited is branching on the runtime type of polymorphic collaborator the
+library defines: when behavior differs across the concrete implementations of an interface the
+library owns, that behavior is a method on the interface, resolved by the object itself, never an
+`instanceof` or `get_class` chain at the call site.
+
+The opening sentence holds only for control flow. When a branch on an enum case yields a value or
+behavior that belongs to the case itself, a token, a flag about the case's nature, or a derived
+value, that value or behavior is a method on the enum: a predicate `isXxx()`, or a vocabulary
+method that returns the value, called at the site instead of comparing the case. Comparing a case
+(`$direction === Order::ASCENDING`, `match ($direction)`) stays correct for control flow whose
+outcome is not a property of the case. This is the enum form of tell-don't-ask, and the companion
+of the modeling rule that enums carry methods only when those methods hold vocabulary meaning (see
+`php-library-modeling.md`, "Enums"): a case that drives a derived value is exactly that vocabulary.
+
+A consumer is outside this rule. A consumer matching on a sealed type the library exposes (for
+example, translating a parsed tree into its own store) cannot add methods to the library's types,
+so its `instanceof` is legitimate. The rule binds the library's own code.
+
+A type the library owns may `instanceof` its own internal types at construction or registration
+time, to invoke behavior that exists only on the concrete type and that cannot be lifted onto a
+public extension interface without breaking external implementers. The minimal public interface
+outweighs the local, build-time type check.
+
+Tell-don't-ask. Behavior that depends on a collaborator's state belongs to the collaborator. Do
+not read a collaborator's fields to recompute a result the collaborator should produce. Ask it for
+the result, not for its parts. A getter exposes a value the caller needs as data, it is not a
+license to reimplement the collaborator's logic at the call site. Tell-don't-ask binds the types
+the library owns. Reading a value off a type the library does not own (a dependency's value object,
+a PSR type) and computing with it is interop, not a violation: the library cannot add a method to a
+type it does not control. The rule still binds the library's own types.
+
+**Prohibited.** Dispatching on the concrete type of interface the library owns:
+
+```php
+return match (true) {
+ $discount instanceof Percentage => $amount->multiplyBy(factor: $discount->rate()),
+ $discount instanceof Fixed => $amount->subtract(other: $discount->amount())
+};
+```
+
+**Correct.** The behavior is a method on the interface, resolved by the object:
+
+```php
+return $discount->applyTo(amount: $amount);
+```
+
+**Prohibited.** Comparing an enum case to produce a value the case owns:
+
+```php
+$token = match ($direction) {
+ Order::ASCENDING => '',
+ Order::DESCENDING => '-'
+};
+```
+
+**Correct.** A vocabulary method on the enum returns the value, called at the site:
+
+```php
+enum Order: string
+{
+ case ASCENDING = 'asc';
+ case DESCENDING = 'desc';
+
+ public function token(): string
+ {
+ return match ($this) {
+ self::ASCENDING => '',
+ self::DESCENDING => '-'
+ };
+ }
+}
+
+$token = $direction->token();
+```
+
+**Prohibited.** Reading a collaborator's parts to recompute what it already owns:
+
+```php
+$doubled = Money::of(amount: $price->amount() * 2, currency: $price->currency());
+```
+
+**Correct.** Telling the collaborator to produce the result:
+
+```php
+$doubled = $price->multiplyBy(factor: 2);
+```
+
+## Return statements
+
+A method has at most three `return` statements. The cap keeps methods small and their control
+flow scannable, and it complements rule 9: early returns are the preferred alternative to `else`,
+but they stop being a simplification once a method accumulates more than three exit points.
+Invariant violations are signaled with a `throw`, not a `return`, so guard clauses usually do not
+add to the count.
+
+**Prohibited.** Four return points:
+
+```php
+public function classify(int $score): Grade
+{
+ if ($score >= 90) {
+ return Grade::A;
+ }
+
+ if ($score >= 80) {
+ return Grade::B;
+ }
+
+ if ($score >= 70) {
+ return Grade::C;
+ }
+
+ return Grade::F;
+}
+```
+
+**Correct.** Single return through `match`:
+
+```php
+public function classify(int $score): Grade
+{
+ return match (true) {
+ $score >= 90 => Grade::A,
+ $score >= 80 => Grade::B,
+ $score >= 70 => Grade::C,
+ default => Grade::F
+ };
+}
+```
+
## Formatting overrides
-Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
+Four formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
only). Apply them manually.
+### Single-line signatures within 120 characters
+
+A function or constructor signature stays on one line when the whole signature fits within the
+120-character limit. Do not break the parameter list onto multiple lines unless the single-line
+form would exceed 120 characters. The opening brace still goes on its own line (PSR-12). Break to
+one parameter per line only when the signature genuinely overflows.
+
+**Prohibited.** Multiline signature that fits on one line:
+
+```php
+private function __construct(
+ public ExternalReference $id,
+ public Money $amount,
+ public OrderContext $context
+) {
+}
+```
+
+**Correct.** Single line within 120 characters:
+
+```php
+private function __construct(public ExternalReference $id, public Money $amount, public OrderContext $context)
+{
+}
+```
+
+When the one-line form would exceed 120 characters, break to one parameter per line and apply the
+no-vertical-alignment and no-trailing-comma rules below.
+
### No vertical alignment in parameter lists
Use a single space between the type and the variable name in parameter lists (constructors,
diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md
deleted file mode 100644
index feefcf5..0000000
--- a/.claude/rules/php-library-commits.md
+++ /dev/null
@@ -1,111 +0,0 @@
----
-description: Conventional Commits format. Applied on request when generating commit messages.
----
-
-# Commits
-
-Applied only when generating commit messages, never automatically. All commit messages are
-written in English.
-
-## Format
-
-`: `
-
-The description starts with a capital letter, uses imperative present tense ("Add", "Fix",
-"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300
-characters. If it does not fit, split the change into multiple commits or move detail into the
-body.
-
-Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone.
-
-## Allowed types
-
-Each entry below is a bullet that starts with a capital letter and ends with a period. This is
-the canonical example of bullet punctuation enforced everywhere in this document.
-
-- `ci` for CI configuration changes.
-- `fix` for a bug fix.
-- `feat` for a user-facing feature.
-- `docs` for documentation only.
-- `test` for adding or correcting tests.
-- `chore` for maintenance with no production code change.
-- `build` for build or dependency changes.
-- `revert` for reverting a previous commit.
-- `refactor` for a code change that neither fixes a bug nor adds a feature.
-
-`style` is not used. Formatting is enforced by the linter and never appears as a standalone
-commit.
-
-## Subject examples
-
-Good:
-
-- `fix: Handle zero-amount transactions.`
-- `feat: Add order cancellation endpoint.`
-- `refactor: Extract OrderStatus into its own enum.`
-
-Bad:
-
-- `Added order cancellation` is past tense, missing type, missing period.
-- `feat: Adds order cancellation.` is third-person singular instead of imperative.
-- `feat: added order cancellation.` starts lowercase and is past tense.
-- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split.
-- `feat(orders): Add cancellation.` uses a scope. Prohibited.
-
-## Body
-
-The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body
-ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for
-an external bug, a decision worth recording).
-
-Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain
-why, not what. The diff already shows what.
-
-## Prose vs. bullets in the body
-
-**Default to prose.** One or two paragraphs fits almost every commit that has a body at all.
-
-**Use bullets only when ALL of these are true:**
-
-1. The commit covers 3 or more independent changes that genuinely belong in the same commit.
-2. The list cannot be expressed as continuous prose without becoming disconnected sentences.
-3. Each item is independently meaningful (no sub-bullets, no continuation across bullets).
-
-A two-item bullet list is the wrong shape. Use prose.
-
-## Bullet formatting (when used)
-
-Every bullet starts with a capital letter and ends with a period. Imperative verb in present
-tense, same as the subject line. Without exception.
-
-Wrong (do NOT generate):
-
-- `add the OrderCancelling port` lowercase, missing period.
-- `Add the OrderCancelling port` capital but missing period.
-- `Adds the OrderCancelling port.` third-person singular instead of imperative.
-
-## Body example with bullets
-
-```
-feat: Add order cancellation flow.
-
-- Add the OrderCancelling inbound port and OrderCancellingHandler.
-- Add the CancelOrder command and its validator.
-- Cover the cancellation path in the integration test suite.
-```
-
-## Body example with prose (preferred for most commits)
-
-```
-fix: Handle zero-amount transactions.
-
-The payment gateway rejects zero-amount charges with a generic 400 instead
-of a documented error code, so the adapter short-circuits before the HTTP
-call and raises ZeroAmountNotAllowed directly.
-```
-
-## Commit splitting
-
-Prefer one logical change per commit. Refactor commits never modify behavior. When a task
-requires multiple types of change, produce multiple commits in order: `refactor` first, then
-`feat` or `fix` on top.
diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md
index b7e0da4..a29de61 100644
--- a/.claude/rules/php-library-documentation.md
+++ b/.claude/rules/php-library-documentation.md
@@ -1,16 +1,23 @@
---
-description: Standards for README and other public-facing Markdown docs in PHP libraries.
+description: Conventions for README and public-facing Markdown docs in PHP libraries.
paths:
- - "**/*.md"
+ - "README.md"
+ - "docs/**/*.md"
---
# Documentation
-Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules
-for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see
-the American English section in `php-library-code-style.md`).
+Conventions for `README.md` and the public-facing Markdown a library ships. PHPDoc rules for
+`.php` files live in `php-library-code-style.md`. American English applies everywhere (see the
+American English section in `php-library-code-style.md`).
-The `CONTRIBUTING.md` file is centralized at
+The **canonical bodies** of the non-README repository files (`SECURITY.md`, the issue templates,
+the pull request template) are not duplicated here. They live as drop-in assets in the
+`tiny-blocks-create` skill, the single source of truth for those files. This rule governs how
+the README and any `docs/` Markdown are written. "Required repository files" below lists which
+files must exist and points to the skill for their content.
+
+`CONTRIBUTING.md` is centralized at
`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and
pull request template link to that location. No local `CONTRIBUTING.md` is created per library.
@@ -25,16 +32,16 @@ outputting.
3. Header is followed by an anchor-linked table of contents.
4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for
second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every
- heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by
- a single `* [FAQ](#faq)` line regardless of how many questions it contains.
+ heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by a
+ single `* [FAQ](#faq)` line regardless of how many questions it contains.
5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional),
License, Contributing.
6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip
it entirely when not needed.
7. **Self-contained code examples** are blocks that include any of: a `use` statement, a
- `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of
- executable code. Self-contained blocks open with ` Author, *Title* (Publisher, Year), Chapter X, "Section Name".`
13. License and Contributing sections each follow the canonical one-line template.
-14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`,
- `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each
- matching the canonical template in "Other documentation files".
+14. The repository contains the required non-README files listed in "Required repository files",
+ each matching its canonical asset in the `tiny-blocks-create` skill.
## README
@@ -79,15 +85,15 @@ The first line is `# ` followed by a blank line and the license badge:
[](https://github.com/tiny-blocks//blob/main/LICENSE)
```
-Replace `` with the library's repository name. The badge is the only badge in the document.
+Replace `` with the library's repository name. The badge is the only badge in the
+document.
### Table of contents
-The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3)
-entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces.
-Every heading from the document appears, with one exception: the FAQ is represented by a single
-`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many
-exist.
+The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) entries
+use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. Every heading
+from the document appears, with one exception: the FAQ is represented by a single
+`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many exist.
```markdown
* [Overview](#overview)
@@ -100,29 +106,17 @@ exist.
* [Contributing](#contributing)
```
-Use the third level whenever the document has H4 headings, regardless of whether they form a
-two-axis split. The TOC mirrors the document structure exactly.
-
-```markdown
-* [How to use](#how-to-use)
- + [Entity](#entity)
- - [Single-field identity](#single-field-identity)
- - [Compound identity](#compound-identity)
- + [Aggregate](#aggregate)
-```
+Use the third level whenever the document has H4 headings. The TOC mirrors the document structure
+exactly.
### Code examples
Code examples fall into two categories.
-**Self-contained examples** include at least one of:
-
-- A `use` statement.
-- A `class`, `enum`, `interface`, `trait`, or `function` declaration.
-- More than 3 lines of executable code.
-
-They open with `push(records: $order->recordedEvents());
```
-**Inline fragment examples** have all of:
-
-- At most 3 lines of executable code.
-- No `use` statements.
-- No type declarations.
-
-Fragments may omit the prologue.
+**Inline fragment examples** have at most 3 lines of executable code, no `use` statements, and no
+type declarations. Fragments may omit the prologue.
```php
Code::OK->value;
```
-The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground.
+The criteria are mechanical: a block meeting any self-contained condition gets the prologue. A
+block meeting every fragment condition may omit it. There is no middle ground.
The `#` convention for inline comments applies only to code examples inside Markdown files. PHP
files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see
-item 16 in `php-library-code-style.md`).
+rule 16 in `php-library-code-style.md`).
### FAQ
@@ -173,31 +158,24 @@ FAQ entries are numbered with zero-padded prefixes and end with a question mark:
### 01. Why is DomainEvent close to a marker interface?
A domain event is a fact about something that happened in the domain. The contract carries only
-`revision()` so the library can route schema migrations through upcasters. Everything else
-(aggregate identity, sequence number, aggregate type, occurrence timestamp) is envelope metadata
-that belongs to `EventRecord`.
+`revision()` so the library can route schema migrations through upcasters.
> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8,
> "Domain Events".
```
-Bibliographic citations follow the format
-`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section
-fragments are optional when the title is precise enough on its own. Multiple citations can be
-stacked as separate blockquote lines.
+Bibliographic citations follow `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".`
+The chapter and section fragments are optional when the title is precise enough. Multiple
+citations stack as separate blockquote lines.
### License and Contributing
-The License section is a single line:
-
```markdown
## License
is licensed under [MIT](LICENSE).
```
-The Contributing section is a single line pointing to the centralized guideline:
-
```markdown
## Contributing
@@ -211,103 +189,15 @@ Tables are preferred to prose for any structured information: constructor parame
builder method catalogs, default value tables, complexity tables, and configuration matrices.
Column layout is chosen per case. No fixed column set is mandated.
-## Other documentation files
-
-Every library repository includes the following files in addition to the README. Each follows
-the canonical template below.
-
-### SECURITY.md
-
-```markdown
-# Security Policy
-
-## Supported versions
-
-Only the latest release receives security updates.
-
-## Reporting a vulnerability
-
-Report security vulnerabilities privately via
-[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new).
-
-Please do not disclose the vulnerability publicly until it has been addressed.
-```
-
-Replace `` with the repository name.
-
-### .github/ISSUE_TEMPLATE/bug_report.md
-
-```markdown
----
-name: Bug report
-about: Report a bug to help improve the library
-labels: bug
----
-
-## Description
-
-A clear and concise description of the bug.
+## Required repository files
-## Steps to reproduce
+In addition to the README, every library repository contains the files below. Their canonical
+bodies are the drop-in assets in the `tiny-blocks-create` skill. This rule only asserts they
+must exist and match those assets.
-1.
-2.
-3.
-
-## Expected behavior
-
-What should happen.
-
-## Actual behavior
-
-What actually happens.
-
-## Environment
-
-- PHP version:
-- Library version:
-- OS:
-```
-
-### .github/ISSUE_TEMPLATE/feature_request.md
-
-```markdown
----
-name: Feature request
-about: Suggest a feature for the library
-labels: enhancement
----
-
-## Problem
-
-What problem does this feature solve?
-
-## Proposed solution
-
-How should the feature work?
-
-## Alternatives considered
-
-Other approaches considered.
-```
-
-### .github/PULL_REQUEST_TEMPLATE.md
-
-```markdown
-> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md).
-
-## Summary
-
-What this pull request does.
-
-## Related issue
-
-Closes #...
-
-## Checklist
-
-- [ ] Tests added or updated.
-- [ ] Documentation updated when applicable.
-- [ ] `composer review` passes.
-- [ ] `composer tests` passes.
-```
+- `SECURITY.md`: security policy (supported versions, private reporting via GitHub Security
+ Advisories). `` is substituted.
+- `.github/ISSUE_TEMPLATE/bug_report.md`: bug report template (`labels: bug`).
+- `.github/ISSUE_TEMPLATE/feature_request.md`: feature request template (`labels: enhancement`).
+- `.github/PULL_REQUEST_TEMPLATE.md`: pull request template linking the centralized contributing
+ guidelines, with the standard checklist (`composer review` passes, `composer tests` passes).
diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md
index 396c40a..c30d364 100644
--- a/.claude/rules/php-library-github-workflows.md
+++ b/.claude/rules/php-library-github-workflows.md
@@ -1,5 +1,5 @@
---
-description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries.
+description: Structure, ordering, and pinning conventions for GitHub Actions workflows in PHP libraries.
paths:
- ".github/workflows/**/*.yml"
- ".github/workflows/**/*.yaml"
@@ -7,68 +7,64 @@ paths:
# Workflows
-Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish
-to Packagist via tags and never deploy.
+Conventions for GitHub Actions workflows in PHP libraries. CD does not apply: libraries publish to
+Packagist via tags and never deploy.
-`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the
-"ci.yml" section below. Additional workflow files (security scanning, automated triage,
-scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file.
-Their trigger, job structure, and steps are chosen by their purpose.
+The **canonical `ci.yml` body** is not duplicated here. It lives as a drop-in asset in the
+`tiny-blocks-create` skill (`assets/github/workflows/ci.yml`), the single source of truth. This
+rule defines the conventions that asset satisfies and that any edit to a workflow must preserve.
-The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in
-`php-library-tooling.md`.
+`ci.yml` is mandatory. Additional workflow files (security scanning, automated triage, scheduled
+tasks, dependency updates) may exist and follow the general rules below. Their trigger, job
+structure, and steps are chosen by their purpose. The Composer scripts invoked by `ci.yml`
+(`composer review`, `composer tests`) are defined in `php-library-tooling.md`.
## Pre-output checklist
-Verify every item before producing or editing any workflow YAML. If any item fails, revise
-before outputting.
+Verify every item before producing or editing any workflow YAML. If any item fails, revise before
+outputting.
### Rules for every workflow
-These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`.
+Apply to `ci.yml` and to every additional workflow in `.github/workflows/`.
1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`,
- `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order
- of the remaining keys is preserved.
+ `permissions`, `jobs`. Absent keys are omitted. The relative order of the rest is preserved.
2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`,
- `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above.
+ `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule.
3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length
ascending.
4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence
- case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names
- start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are
- discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`).
+ case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION`). Step names start with a verb. Job keys
+ describe the job's purpose. Generic keys (`run`, `job`, `do`) are discouraged in favor of
+ descriptive identifiers (`auto-assign`, `analyze`, `notify`).
5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group`
- expression scoped by the workflow's trigger:
- - `pull_request`: `-${{ github.event.pull_request.number }}`.
- - `issues`, or `issues` combined with `pull_request`:
- `-${{ github.event.issue.number || github.event.pull_request.number }}`.
- - `push`, `schedule`, or both: `-${{ github.ref }}`.
-
- `` is the workflow's short name (`ci`, `codeql`, `auto-assign`).
+ expression scoped by the workflow's trigger, prefixed by the workflow's short purpose name
+ (`ci`, `codeql`, `auto-assign`):
+ - `pull_request`: `-${{ github.event.pull_request.number }}`.
+ - `issues`, or `issues` combined with `pull_request`:
+ `-${{ github.event.issue.number || github.event.pull_request.number }}`.
+ - `push`, `schedule`, or both: `-${{ github.ref }}`.
6. `permissions` is declared at the workflow root with the minimum scope every job needs.
- Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope
- than the root, never broader.
+ Job-level `permissions` are allowed only when a specific job needs a narrower scope than the
+ root, never broader.
7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight
- script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL,
- security scanning). Adjust based on observed runtime when prior runs exist.
-8. Every action is pinned to a fixed major version tag written explicitly. Examples are
- `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch
- name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major
- version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`,
- `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is
- required only when the existing pin is missing, ambiguous, or pointing to a non-version
- reference. Example versions cited in this file may be outdated and are not a license to skip
- the lookup when it is required.
+ script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, security
+ scanning). Adjust based on observed runtime when prior runs exist.
+8. Every action is pinned to a fixed, immutable ref: a version tag at any granularity (major, minor, or patch) or a
+ commit SHA. Moving refs (branch names such as @main/@master, or @v with no version) are prohibited. Do not normalize
+ an explicit minor or patch pin down to its major, preserve the granularity the maintainer chose.
9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`.
10. All text (workflow name, job names, step names, comments) uses American English with correct
spelling and punctuation. Sentences and descriptions end with a period.
### Rules specific to ci.yml
-These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them.
+Apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them.
-1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`.
+1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. Per rule 5
+ for every workflow, with purpose `ci` and a `pull_request` trigger, `concurrency.group` is
+ `ci-${{ github.event.pull_request.number }}`.
2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`.
3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each
downstream job lists its upstream jobs in `needs`.
@@ -76,212 +72,33 @@ These rules apply only to `.github/workflows/ci.yml`. Additional workflows are n
`composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job
output `php-version`. Downstream jobs reference
`${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP.
-5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both
- scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is
- invoked in either job.
+5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. No other
+ command is invoked in either job.
6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named
`vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of
running `composer install` again.
-7. The `tests` job is the only job that may extend with extra setup required by the library,
- such as service containers, fixture preparation, or environment variables used during
- testing. The other three jobs are identical across every library in the ecosystem.
-8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5
- for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is
- `contents: read`.
-
-## ci.yml
-
-`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the
-exact order below. The first three jobs are identical across every library. Only `tests` may
-extend with extra setup required by the library.
-
-### Resolve PHP version
-
-Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the
-output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs
-consume the output to configure their PHP setup.
-
-### Build
-
-Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with
-`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and
-`composer.lock` as the artifact `vendor-artifact`.
-
-### Auto review
-
-Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs
-`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style
-checks for the library.
-
-### Tests
-
-Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs
-`composer tests`. Any setup required by the library's tests (service containers, fixture preparation,
-environment variables used during testing) lives in this job only.
-
-## Reference shape
-
-The YAML below is the canonical minimal form. Every library starts from this exact shape and extends
-only the `tests` job when its tests require extra setup. Action versions cited here may be outdated.
-Look up the current major version of every action via web search before adopting this shape verbatim.
-
-### Minimal workflow
-
-```yaml
-name: CI
-
-on:
- pull_request:
-
-concurrency:
- group: pr-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- resolve-php-version:
- name: Resolve PHP version
- runs-on: ubuntu-latest
- timeout-minutes: 5
- outputs:
- php-version: ${{ steps.config.outputs.php-version }}
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Resolve PHP version from composer.json
- id: config
- run: |
- version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1)
- echo "php-version=$version" >> "$GITHUB_OUTPUT"
-
- build:
- name: Build
- needs: resolve-php-version
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - 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
- needs: [resolve-php-version, build]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - 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
- needs: [resolve-php-version, auto-review]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Download vendor artifact from build
- uses: actions/download-artifact@v8
- with:
- name: vendor-artifact
- path: .
-
- - name: Run tests
- run: composer tests
-```
-
-### Extending the tests job
-
-When the library's tests need external services, env vars, or fixture preparation, the additions live
-inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service
-container and the env vars consumed by the test suite.
-
-```yaml
-tests:
- name: Tests
- needs: [resolve-php-version, auto-review]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- env:
- DB_HOST: 127.0.0.1
- DB_NAME: library_test
- DB_PORT: '3306'
- DB_USER: library
- DB_PASSWORD: library
- services:
- mysql:
- image: mysql:8
- ports:
- - 3306:3306
- env:
- MYSQL_DATABASE: library_test
- MYSQL_ROOT_PASSWORD: library
- options: >-
- --health-cmd="mysqladmin ping"
- --health-interval=10s
- --health-timeout=5s
- --health-retries=5
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Download vendor artifact from build
- uses: actions/download-artifact@v8
- with:
- name: vendor-artifact
- path: .
-
- - name: Run tests
- run: composer tests
-```
+7. The `tests` job is the only job that may extend with extra setup the library needs (service
+ containers, fixture preparation, environment variables used during testing). The other three
+ jobs are identical across every library in the ecosystem.
+8. `timeout-minutes` is 5 for `resolve-php-version` and 15 for `build`, `auto-review`, and
+ `tests`. `permissions` is `contents: read`.
+
+## ci.yml job sequence
+
+`ci.yml` gates every pull request with four jobs in this exact order. The first three are
+identical across every library. Only `tests` may extend.
+
+- **Resolve PHP version.** Reads `.require.php` from `composer.json` and exposes the minor version
+ as the output `php-version`. A single step uses `jq` and a short regex to extract the value.
+- **Build.** Sets up PHP using the resolved version, validates `composer.json`, installs with
+ `--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and
+ `composer.lock` as `vendor-artifact`.
+- **Auto review.** Needs `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up
+ PHP, runs `composer review` (phpcs + phpstan).
+- **Tests.** Needs `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up
+ PHP, runs `composer tests` (phpunit + infection). Library-specific test setup lives in this job
+ only.
+
+To extend the `tests` job (external services, env vars, fixtures), the additions go inside the
+`tests` job exclusively. The skill asset includes an extended example with a MySQL service
+container.
diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md
index 127413c..a587b54 100644
--- a/.claude/rules/php-library-modeling.md
+++ b/.claude/rules/php-library-modeling.md
@@ -44,7 +44,9 @@ algorithm. If any item fails, revise before outputting.
12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message
for end-user display). They signal invariant violations only, never control flow.
13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary
- meaning.
+ meaning. A value or behavior a case owns lives on the enum as that method, called instead of a
+ `match` on the case at the site. See "Polymorphism and tell-don't-ask" in
+ `php-library-code-style.md`.
14. Extension points use `class` instead of `final readonly class`. They expose a private
constructor with static factory methods as the only creation path. Internal state is
injected via the constructor.
@@ -52,6 +54,9 @@ algorithm. If any item fails, revise before outputting.
or worse needs explicit justification.
16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage
is bounded and proportional to the output, not to the sum of intermediate stages.
+17. A configuration-like value object whose fields are mostly optional exposes a no-argument
+ baseline factory (`default()`) plus fluent immutable `with*` copies, not a single factory
+ whose signature lists every field. See "Value objects".
## Modeling principles
@@ -63,7 +68,8 @@ Apply the following principles where they sharpen the design. Treat them as guid
domain uses. Code and conversation share the same terms.
- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance.
Substitutability holds at every interface boundary.
-- DRY. No duplicated logic across two or more places.
+- DRY. No duplicated logic across two or more places. See "Duplication" in
+ `php-library-code-style.md` for how to resolve it without inheritance or private helpers.
- KISS. No abstraction without real duplication or isolation need.
## Nomenclature
@@ -126,6 +132,14 @@ The test. If the consumer instantiates or extends this class to integrate with t
role name is legitimate. If the class models a concept the consumer manipulates (a money amount,
a country code, a color), the role name is wrong.
+**Scope.** The architectural-role banlist and the anemic-verb banlist apply to the **public
+surface**: types at the `src/` root, types in public `/` folders, and public
+exception and contract names. Inside `src/Internal/` (implementation detail by definition, where
+the namespace is the boundary), a collaborator may carry a mechanical role or operation name that
+describes its job (`Decoder`, `Encoder`, `Parser`, `Resolver`), since consumers never see or
+manipulate it. The always-banned names (`Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`)
+remain banned everywhere, `Internal/` included.
+
## Value objects
- Are immutable. No setters. No mutation after construction. Operations return new instances.
@@ -171,6 +185,26 @@ Money::of(amount: 1000, currency: Currency::BRL);
Money::zero(currency: Currency::USD);
```
+When a value object is configuration-like and most of its fields are optional with defaults, prefer
+a baseline factory that takes no required arguments (`default()`, or `from()` with every parameter
+defaulted) together with fluent immutable `with*` copies, over a single factory whose signature
+carries every field. Each `with*` returns a new instance. Prefer the `with*` methods on the value
+object itself over a separate mutable builder class: the value object is already immutable, so it
+is its own builder. The smell is a factory signature that lists every field while most are
+optional.
+
+**Prohibited.** A single factory whose signature carries every field, most of them optional:
+
+```php
+MoneyFormat::from(scale: 4, symbol: '€', grouping: ',');
+```
+
+**Correct.** A baseline `default()` plus fluent `with*` copies that override only what differs:
+
+```php
+MoneyFormat::default()->withScale(scale: 4)->withGrouping(grouping: ',');
+```
+
## Exceptions
- Every failure throws a dedicated exception class named after the invariant it guards. Never
@@ -227,7 +261,11 @@ if ($value < 0 || $value > 16) {
- Are PHP backed enums.
- Include methods only when those methods carry vocabulary meaning. Examples are
- `Order::ASCENDING_KEY` and `RoundingMode::apply()`.
+ `OrderStatus::isFinal()` and `RoundingMode::apply()`.
+- A value or behavior a case owns (a token, a flag, a derived value) is one of those vocabulary
+ methods, a predicate `isXxx()` or a method returning the value, called at the site instead of a
+ `match` comparing the case. This is the enum form of tell-don't-ask. See "Polymorphism and
+ tell-don't-ask" in `php-library-code-style.md`.
## Extension points
diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md
index 86a0c10..30a329f 100644
--- a/.claude/rules/php-library-testing.md
+++ b/.claude/rules/php-library-testing.md
@@ -36,9 +36,12 @@ Verify every item before producing any test code. If any item fails, revise befo
4. No intermediate variables used only once. Chain method calls when the intermediate state is
not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of
`$money = Money::of(...)` followed by `$money->add(...)`).
-5. No private or helper methods in test classes. The only non-test methods allowed are data
+5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit
+ lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data
providers. Setup logic complex enough to extract belongs in a dedicated fixture class.
6. Test only the public API. Never assert on private state or `Internal/` classes directly.
+ One narrow, last-resort exception covers irreducible internal elements. See "White-box
+ coverage of irreducible internals".
7. Test the behavior that **raises** an exception, never the exception itself. Exception classes
represent invariant violations and are value objects, not the subject of behavior tests. A
test constructs the conditions, invokes the public method that is supposed to fail, and
@@ -50,7 +53,12 @@ Verify every item before producing any test code. If any item fails, revise befo
removed.
8. Never mock internal collaborators. Use real objects. Test doubles are used only at system
boundaries (filesystem, clock, network) when the library interacts with external resources.
-9. Name tests after behavior, not method names.
+9. Name tests after behavior using the `testXxxWhenYyyThenZzz` shape, never after the method
+ under test. `Xxx` names the subject or operation, `Yyy` the condition, `Zzz` the expected
+ outcome (for example, `testAddMoneyWhenSameCurrencyThenAmountsAreSummed`). The `When`/`Then`
+ structure is mandatory. The `@Given`/`@When`/`@Then`/`@And` annotation blocks describe the
+ steps within. A condition-free operation may collapse to `testXxxThenZzz` when there is no
+ meaningful precondition to name.
10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`,
`$fake`, `$dummy` as variable or property names. Use the domain concept the object
represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like
@@ -59,8 +67,9 @@ Verify every item before producing any test code. If any item fails, revise befo
`/** @Given a mocked collection in test state */`.
12. Never use the `/** @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.
+13. Named arguments are never used on PHPUnit assertions and expectations. Arguments are passed
+ positionally. The canonical rule and its full exclusion list live in
+ `php-library-code-style.md` rule 4.
14. Never include conditional logic inside tests. Each `@Then` block expresses one logical
concept. The only allowed `try`/`catch` is when the assertion target is a property of the
caught exception that cannot be expressed via `expectException*` methods (notably
@@ -69,6 +78,23 @@ Verify every item before producing any test code. If any item fails, revise befo
15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from
coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See
"Coverage and mutation discipline".
+16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit
+ test-class sub-grouping).
+
+## Generics in test PHPDoc
+
+The "zero PHPDoc anywhere inside `tests/`" rule (defined in `php-library-code-style.md`) has one
+narrow exception: PHPDoc that exists *purely to express generics* the native type system cannot.
+A test fixture that extends a generic public type carries the type argument with `@extends` (for
+example `@extends Collection` on an `Invoices` fixture), and a generics-only `@var` may
+pin a type parameter at an inference point where an imprecise result feeds a typed sink (for
+example `/** @var Collection $shipments */` before passing a mapped collection to
+`Shipments::createFrom(...)`). These tags carry only the type-parameter information, never a
+summary or prose description. Every other form of PHPDoc (summaries, `@param`/`@return`
+descriptions on test methods, fixtures, data providers, or anonymous classes) stays prohibited.
+This is the same carve-out stated in `php-library-code-style.md` under "When prohibited",
+restated here because it most often surfaces on collection fixtures and inference points in
+`tests/`.
## Structure: Given/When/Then (BDD)
@@ -116,9 +142,6 @@ public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void
}
```
-Use `@And` for complementary preconditions or actions within the same scenario, avoiding
-consecutive `@Given` or `@When` tags.
-
## Testing exceptions
Exception classes are value objects describing an invariant violation. They are not the subject
@@ -202,8 +225,8 @@ code. Remove it instead of writing a behavior test against a constructor.
does not cover.** Message, code, and class are covered by PHPUnit (`expectException`,
`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those
methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors
-that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific
-accessors on a `TransportFailure` (`url()`, `method()`, `reason()`).
+that PHPUnit cannot reach, notably `getPrevious()` for chain inspection, or domain-specific
+accessors on a `HttpNetworkFailed` (`url()`, `method()`, `reason()`).
**Prohibited.** `try`/`catch` to assert message:
@@ -227,22 +250,11 @@ $http->send(request: $request);
## Test setup and fixtures
-- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or
- assignment. Never place multiple declarations under a single annotation. The exception for
- data-provider tests applies here as well (see rule 3).
-- No intermediate variables used only once. Chain method calls when the intermediate state is
- not referenced elsewhere.
-- No private or helper methods in test classes. The only non-test methods allowed are data
- providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in
- a private method on the test class.
-- Domain terms in variables and properties. 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.
-- 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.
+Checklist items 3, 4, 5, 10, and 11 govern setup blocks: one declaration per annotation, no
+single-use intermediate variables, no private or helper methods, domain-named variables, and
+domain-language annotations. The examples below illustrate the rules most often violated in
+practice. Double naming (the `$spy`/`$mock` banlist and the class-name suffix nuance) is detailed
+in "Test doubles" below.
**Prohibited.** Multiple declarations under a single annotation:
@@ -318,8 +330,43 @@ Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, du
- Never suppress mutants via `infection.json.dist` or any other mechanism.
- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the
production code to make it testable. Never work around the tool.
+- The sole exception is an irreducible internal element (a non-functional memoization
+ cache, or the private constructor of a static-only surface) that cannot be reached
+ publicly without harming the design. It is covered or killed through a reflection-based
+ white-box test, never through suppression. See "White-box coverage of irreducible
+ internals".
Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are
enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/`
because every mutation must be killed by an assertion. This file covers only the behavioral
rules that complement those thresholds.
+
+## White-box coverage of irreducible internals
+
+Rules 6 and 15 are near-absolute: tests exercise the public API, refactoring is the response
+when a line or mutation resists coverage, and code is never hidden from coverage or mutation.
+They yield in one narrow case: an *irreducible* internal element that cannot be reached
+through the public API without either removing a legitimate non-functional optimization or
+defeating a deliberate design. Two such elements recur:
+
+- **Memoization caches.** A purely non-functional cache (a resolved-mapping cache, a
+ shared-instance cache, a reflection-descriptor cache) whose removal leaves behavior
+ identical. The mutant that drops the cache is an equivalent mutant: no public observation
+ distinguishes the cached path from the recomputed one, so no public-API test can kill it.
+- **Intentionally-uncallable members.** The private constructor of a static-only surface (a
+ class that exists solely to expose static factories and must never be instantiated). It is
+ never executed through any public path, so its line stays uncovered by construction.
+
+For these, and only these, a white-box test is permitted as a last resort: reflecting into
+`Internal/` private state to assert that memoization holds, or reflection-invoking an
+uncallable constructor so its line is covered. Such a test still follows the BDD structure
+and `testXxxWhenYyyThenZzz` naming, and the repeated-invocation `@When` exception (checklist
+item 3) already covers the memoization case.
+
+This exception covers code. It never hides it. `@codeCoverageIgnore`, coverage-excluding
+configuration, and mutant suppression remain prohibited without exception. The irreducible
+element is killed or covered honestly through reflection, not excluded from the metric. The
+burden is on demonstrating irreducibility: if the line or mutation can be reached through the
+public API, or if a proportionate refactor would expose it without harming the design, this
+exception does not apply and the public-API test is required. White-box access is never a
+convenience and never the first resort.
diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md
index 3b55111..8cf50d2 100644
--- a/.claude/rules/php-library-tooling.md
+++ b/.claude/rules/php-library-tooling.md
@@ -1,10 +1,12 @@
---
-description: Canonical config files for PHP libraries in the tiny-blocks ecosystem.
+description: Invariants for the canonical config files of PHP libraries in the tiny-blocks ecosystem.
paths:
- "composer.json"
- "phpcs.xml"
+ - "phpstan.neon"
- "phpstan.neon.dist"
- "phpunit.xml"
+ - "infection.json"
- "infection.json.dist"
- ".editorconfig"
- ".gitattributes"
@@ -14,451 +16,123 @@ paths:
# Tooling
-Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a
-fixed shape. Deviations require justification. Folder structure lives in
-`php-library-architecture.md`. Code style lives in `php-library-code-style.md`.
+Invariants that every config file in a tiny-blocks library must satisfy. The **canonical file
+bodies** (full `composer.json`, `Makefile`, `phpunit.xml`, etc.) are not duplicated here. They
+live as drop-in assets in the `tiny-blocks-create` skill, which is the single source of truth
+for scaffolding a new library or restoring a file to its canonical shape. This rule defines the
+invariants those files are checked against when editing an existing library.
+
+Folder structure lives in `php-library-architecture.md`. Code style lives in
+`php-library-code-style.md`.
## Pre-output checklist
-Verify every item before creating, editing, or relocating any of the files below. If any item
-fails, revise before outputting.
+Verify every item before creating, editing, or relocating any config file. If any item fails,
+revise before outputting.
-1. The library repository contains all the following files at its root: `composer.json`,
- `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`,
- `.gitattributes`, `.gitignore`, `Makefile`.
+1. The repository root contains all of: `composer.json`, `phpcs.xml`, `phpstan.neon.dist`,
+ `phpunit.xml`, `infection.json.dist`, `.editorconfig`, `.gitattributes`, `.gitignore`,
+ `Makefile`. (See "Config file naming" for which carry a `.dist` suffix and why.)
2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`,
- `test-file`, `tests`. No other public scripts are defined.
-3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section
- (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`).
-4. `composer.json` `description` is a single short sentence describing what the library does.
- Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer
- metadata.
-5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`.
- Additional keywords are topic tokens derived from the library's purpose (`psr-7`,
- `http-client`, `event-sourcing`, etc.).
-6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added.
-7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`,
- `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`.
+ `test-file`, `tests`. No other public scripts.
+3. `composer.json` fixed fields use the canonical values from the skill asset (`license`, `type`,
+ `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). The five universal
+ dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`,
+ `phpunit/phpunit`, `squizlabs/php_codesniffer`) are present. `require-dev` may add libraries
+ the tests need on top of those five. The asset's caret ranges are the canonical floor, and
+ the repo `composer.json` matches the asset. To bump, update the asset first, then the repo.
+4. `composer.json` `description` is a single short sentence. Multi-sentence prose belongs in the
+ README Overview, not in Composer metadata.
+5. `composer.json` includes a `keywords` array that contains `"tiny-blocks"`. Its position in
+ the array is not constrained. The remaining entries are topic tokens derived from the
+ library's purpose (`psr-7`, `http-client`, `event-sourcing`, etc.).
+6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs. Formatting rules outside
+ PSR-12 live in `php-library-code-style.md` under "Formatting overrides".
+7. `phpunit.xml` sets all five `failOn*` flags to `true` (`failOnDeprecation`, `failOnNotice`,
+ `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`).
8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`.
-9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is
+ Non-namespace root attributes are sorted alphabetically. The `xmlns:xsi` and
+ `xsi:noNamespaceSchemaLocation` declarations lead the attribute list and are not part of
+ the alphabetical run.
+9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either is
prohibited.
-10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and
- `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`.
-11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`.
- The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`.
- `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity,
- excluded from the published package).
-12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not**
- listed (it is versioned on GitHub).
-13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical
- image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly.
-14. All test artifact paths use `reports/` (plural). The directory is consistent across
- `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`.
-15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`.
-
-## composer.json
-
-Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`,
-`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five
-universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`,
-`phpunit/phpunit`, `squizlabs/php_codesniffer`).
-
-Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`,
-`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for
-example, HTTP client implementations in a PSR-7 library) on top of the five universal tools.
-
-```json
-{
- "name": "tiny-blocks/",
- "description": "",
- "license": "MIT",
- "type": "library",
- "keywords": [
- "tiny-blocks",
- "",
- ""
- ],
- "authors": [
- {
- "name": "Gustavo Freze de Araujo Santos",
- "homepage": "https://github.com/gustavofreze"
- }
- ],
- "homepage": "https://github.com/tiny-blocks/",
- "support": {
- "issues": "https://github.com/tiny-blocks//issues",
- "source": "https://github.com/tiny-blocks/"
- },
- "require": {
- "php": "^8.5"
- },
- "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\\\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Test\\TinyBlocks\\\\": "tests/"
- }
- },
- "config": {
- "allow-plugins": {
- "ergebnis/composer-normalize": true,
- "infection/extension-installer": true
- },
- "sort-packages": true
- },
- "scripts": {
- "configure": [
- "@composer install --optimize-autoloader",
- "@composer normalize"
- ],
- "configure-and-update": [
- "@composer update --optimize-autoloader",
- "@composer normalize"
- ],
- "review": [
- "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests",
- "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress"
- ],
- "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
- "tests": [
- "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
- "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage"
- ]
- }
-}
-```
-
-Script usage:
-
-- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`.
- Use this after cloning the repository or pulling new changes.
-- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by
- `composer normalize`. Use this when intentionally updating dependencies.
-- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation.
-- `composer tests` runs `phpunit` followed by `infection`. Used by CI.
-- `composer test-file ` runs a filtered subset of tests without coverage. Local
- development only.
-
-## phpcs.xml
-
-References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing
-comma, etc.) live in `php-library-code-style.md` under "Formatting overrides".
-
-```xml
-
-
- Code style for the tiny-blocks library.
-
- src
- tests
-
-```
-
-## phpstan.neon.dist
-
-Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked
-by the `review` Composer script.
-
-```neon
-parameters:
- level: max
- paths:
- - src
- - tests
- reportUnmatchedIgnoredErrors: true
-```
-
-`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max`
-(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait
-unused-method warnings on shared behavior, etc.). Each entry follows these rules:
+10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`,
+ `end_of_line = lf` as the global default under `[*]`. YAML uses `indent_size = 2` and
+ Makefile uses `indent_style = tab` as per-extension overrides.
+11. `.gitattributes` sets `* text=auto eol=lf` and lists every committed dev-only file under
+ `export-ignore`. The Packagist tarball contains only `src/`, `composer.json`, `README.md`,
+ `LICENSE`, and `SECURITY.md`. `.claude/` is listed under `export-ignore` (versioned on
+ GitHub for contributor parity, excluded from the published package), and `CLAUDE.md` (where
+ committed) is `export-ignore`d alongside it for the same reason. `.gitattributes` lists
+ only files that are actually committed: it never names a file the repository does not
+ contain (no `CONTRIBUTING.md`, which is centralized, and no phantom `.dist`/non-`.dist`
+ twin of a file that is committed under only one of those names).
+12. `.gitignore` ignores the dependency and artifact paths, the local config overrides
+ (`/phpstan.neon`, `/infection.json`), and nothing tool caches the project does not produce.
+ The `.claude/` directory itself is **not** ignored (it is versioned on GitHub). Only
+ `/.claude/settings.local.json`, the per-clone settings override, is ignored.
+13. `Makefile` wraps every PHP and Composer command in Docker using the canonical image
+ `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. Targets that share
+ a name with a Composer script delegate to it. Additional non-Composer convenience targets
+ (`help`, `clean`, `show-*`) are permitted.
+14. All test artifact paths use `reports/` (plural), consistent across `composer tests`,
+ `infection.json.dist`, `phpunit.xml`, and `Makefile`. `reports/` is listed under
+ `export-ignore` in `.gitattributes`.
+
+## Config file naming
+
+The committed config files split into two naming conventions on purpose. The split is documented
+here so it reads as intentional, not accidental.
+
+- **Committed live, no `.dist`:** `phpcs.xml` and `phpunit.xml`. The ruleset (`PSR12` only) and
+ the test configuration are stable across the whole ecosystem and identical in every library.
+ There is no per-clone local-override story, so the live file is committed directly.
+- **Committed as `.dist`:** `phpstan.neon.dist` and `infection.json.dist`. These are the two
+ tools a contributor may legitimately want to tune locally (a temporary `ignoreErrors` entry, a
+ narrower mutator set while iterating). The `.dist` baseline is committed. A contributor drops a
+ gitignored `phpstan.neon` or `infection.json` to override it, and the tool auto-resolves the
+ override over the `.dist` fallback. Those override names appear in `.gitignore`.
+
+Do not introduce a `.dist` twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live
+`phpstan.neon`/`infection.json` in place of the `.dist` baseline.
+
+## phpstan ignoreErrors
+
+`phpstan.neon.dist` runs at `level: max` on `src` and `tests`. `ignoreErrors` is permitted to
+suppress legitimate false positives produced by `level: max` (third-party signatures carrying
+`mixed`, PHP-FIG interfaces returning untyped arrays, trait unused-method warnings on shared
+behavior, and the typed-array cases routed here by `php-library-code-style.md` instead of adding
+PHPDoc). Each entry follows these rules:
- A short comment above the entry justifies its existence.
- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns.
- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing
cleanup.
-Example with `ignoreErrors`:
-
```neon
-parameters:
- level: max
- paths:
- - src
- - tests
- ignoreErrors:
- # Trait method intentionally unused by the consuming aggregate; reflection wires it.
- - identifier: trait.unused
- path: src/Internal/EventualAggregateRootBehavior.php
-
- # json_encode signature carries `mixed` for backward compatibility at level max.
- - identifier: argument.type
- path: src/Internal/Serialization/JsonEncoder.php
- reportUnmatchedIgnoredErrors: true
-```
-
-## phpunit.xml
-
-Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be
-independent of one another. Coverage and JUnit reports go under `reports/`.
-
-```xml
-
-
-
-
-
- src
-
-
-
-
-
- tests
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-Root attributes are sorted alphabetically.
-
-## infection.json.dist
-
-Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape
-make the build fail.
-
-```json
-{
- "logs": {
- "text": "reports/infection/logs/infection-text.log",
- "summary": "reports/infection/logs/infection-summary.log"
- },
- "tmpDir": "reports/infection/",
- "minMsi": 100,
- "timeout": 30,
- "source": {
- "directories": [
- "src"
- ]
- },
- "phpUnit": {
- "configDir": "",
- "customPath": "./vendor/bin/phpunit"
- },
- "mutators": {
- "@default": true
- },
- "minCoveredMsi": 100,
- "testFramework": "phpunit"
-}
-```
-
-## .editorconfig
-
-Whitespace and line ending rules applied by editor integrations.
-
-```ini
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-indent_size = 4
-indent_style = space
-max_line_length = 120
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.{yml,yaml}]
-indent_size = 2
-
-[Makefile]
-indent_style = tab
-
-[*.md]
-trim_trailing_whitespace = false
-```
-
-## .gitattributes
-
-Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The
-published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`.
-
+ignoreErrors:
+ # Trait method intentionally unused by the consuming aggregate. Reflection wires it.
+ - identifier: trait.unused
+ path: src/Internal/EventualAggregateRootBehavior.php
```
-* text=auto eol=lf
-*.php text diff=php
+## Infection mutator config
-# Dev-only, excluded from the Packagist tarball
-/.github export-ignore
-/tests export-ignore
-/.claude export-ignore
-/.editorconfig export-ignore
-/.gitattributes export-ignore
-/.gitignore export-ignore
-/phpunit.xml export-ignore
-/phpunit.xml.dist export-ignore
-/phpstan.neon export-ignore
-/phpstan.neon.dist export-ignore
-/phpcs.xml export-ignore
-/phpcs.xml.dist export-ignore
-/infection.json export-ignore
-/infection.json.dist export-ignore
-/Makefile export-ignore
-/CONTRIBUTING.md export-ignore
-/CHANGES.md export-ignore
-/reports export-ignore
-/.phpunit.cache export-ignore
-```
+`infection.json.dist` is configured with `"mutators": {"@default": true}`. That is the only
+permitted form. No `ignore` lists, no `ignoreSourceCodeByRegex`, and no per-mutator overrides
+are allowed. Every mutant the default profile produces must be killed by a test. When a mutant
+escapes, the production code is refactored to make it testable rather than the configuration
+relaxed. This aligns with `php-library-testing.md` rule 15 (no mutant suppression by any
+mechanism) and with the MSI 100 thresholds in checklist item 9.
-## .gitignore
+## Composer scripts
-Keeps the repository working tree clean of artifacts that should never be committed. Entries
-are grouped from most fundamental (PHP dependencies) to least critical (OS files). The
-`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors
-share the same rules, and it is excluded from the published Packagist tarball through
-`export-ignore` in `.gitattributes` (see above).
-
-```
-# PHP dependencies
-/vendor/
-composer.lock
+The five scripts and their purpose. Bodies live in the skill asset.
-# Tooling cache
-.phpcs-cache
-.phpunit.cache/
-.php-cs-fixer.cache
-.phpunit.result.cache
-
-# Coverage and reports
-build/
-reports/
-coverage/
-infection.log
-
-# Editors and agents
-.idea/
-.cursor/
-.vscode/
-
-# OS
-Thumbs.db
-.DS_Store
-Desktop.ini
-```
-
-## Makefile
-
-Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container
-using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script
-delegate to it directly, avoiding duplication.
-
-```makefile
-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 configure
-
-.PHONY: configure-and-update
-configure-and-update: ## Configure development environment and update dependencies
- @${DOCKER_RUN} composer configure-and-update
-
-.PHONY: tests
-tests: ## Run unit and mutation 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: review
-review: ## Run lint and static analysis
- @${DOCKER_RUN} composer review
-
-.PHONY: show-reports
-show-reports: ## Open coverage and mutation reports in the browser
- @sensible-browser reports/coverage/coverage-html/index.html reports/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 reports 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|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
- @echo ""
- @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
- @grep -E '^(tests|test-file):.*?## .*$$' $(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}'
-```
+- `composer configure` installs with `--optimize-autoloader` then normalizes. Run after cloning
+ or pulling.
+- `composer configure-and-update` updates dependencies then normalizes. Run when intentionally
+ bumping dependencies.
+- `composer review` runs `phpcs` then `phpstan`. Used by CI (`auto-review` job) and locally.
+- `composer tests` runs `phpunit` then `infection`. Used by CI (`tests` job).
+- `composer test-file ` runs a filtered subset without coverage. Local only.
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..512042f
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,232 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "permissions": {
+ "defaultMode": "default",
+ "allow": [
+ "Read",
+ "Glob",
+ "Grep",
+
+ "Edit(./**)",
+ "Write(./**)",
+
+ "Bash(make:*)",
+ "Bash(docker:*)",
+
+ "Bash(rtk gain:*)",
+ "Bash(rtk discover:*)",
+ "Bash(rtk --version)",
+
+ "Bash(rtk git status:*)",
+ "Bash(rtk git diff:*)",
+ "Bash(rtk git log:*)",
+ "Bash(rtk git show:*)",
+ "Bash(rtk ls:*)",
+ "Bash(rtk cat:*)",
+ "Bash(rtk grep:*)",
+ "Bash(rtk rg:*)",
+ "Bash(rtk head:*)",
+ "Bash(rtk tail:*)",
+
+ "Bash(rtk docker:*)",
+
+ "Bash(rg:*)",
+ "Bash(grep:*)",
+ "Bash(jq:*)",
+ "Bash(cat:*)",
+ "Bash(ls:*)",
+ "Bash(head:*)",
+ "Bash(tail:*)",
+ "Bash(wc:*)",
+ "Bash(sort:*)",
+ "Bash(uniq:*)",
+ "Bash(diff:*)",
+ "Bash(echo:*)",
+ "Bash(mkdir:*)",
+ "Bash(rmdir:*)",
+ "Bash(rm:*)",
+
+ "Bash(composer install:*)",
+ "Bash(composer validate:*)",
+ "Bash(composer outdated:*)",
+ "Bash(composer show:*)",
+
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)",
+ "Bash(git show:*)",
+ "Bash(git blame:*)",
+ "Bash(git ls-files:*)",
+ "Bash(git grep:*)",
+ "Bash(git merge-base:*)",
+ "Bash(git rev-parse:*)",
+ "Bash(git describe:*)",
+ "Bash(git shortlog:*)",
+ "Bash(git reflog show:*)",
+ "Bash(git remote -v)",
+ "Bash(git remote get-url:*)",
+ "Bash(git stash list)",
+ "Bash(git stash show:*)",
+ "Bash(git worktree list)",
+ "Bash(git config --get:*)",
+ "Bash(git config --get-all:*)",
+ "Bash(git config --list)",
+ "Bash(git config --list:*)",
+
+ "Bash(git branch)",
+ "Bash(git branch -a)",
+ "Bash(git branch -r)",
+ "Bash(git branch -v)",
+ "Bash(git branch -vv)",
+ "Bash(git branch --show-current)",
+ "Bash(git branch --list:*)",
+ "Bash(git branch --contains:*)",
+ "Bash(git branch --merged:*)",
+ "Bash(git branch --no-merged:*)",
+
+ "Bash(git tag)",
+ "Bash(git tag -l)",
+ "Bash(git tag -l:*)",
+ "Bash(git tag -n)",
+ "Bash(git tag -n:*)",
+ "Bash(git tag --list)",
+ "Bash(git tag --list:*)",
+ "Bash(git tag --contains:*)",
+ "Bash(git tag --points-at:*)",
+
+ "Bash(git rm:*)"
+ ],
+ "ask": [
+ "Edit(./.claude/**)",
+ "Write(./.claude/**)",
+
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(git mv:*)",
+
+ "Bash(composer require:*)",
+ "Bash(composer update:*)",
+ "Bash(composer remove:*)",
+ "Bash(composer normalize:*)",
+
+ "Bash(curl:*)",
+ "Bash(wget:*)"
+ ],
+ "deny": [
+ "Read(./.env)",
+ "Read(./.env.*)",
+ "Read(./**/.env)",
+ "Read(./**/.env.*)",
+ "Read(./secrets/**)",
+ "Read(./**/credentials*)",
+ "Read(./**/*.pem)",
+ "Read(./**/*.key)",
+ "Read(./**/id_rsa)",
+ "Read(./**/id_ed25519)",
+ "Read(~/.ssh/**)",
+
+ "Edit(./.env)",
+ "Edit(./.env.*)",
+ "Edit(./**/.env)",
+ "Edit(./**/.env.*)",
+ "Edit(./secrets/**)",
+ "Edit(./.git/**)",
+ "Edit(~/.bashrc)",
+ "Edit(~/.zshrc)",
+ "Edit(~/.profile)",
+ "Edit(~/.ssh/**)",
+
+ "Write(./.env)",
+ "Write(./.env.*)",
+ "Write(./**/.env)",
+ "Write(./**/.env.*)",
+ "Write(./secrets/**)",
+ "Write(./.git/**)",
+ "Write(~/.bashrc)",
+ "Write(~/.zshrc)",
+ "Write(~/.profile)",
+ "Write(~/.ssh/**)",
+
+ "Bash(php:*)",
+
+ "Bash(rm --no-preserve-root:*)",
+ "Bash(rm -rf /)",
+ "Bash(rm * /)",
+ "Bash(rm * ~)",
+ "Bash(rm * ~/)",
+ "Bash(rm * $HOME)",
+ "Bash(rm * $HOME/)",
+
+ "Bash(git push:*)",
+ "Bash(git pull:*)",
+ "Bash(git fetch:*)",
+ "Bash(git checkout:*)",
+ "Bash(git switch:*)",
+ "Bash(git restore:*)",
+ "Bash(git reset:*)",
+ "Bash(git merge:*)",
+ "Bash(git rebase:*)",
+ "Bash(git revert:*)",
+ "Bash(git cherry-pick:*)",
+ "Bash(git apply:*)",
+ "Bash(git am:*)",
+ "Bash(git stash push:*)",
+ "Bash(git stash pop:*)",
+ "Bash(git stash apply:*)",
+ "Bash(git stash drop:*)",
+ "Bash(git stash clear)",
+ "Bash(git stash save:*)",
+ "Bash(git branch -d:*)",
+ "Bash(git branch -D:*)",
+ "Bash(git branch -m:*)",
+ "Bash(git branch -M:*)",
+ "Bash(git branch -c:*)",
+ "Bash(git branch -C:*)",
+ "Bash(git tag -a:*)",
+ "Bash(git tag -s:*)",
+ "Bash(git tag -d:*)",
+ "Bash(git tag -f:*)",
+ "Bash(git tag --delete:*)",
+ "Bash(git tag --force:*)",
+ "Bash(git remote add:*)",
+ "Bash(git remote remove:*)",
+ "Bash(git remote rm:*)",
+ "Bash(git remote rename:*)",
+ "Bash(git remote set-url:*)",
+ "Bash(git submodule:*)",
+ "Bash(git worktree add:*)",
+ "Bash(git worktree remove:*)",
+ "Bash(git worktree prune:*)",
+ "Bash(git filter-branch:*)",
+ "Bash(git filter-repo:*)",
+ "Bash(git replace:*)",
+ "Bash(git notes:*)",
+ "Bash(git clean:*)",
+ "Bash(git gc:*)",
+ "Bash(git prune:*)",
+ "Bash(git reflog delete:*)",
+ "Bash(git reflog expire:*)",
+ "Bash(git config --add:*)",
+ "Bash(git config --unset:*)",
+ "Bash(git config --unset-all:*)",
+ "Bash(git config --replace-all:*)",
+ "Bash(git config --global:*)",
+
+ "Bash(eval:*)",
+
+ "Bash(sudo:*)",
+ "Bash(mysql:*)",
+ "Bash(dropdb:*)",
+ "Bash(dd:*)",
+ "Bash(chmod:*)",
+ "Bash(chown:*)",
+
+ "Bash(curl * | sh)",
+ "Bash(curl * | bash)",
+ "Bash(curl * | sudo:*)",
+ "Bash(wget * | sh)",
+ "Bash(wget * | bash)",
+ "Bash(wget * | sudo:*)"
+ ]
+ }
+}
diff --git a/.claude/skills/commit-message/SKILL.md b/.claude/skills/commit-message/SKILL.md
new file mode 100644
index 0000000..de37fcd
--- /dev/null
+++ b/.claude/skills/commit-message/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: commit-message
+description: Generate a git commit message in the tiny-blocks Conventional Commits format (type-prefixed, imperative, capitalized, period-terminated, no scopes). Use this skill whenever the user asks you to write, draft, suggest, or fix a commit message, or whenever you are about to propose commit text for staged changes, even if they do not say the words "conventional commits". Commit messages are produced on request only and are never generated automatically as part of another task.
+---
+
+# Commit message
+
+Produce a single commit message in the tiny-blocks format. This skill formats the message only.
+It never stages, commits, or runs any Git command. That happens only when the user explicitly
+asks for it.
+
+All commit messages are written in English.
+
+## Format
+
+```
+:
+```
+
+The description starts with a capital letter, uses imperative present tense (`Add`, `Fix`,
+`Change`, not `Added`, `Adds`, or `Adding`), and ends with a period. Keep the subject under 300
+characters. If it does not fit, split the change into multiple commits or move detail into the
+body.
+
+**Scopes are prohibited.** `feat(orders): ...` is wrong. The type stands alone.
+
+## Trailers
+
+Commit messages carry no trailers, regardless of any default to the contrary. Never append a
+`Co-Authored-By` line or any other trailer. The message is the type-prefixed subject and, when
+justified, a body. Nothing follows the body.
+
+## Allowed types
+
+- `ci` for CI configuration changes.
+- `fix` for a bug fix.
+- `feat` for a user-facing feature.
+- `docs` for documentation only.
+- `test` for adding or correcting tests.
+- `chore` for maintenance with no production code change.
+- `build` for build or dependency changes.
+- `revert` for reverting a previous commit.
+- `refactor` for a code change that neither fixes a bug nor adds a feature.
+
+`style` is not used. Formatting is enforced by the linter and never appears as a standalone
+commit.
+
+## Subject examples
+
+**Example 1:**
+Input: handled the case where a transaction has a zero amount
+Output: `fix: Handle zero-amount transactions.`
+
+**Example 2:**
+Input: added an endpoint to cancel an order
+Output: `feat: Add order cancellation endpoint.`
+
+**Example 3:**
+Input: pulled OrderStatus out into its own enum, no behavior change
+Output: `refactor: Extract OrderStatus into its own enum.`
+
+Reject these shapes:
+
+- `Added order cancellation`: past tense, missing type, missing period.
+- `feat: Adds order cancellation.`: third-person singular instead of imperative.
+- `feat: added order cancellation.`: starts lowercase and is past tense.
+- `feat: Add cancellation, and fix billing rounding.`: bundles two changes, so split them.
+- `feat(orders): Add cancellation.`: uses a scope, which is prohibited.
+
+## Body
+
+The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body
+only when the reason cannot be inferred from the diff: a non-obvious trade-off, a workaround for
+an external bug, a decision worth recording.
+
+Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain
+**why**, not what. The diff already shows what.
+
+### Prose vs. bullets in the body
+
+Default to prose. One or two paragraphs fits almost every commit that has a body at all.
+
+Use bullets only when **all** of these are true:
+
+1. The commit covers 3 or more independent changes that genuinely belong in the same commit.
+2. The list cannot be expressed as continuous prose without becoming disconnected sentences.
+3. Each item is independently meaningful (no sub-bullets, no continuation across bullets).
+
+A two-item bullet list is the wrong shape. Use prose.
+
+When bullets are used, every bullet starts with a capital letter and ends with a period, with an
+imperative present-tense verb, same as the subject line.
+
+### Body example with prose (preferred)
+
+```
+fix: Handle zero-amount transactions.
+
+The payment gateway rejects zero-amount charges with a generic 400 instead
+of a documented error code, so the adapter short-circuits before the HTTP
+call and raises ZeroAmountNotAllowed directly.
+```
+
+### Body example with bullets
+
+```
+feat: Add order cancellation flow.
+
+- Add the OrderCancelling inbound port and OrderCancellingHandler.
+- Add the CancelOrder command and its validator.
+- Cover the cancellation path in the integration test suite.
+```
+
+## Commit splitting
+
+Prefer one logical change per commit. Refactor commits never modify behavior. When a task needs
+multiple types of change, produce multiple commits in order: `refactor` first, then `feat` or
+`fix` on top. When the staged diff mixes types, say so and propose the split rather than forcing
+one message over an incoherent change set.
diff --git a/.claude/skills/tiny-blocks-consume/SKILL.md b/.claude/skills/tiny-blocks-consume/SKILL.md
new file mode 100644
index 0000000..c318df8
--- /dev/null
+++ b/.claude/skills/tiny-blocks-consume/SKILL.md
@@ -0,0 +1,68 @@
+---
+name: tiny-blocks-consume
+description:
+ Discover and reuse an existing tiny-blocks library as a dependency instead of writing or keeping hand-written code. Use this skill in two moments: before implementing a capability from scratch or adding a dependency from outside the ecosystem, and when reviewing or refactoring existing code, to catch where a tiny-blocks package now covers something already written by hand. It checks the catalog of published tiny-blocks packages for a candidate, adds the match with composer, and reads the installed library's own README and public API to use it correctly. Trigger on any request to implement, add, build, review, simplify, or refactor where an existing building block (collections, value objects, money, time, http, mapping, logging, identifiers, and similar) might apply.
+---
+
+# tiny-blocks consume
+
+Reuse the ecosystem before building anew. Inside any library, when a capability is needed, the
+first move is to check whether a tiny-blocks package already provides it, adopt that package, and
+use its documented API. This is the consuming counterpart of `tiny-blocks-create`.
+
+The source of truth for how to use a package is the package itself. After adding a dependency, its
+README and public PHPDoc under `vendor/tiny-blocks//` are authoritative. This skill does not
+copy any package API. It only points to the catalog for discovery and to the installed package for
+usage.
+
+## When to use
+
+Use this before writing new code for a capability that is plausibly generic: collections, value
+objects, money or currency, time, country codes, http primitives, object mapping, logging,
+identifiers, encoding, environment variables, and similar. Also use it before reaching for any
+dependency from outside the ecosystem.
+
+Use it also when reviewing or refactoring existing code. A package may have been published after
+that code was written, so check whether hand-rolled logic can now be replaced by a tiny-blocks
+package. The catalog is what surfaces newly published packages, so refresh it (see below) before
+concluding that nothing applies.
+
+Do not use it for logic that is specific to the library being built and has no general building
+block. In that case, write the code following the rules.
+
+## Consume steps
+
+1. Name the capability in one phrase, whether it is something you are about to write or something
+ the existing code already does by hand (for example, "type-safe ordered collection" or "ISO
+ currency with fraction digits").
+2. Check `references/catalog.md` for a tiny-blocks candidate. If nothing matches and the need is
+ generic, refresh the catalog (see below) and look again, since a newer package may exist.
+3. If a candidate fits, add it with `composer require tiny-blocks/`. Packages from the
+ ecosystem are exempt from the freshness cooldown, and `composer require` prompts once before
+ adding.
+4. Learn the API from the installed package, not from memory. Read
+ `vendor/tiny-blocks//README.md` and the public classes, interfaces, and enums under
+ `vendor/tiny-blocks//src/`. Their PHPDoc and the README examples are the contract.
+5. Use the package following its documented API. Transitive dependencies are resolved by composer,
+ so depend on and use only the package that solves the capability directly.
+6. If no candidate fits, only then write the code from scratch, or consider a dependency from
+ outside the ecosystem, subject to the freshness cooldown and the `composer require` prompt.
+
+## Catalog
+
+`references/catalog.md` is the committed index of published tiny-blocks packages, with a one-line
+purpose for each. It exists for fast, offline discovery. It is generated from Packagist, not hand
+maintained. Each entry points to a package whose full API lives in its own README and PHPDoc once
+installed.
+
+## Refresh the catalog
+
+Run `python3 scripts/refresh-catalog.py` to rebuild `references/catalog.md` from the `tiny-blocks`
+vendor on Packagist. The script uses only the Python standard library, with no curl or jq, pulls
+the package list plus each description, skips abandoned packages, and rewrites the list. Refresh
+when a new package shipped, or when the catalog looks stale and a needed capability is not listed.
+
+## Validate
+
+After adding a dependency and wiring it in, run `make review` and `make tests`. Both must be green
+before the work is complete. A new dependency that breaks either gate is not done.
diff --git a/.claude/skills/tiny-blocks-consume/references/catalog.md b/.claude/skills/tiny-blocks-consume/references/catalog.md
new file mode 100644
index 0000000..778be52
--- /dev/null
+++ b/.claude/skills/tiny-blocks-consume/references/catalog.md
@@ -0,0 +1,32 @@
+# tiny-blocks catalog
+
+Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by
+scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README
+and public PHPDoc under vendor/tiny-blocks//.
+
+- `tiny-blocks/building-blocks`: Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain
+ events, snapshots, and upcasters.
+- `tiny-blocks/collection`: Models a type-safe, fluent collection API for PHP with eager and lazy pipelines over arrays,
+ iterators, and generators.
+- `tiny-blocks/country`: Provides an ISO 3166-1 country value object for PHP, with Alpha-2, Alpha-3, numeric, and IANA
+ timezone resolution.
+- `tiny-blocks/currency`: Models ISO-4217 currencies as a PHP enum, with per-currency fraction digit resolution.
+- `tiny-blocks/docker-container`: Manages Docker containers programmatically for PHP, aimed at integration tests and
+ disposable infrastructure.
+- `tiny-blocks/encoder`: Encoder and decoder for arbitrary data.
+- `tiny-blocks/environment-variable`: Provides a type-safe environment variable reader for PHP, with strict integer and
+ boolean conversion.
+- `tiny-blocks/http`: Implements PSR-7, PSR-15, PSR-17 and PSR-18 HTTP primitives for PHP, with a fluent response
+ builder, cookies, cache control, and a PSR-18 client facade.
+- `tiny-blocks/immutable-object`: Provides immutable behavior for objects.
+- `tiny-blocks/ksuid`: K-Sortable Unique Identifier.
+- `tiny-blocks/logger`: Emits PSR-3 structured logs for PHP, with correlation tracking and configurable sensitive data
+ redaction.
+- `tiny-blocks/mapper`: Maps PHP objects to and from arrays, JSON, and iterables through reflection and pluggable
+ strategies.
+- `tiny-blocks/math`: Value Objects for handling arbitrary precision numbers.
+- `tiny-blocks/outbox`: Write-side adapter for the Transactional Outbox pattern that persists domain events atomically
+ with aggregate state through Doctrine DBAL.
+- `tiny-blocks/time`: Models time as immutable value objects for PHP: instants, durations, periods, timezones, and
+ time-of-day, all UTC-normalized.
+- `tiny-blocks/value-object`: Defines the default behavior contract for PHP value objects with structural equality.
diff --git a/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py b/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py
new file mode 100644
index 0000000..b6fa359
--- /dev/null
+++ b/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Rebuild references/catalog.md from the tiny-blocks vendor on Packagist.
+
+Usage:
+ python3 scripts/refresh-catalog.py
+
+Depends only on the Python standard library. No curl, no jq, no shell.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+import textwrap
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import List, Optional
+
+VENDOR = "tiny-blocks"
+LIST_URL = f"https://packagist.org/packages/list.json?vendor={VENDOR}"
+CATALOG_PATH = Path(__file__).resolve().parent.parent / "references" / "catalog.md"
+LINE_WIDTH = 120
+REQUEST_TIMEOUT_SECONDS = 30
+
+CATALOG_HEADER = """\
+# tiny-blocks catalog
+
+Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by
+scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README
+and public PHPDoc under vendor/tiny-blocks//.
+
+"""
+
+
+def report(message: str) -> None:
+ print(message, file=sys.stderr)
+
+
+def fetch_json(url: str) -> dict:
+ request = urllib.request.Request(url=url, headers={"User-Agent": "tiny-blocks-catalog"})
+
+ with urllib.request.urlopen(url=request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
+ payload = json.load(fp=response)
+ return payload
+
+
+def sanitize(description: str) -> str:
+ collapsed = " ".join(description.split())
+
+ for character in (";", "—", "–"):
+ collapsed = collapsed.replace(character, ",")
+ return collapsed
+
+
+def catalog_line(name: str) -> Optional[str]:
+ try:
+ metadata = fetch_json(url=f"https://packagist.org/packages/{name}.json").get("package", {})
+ except (urllib.error.URLError, json.JSONDecodeError):
+ report(message=f"Skipping {name}, metadata fetch failed.")
+ return None
+
+ if metadata.get("abandoned"):
+ return None
+
+ description = sanitize(description=metadata.get("description") or "")
+
+ return textwrap.fill(
+ text=f"- `{name}`: {description}",
+ width=LINE_WIDTH,
+ subsequent_indent=" ",
+ break_long_words=False,
+ break_on_hyphens=False,
+ )
+
+
+def build_catalog() -> str:
+ names = sorted(fetch_json(url=LIST_URL).get("packageNames", []))
+ lines: List[str] = []
+
+ for name in names:
+ line = catalog_line(name=name)
+
+ if line is not None:
+ lines.append(line)
+ return CATALOG_HEADER + "\n".join(lines) + "\n"
+
+
+def main() -> int:
+ try:
+ catalog = build_catalog()
+ except (urllib.error.URLError, json.JSONDecodeError) as error:
+ report(message=f"Failed to build the catalog: {error}")
+ return 1
+
+ CATALOG_PATH.write_text(data=catalog, encoding="utf-8")
+ print(f"Wrote {CATALOG_PATH}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.claude/skills/tiny-blocks-create/SKILL.md b/.claude/skills/tiny-blocks-create/SKILL.md
new file mode 100644
index 0000000..efcc62b
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/SKILL.md
@@ -0,0 +1,158 @@
+---
+name: tiny-blocks-create
+description: Scaffold a new PHP library for the tiny-blocks ecosystem, or restore a single canonical config/repository file (composer.json, phpcs.xml, phpunit.xml, phpstan.neon.dist, infection.json.dist, .editorconfig, .gitattributes, .gitignore, Makefile, the CI workflow, SECURITY.md, the issue templates, the PR template) to its standard shape. Use this skill whenever the user asks to create, bootstrap, set up, or initialize a new tiny-blocks library, to add the standard config/tooling files to a repository, or to fix/regenerate any of those files to match the ecosystem standard, even if they only mention one file by name. This skill owns the canonical bodies of those files. Do not hand-write them from memory.
+---
+
+# tiny-blocks library scaffolding
+
+This skill is the single source of truth for the boilerplate every tiny-blocks PHP library
+shares: the config files, the CI workflow, and the repository templates. The canonical bodies
+live in `assets/` as drop-in files. Copy them and substitute the placeholders rather than
+regenerating them from memory. The assets already encode the ecosystem's decisions (PSR-12 only,
+`level: max`, MSI 100, Docker-wrapped Makefile, the `.dist` naming split, the export-ignore set).
+
+The semantic conventions (how to name classes, how to structure `src/`, how to write tests) are
+**not** in this skill. They live in `.claude/rules/`. This skill produces the skeleton. The rules
+govern the code you then write into it.
+
+## When to use which mode
+
+- **Full scaffold**: the user is starting a new library. Create the directory skeleton and copy
+ every asset, substituting placeholders.
+- **Single-file restore**: the user wants one file brought back to standard (for example, "fix
+ my Makefile" or "regenerate the CI workflow"). Copy only that asset. Do not touch the rest.
+
+## Asset map
+
+Copy each asset to the path on the right, relative to the repository root.
+
+| Asset (`assets/…`) | Destination | Placeholders |
+|--------------------------------------------|---------------------------------------------|--------------|
+| `config/composer.json` | `composer.json` | yes |
+| `config/phpcs.xml` | `phpcs.xml` | no |
+| `config/phpstan.neon.dist` | `phpstan.neon.dist` | no |
+| `config/phpunit.xml` | `phpunit.xml` | no |
+| `config/infection.json.dist` | `infection.json.dist` | no |
+| `config/.editorconfig` | `.editorconfig` | no |
+| `config/.gitattributes` | `.gitattributes` | no |
+| `config/.gitignore` | `.gitignore` | no |
+| `config/Makefile` | `Makefile` | no |
+| `github/workflows/ci.yml` | `.github/workflows/ci.yml` | no |
+| `github/ISSUE_TEMPLATE/bug_report.md` | `.github/ISSUE_TEMPLATE/bug_report.md` | no |
+| `github/ISSUE_TEMPLATE/feature_request.md` | `.github/ISSUE_TEMPLATE/feature_request.md` | no |
+| `github/PULL_REQUEST_TEMPLATE.md` | `.github/PULL_REQUEST_TEMPLATE.md` | no |
+| `docs/SECURITY.md` | `SECURITY.md` | yes |
+
+## Placeholders
+
+Two assets carry placeholders. Substitute every occurrence.
+
+| Placeholder | Meaning | Example |
+|---------------------------------------------------------|----------------------------------------------|---------------------------|
+| `` | Repository name, kebab-case | `event-sourcing` |
+| `` | PSR-4 namespace segment, PascalCase | `EventSourcing` |
+| `` | `composer.json` `description` (one sentence) | n/a |
+| ``, `` | `composer.json` `keywords` topic tokens | `psr-7`, `event-sourcing` |
+
+`` appears in `composer.json` (`name`, `homepage`, `support`) and in `SECURITY.md`
+(advisory URL). `` appears only in `composer.json` (`autoload` / `autoload-dev` PSR-4
+prefixes). The first `keywords` entry is always `tiny-blocks`. The topic tokens follow.
+
+## Full scaffold steps
+
+1. Confirm ``, ``, the one-sentence description, and the keyword topics with
+ the user if not already known.
+2. Create the directory skeleton:
+ ```
+ src/
+ tests/
+ .github/workflows/
+ .github/ISSUE_TEMPLATE/
+ ```
+3. Copy every asset to its destination (table above), substituting placeholders.
+4. Author the files this skill does **not** carry, following the rules:
+ - `README.md`: follow `php-library-documentation.md` (title, license badge, TOC, the fixed
+ section order, code-example rules).
+ - `LICENSE`: MIT, attributed to the author in `composer.json`.
+ - Initial `src/` and `tests/`: follow `php-library-architecture.md`,
+ `php-library-code-style.md`, `php-library-modeling.md`, and `php-library-testing.md`.
+5. Validate (see below) before reporting the scaffold complete.
+
+## The .dist naming split
+
+The assets deliberately commit `phpcs.xml` and `phpunit.xml` as live files, but
+`phpstan.neon.dist` and `infection.json.dist` with the `.dist` suffix. This is intentional and
+documented in `php-library-tooling.md`: the ruleset and the test config are stable and committed
+live. PHPStan and Infection are the two tools a contributor may tune locally, so a gitignored
+`phpstan.neon` / `infection.json` overrides the committed `.dist` baseline. Do not add a `.dist`
+twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live `phpstan.neon`/`infection.json`.
+
+## Extending the CI tests job
+
+`ci.yml` is the minimal canonical workflow. Only the `tests` job may be extended, and only when
+the library's tests need external services, environment variables, or fixture preparation. Add
+them inside the `tests` job. Leave `resolve-php-version`, `build`, and `auto-review` untouched.
+Example with a MySQL service container:
+
+```yaml
+tests:
+ name: Tests
+ needs: [resolve-php-version, auto-review]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ env:
+ DB_HOST: 127.0.0.1
+ DB_NAME: library_test
+ DB_PORT: '3306'
+ DB_USER: library
+ DB_PASSWORD: library
+ services:
+ mysql:
+ image: mysql:8
+ ports:
+ - 3306:3306
+ env:
+ MYSQL_DATABASE: library_test
+ MYSQL_ROOT_PASSWORD: library
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v8
+ with:
+ name: vendor-artifact
+ path: .
+
+ - name: Run tests
+ run: composer tests
+```
+
+## Pinned action versions
+
+The action versions pinned in `ci.yml` (`actions/checkout@v6`, `shivammathur/setup-php@v2`,
+`actions/upload-artifact@v7`, `actions/download-artifact@v8`) may be outdated. Before adopting the
+workflow, verify the current major version of each action and update the pin while preserving the
+`@vN` prefix style, as required by `php-library-github-workflows.md` rule 8.
+
+## Validate
+
+After scaffolding (or restoring `composer.json`/the test config), run the toolchain through the
+Makefile and confirm both pass before reporting done:
+
+- `make review`: phpcs (PSR-12) and phpstan (`level: max`) must pass clean.
+- `make tests`: phpunit and infection must pass with MSI 100 / covered MSI 100.
+
+If `make` targets are missing, `make help` lists them. Do not claim the scaffold is complete on
+the strength of file creation alone. The definition of done is a clean `review` and `tests`.
diff --git a/.claude/skills/tiny-blocks-create/assets/config/.editorconfig b/.claude/skills/tiny-blocks-create/assets/config/.editorconfig
new file mode 100644
index 0000000..be5640e
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+max_line_length = 120
+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/.claude/skills/tiny-blocks-create/assets/config/.gitattributes b/.claude/skills/tiny-blocks-create/assets/config/.gitattributes
new file mode 100644
index 0000000..2bd9baa
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/.gitattributes
@@ -0,0 +1,19 @@
+* text=auto eol=lf
+
+*.php text diff=php
+
+# Dev-only, excluded from the Packagist tarball
+/.github export-ignore
+/tests export-ignore
+/.claude export-ignore
+/CLAUDE.md export-ignore
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpcs.xml export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon.dist export-ignore
+/infection.json.dist export-ignore
+/Makefile export-ignore
+/reports export-ignore
+/.phpunit.cache export-ignore
diff --git a/.claude/skills/tiny-blocks-create/assets/config/.gitignore b/.claude/skills/tiny-blocks-create/assets/config/.gitignore
new file mode 100644
index 0000000..c8f4364
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/.gitignore
@@ -0,0 +1,28 @@
+# PHP dependencies
+/vendor/
+composer.lock
+
+# Local config overrides (committed baselines are the .dist files)
+/phpstan.neon
+/infection.json
+
+# Tooling cache
+.phpunit.cache/
+.phpunit.result.cache
+
+# Coverage and reports
+build/
+reports/
+coverage/
+infection.log
+
+# Editors and agents
+.idea/
+.cursor/
+.vscode/
+/.claude/settings.local.json
+
+# OS
+Thumbs.db
+.DS_Store
+Desktop.ini
diff --git a/.claude/skills/tiny-blocks-create/assets/config/Makefile b/.claude/skills/tiny-blocks-create/assets/config/Makefile
new file mode 100644
index 0000000..90ab50d
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/Makefile
@@ -0,0 +1,74 @@
+PWD := $(CURDIR)
+ARCH := $(shell uname -m)
+PLATFORM :=
+
+ifeq ($(ARCH),arm64)
+ PLATFORM := --platform=linux/amd64
+endif
+
+TTY := $(shell [ -t 0 ] && echo -it)
+
+DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --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 configure
+
+.PHONY: configure-and-update
+configure-and-update: ## Configure development environment and update dependencies
+ @${DOCKER_RUN} composer configure-and-update
+
+.PHONY: tests
+tests: ## Run unit and mutation 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: review
+review: ## Run lint and static analysis
+ @${DOCKER_RUN} composer review
+
+.PHONY: show-reports
+show-reports: ## Open coverage and mutation reports in the browser
+ @sensible-browser reports/coverage/coverage-html/index.html reports/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 reports 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|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
+ @grep -E '^(tests|test-file):.*?## .*$$' $(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/.claude/skills/tiny-blocks-create/assets/config/composer.json b/.claude/skills/tiny-blocks-create/assets/config/composer.json
new file mode 100644
index 0000000..e10a520
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/composer.json
@@ -0,0 +1,70 @@
+{
+ "name": "tiny-blocks/",
+ "description": "",
+ "license": "MIT",
+ "type": "library",
+ "keywords": [
+ "tiny-blocks",
+ "",
+ ""
+ ],
+ "authors": [
+ {
+ "name": "Gustavo Freze de Araujo Santos",
+ "homepage": "https://github.com/gustavofreze"
+ }
+ ],
+ "homepage": "https://github.com/tiny-blocks/",
+ "support": {
+ "issues": "https://github.com/tiny-blocks//issues",
+ "source": "https://github.com/tiny-blocks/"
+ },
+ "require": {
+ "php": "^8.5"
+ },
+ "require-dev": {
+ "ergebnis/composer-normalize": "^2.52",
+ "infection/infection": "^0.33",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^13.1",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "autoload": {
+ "psr-4": {
+ "TinyBlocks\\\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\TinyBlocks\\\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "ergebnis/composer-normalize": true,
+ "infection/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "configure": [
+ "@composer install --optimize-autoloader",
+ "@composer normalize"
+ ],
+ "configure-and-update": [
+ "@composer update --optimize-autoloader",
+ "@composer normalize"
+ ],
+ "review": [
+ "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests",
+ "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress"
+ ],
+ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
+ "tests": [
+ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage"
+ ]
+ }
+}
diff --git a/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist b/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist
new file mode 100644
index 0000000..aab8c7e
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist
@@ -0,0 +1,23 @@
+{
+ "logs": {
+ "text": "reports/infection/logs/infection-text.log",
+ "summary": "reports/infection/logs/infection-summary.log"
+ },
+ "tmpDir": "reports/infection/",
+ "minMsi": 100,
+ "timeout": 30,
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "phpUnit": {
+ "configDir": "",
+ "customPath": "./vendor/bin/phpunit"
+ },
+ "mutators": {
+ "@default": true
+ },
+ "minCoveredMsi": 100,
+ "testFramework": "phpunit"
+}
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml b/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml
new file mode 100644
index 0000000..a52372c
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml
@@ -0,0 +1,7 @@
+
+
+ Code style for the tiny-blocks library.
+
+ src
+ tests
+
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist b/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist
new file mode 100644
index 0000000..0df69df
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist
@@ -0,0 +1,6 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ reportUnmatchedIgnoredErrors: true
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml b/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml
new file mode 100644
index 0000000..9cc6d13
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ src
+
+
+
+
+
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md b/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md
new file mode 100644
index 0000000..a892afe
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md
@@ -0,0 +1,12 @@
+# Security Policy
+
+## Supported versions
+
+Only the latest release receives security updates.
+
+## Reporting a vulnerability
+
+Report security vulnerabilities privately via
+[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new).
+
+Please do not disclose the vulnerability publicly until it has been addressed.
diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..8ddd1db
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,29 @@
+---
+name: Bug report
+about: Report a bug to help improve the library
+labels: bug
+---
+
+## Description
+
+A clear and concise description of the bug.
+
+## Steps to reproduce
+
+1.
+2.
+3.
+
+## Expected behavior
+
+What should happen.
+
+## Actual behavior
+
+What actually happens.
+
+## Environment
+
+- PHP version:
+- Library version:
+- OS:
diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..b344d9e
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Suggest a feature for the library
+labels: enhancement
+---
+
+## Problem
+
+What problem does this feature solve?
+
+## Proposed solution
+
+How should the feature work?
+
+## Alternatives considered
+
+Other approaches considered.
diff --git a/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md b/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..7a2c836
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,16 @@
+> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md).
+
+## Summary
+
+What this pull request does.
+
+## Related issue
+
+Closes #...
+
+## Checklist
+
+- [ ] Tests added or updated.
+- [ ] Documentation updated when applicable.
+- [ ] `composer review` passes.
+- [ ] `composer tests` passes.
diff --git a/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml b/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml
new file mode 100644
index 0000000..728bb3b
--- /dev/null
+++ b/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml
@@ -0,0 +1,105 @@
+name: CI
+
+on:
+ pull_request:
+
+concurrency:
+ group: ci-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ resolve-php-version:
+ name: Resolve PHP version
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ php-version: ${{ steps.config.outputs.php-version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Resolve PHP version from composer.json
+ id: config
+ run: |
+ version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1)
+ echo "php-version=$version" >> "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: resolve-php-version
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - 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
+ needs: [resolve-php-version, build]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - 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
+ needs: [resolve-php-version, auto-review]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ tools: composer:2
+ php-version: ${{ needs.resolve-php-version.outputs.php-version }}
+
+ - 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/.gitattributes b/.gitattributes
index eedb473..2bd9baa 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,19 +6,14 @@
/.github export-ignore
/tests export-ignore
/.claude export-ignore
+/CLAUDE.md export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
+/phpcs.xml export-ignore
/phpunit.xml export-ignore
-/phpunit.xml.dist export-ignore
-/phpstan.neon export-ignore
/phpstan.neon.dist export-ignore
-/phpcs.xml export-ignore
-/phpcs.xml.dist export-ignore
-/infection.json export-ignore
/infection.json.dist export-ignore
/Makefile export-ignore
-/CONTRIBUTING.md export-ignore
-/CHANGES.md export-ignore
/reports export-ignore
/.phpunit.cache export-ignore
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index e34c801..de1576d 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -6,7 +6,7 @@ PHP library in the tiny-blocks ecosystem.
## Mandatory pre-task step
-Before starting any task, read and strictly follow `.claude/CLAUDE.md` and every rule file in
+Before starting any task, read and strictly follow `CLAUDE.md` and every rule file in
`.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/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
index 8c9683c..e87e331 100644
--- a/.github/workflows/auto-assign.yml
+++ b/.github/workflows/auto-assign.yml
@@ -1,4 +1,4 @@
-name: Auto assign
+name: Auto assign issues and pull requests
on:
issues:
@@ -18,7 +18,7 @@ permissions:
jobs:
auto-assign:
- name: Auto assign issues and pull requests
+ name: Auto assign
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d395d35..728bb3b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,7 @@ on:
pull_request:
concurrency:
- group: pr-${{ github.event.pull_request.number }}
+ group: ci-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
diff --git a/.gitignore b/.gitignore
index 6107765..c8f4364 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,10 +2,12 @@
/vendor/
composer.lock
+# Local config overrides (committed baselines are the .dist files)
+/phpstan.neon
+/infection.json
+
# Tooling cache
-.phpcs-cache
.phpunit.cache/
-.php-cs-fixer.cache
.phpunit.result.cache
# Coverage and reports
@@ -18,6 +20,7 @@ infection.log
.idea/
.cursor/
.vscode/
+/.claude/settings.local.json
# OS
Thumbs.db
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..d7efbcf
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,61 @@
+# tiny-blocks PHP library
+
+A library in the [tiny-blocks](https://github.com/tiny-blocks) ecosystem: small, focused,
+framework-agnostic PHP building blocks published to Packagist. Target runtime is **PHP 8.5**.
+
+This file is the index. The detailed conventions live in `.claude/rules/` (loaded automatically
+when you touch matching files) and in three skills under `.claude/skills/`. Keep this file short:
+when a convention needs explaining, it belongs in a rule or a skill, not here.
+
+## Validate
+
+Every PHP and Composer command runs inside Docker via the `Makefile` (image
+`gustavofreze/php:8.5-alpine`). Never run PHP on the host directly.
+
+- `make review`: phpcs (PSR-12) + phpstan (`level: max`). Run before claiming code is clean.
+- `make tests`: phpunit + infection. Mutation thresholds are `minMsi: 100` / `minCoveredMsi: 100`.
+- `make test-file FILE=`: one filtered test file, no coverage.
+- `make help`: discover all targets if any of the above is missing or has changed.
+
+Treat `make review` and `make tests` as the definition of done. Both gate every pull request in
+CI. Passing them locally is the bar before any "complete" / "fixed" / "passing" claim.
+
+## Conventions (`.claude/rules/`)
+
+Path-scoped. Each loads only when you edit matching files.
+
+- `php-library-architecture.md`: folder layout, public API boundary, `Internal/` semantics (`src/`).
+- `php-library-code-style.md`: semantic code rules, naming, PHPDoc, `self`/`static` (`src/`, `tests/`).
+- `php-library-modeling.md`: value objects, exceptions, enums, complexity (`src/`).
+- `php-library-testing.md`: BDD Given/When/Then, PHPUnit, fixtures, coverage discipline (`tests/`).
+- `php-library-tooling.md`: invariants for `composer.json`, `phpcs.xml`, `phpunit.xml`, etc.
+- `php-library-documentation.md`: README and `docs/` conventions.
+- `php-library-github-workflows.md`: GitHub Actions conventions.
+
+## Skills (`.claude/skills/`)
+
+- `tiny-blocks-create`: scaffold a new library or restore a canonical config/repo file. Holds
+ the drop-in bodies of every config file, the CI workflow, and the issue/PR/security templates.
+- `tiny-blocks-consume`: discover and reuse a published tiny-blocks package as a dependency
+ instead of writing the capability by hand. Checks the catalog, adds the match with Composer,
+ and uses the installed package's own README and public API. The consuming counterpart of
+ `tiny-blocks-create`.
+- `commit-message`: generate a Conventional Commits message in the ecosystem's format. Invoke
+ when writing a commit. Commit messages are never generated automatically.
+
+## Global defaults
+
+- All identifiers, comments, documentation, and commit messages use American English.
+- In prose and headings, do not use semicolons or em-dashes. This applies to PHPDoc descriptions
+ and to every Markdown file (README, docs). Use a period or a comma in place of a semicolon, and
+ a colon, a comma, or parentheses in place of an em-dash. Hyphens in compound words and
+ identifiers (`tiny-blocks`, `name-length`) are not affected, and semicolons that terminate PHP
+ statements in code are not affected.
+- Prefer dependencies from the tiny-blocks ecosystem before reaching outside it.
+- Do not install or update any dependency to a version published less than 7 days ago. Freshly
+ released versions can be yanked or compromised. Let them age past the cooldown first. Packages
+ from the tiny-blocks ecosystem (`tiny-blocks/*`) are exempt, they are first-party. When a
+ dependency bump is needed but the target version is too recent, report it and wait rather than
+ pinning the new version.
+- Do not run any history-altering Git operation (branch, commit, push, merge, rebase, tag) unless
+ explicitly asked.
diff --git a/README.md b/README.md
index 827641e..45f9e6c 100644
--- a/README.md
+++ b/README.md
@@ -51,9 +51,6 @@ snapshots for event-sourced aggregates (Greg Young), event upcasting for schema
event envelope decoupling domain events from infrastructure metadata (Hohpe/Woolf EIP). Every extension is annotated
in its own PHPDoc with its source.
-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. Serialization to wire formats is delegated to adapters such as
[`tiny-blocks/outbox`](https://github.com/tiny-blocks/outbox).
@@ -260,9 +257,11 @@ concurrency control, and a `ModelVersion` for schema evolution of the aggregate
`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.
-Aggregates of this type are **use-once**: after the application service drains `recordedEvents()` into the outbox,
-the aggregate instance must be discarded. The recorded-events buffer is never cleared, re-saving the same instance
-fails by design with a duplicate-event error from the outbox.
+After persisting the aggregate state, the application service drains the recorded events with `pullEvents()`, which
+returns them and clears the buffer, so a second save of the same instance does not re-emit the events already
+drained. `peekEvents()` returns a non-destructive copy for inspection without touching the buffer. An instance models a
+single unit of work: reload from the repository before operating on the same logical aggregate again rather than
+reusing a drained instance.
#### Declaring events
@@ -332,7 +331,7 @@ fails by design with a duplicate-event error from the outbox.
#### Emitting events from the aggregate
-* `push()`: protected method on `EventualAggregateRootBehavior`. Increments the aggregate version and appends a
+* `pushEvent()`: protected method on `EventualAggregateRootBehavior`. Increments the aggregate version and appends a
fully-built `EventRecord` to the recorded buffer.
```php
@@ -354,7 +353,7 @@ fails by design with a duplicate-event error from the outbox.
public static function place(OrderId $id, string $item): Order
{
$order = new Order(id: $id);
- $order->push(event: new OrderPlaced(item: $item));
+ $order->pushEvent(event: new OrderPlaced(item: $item));
return $order;
}
@@ -363,8 +362,9 @@ fails by design with a duplicate-event error from the outbox.
#### Draining events
-* `recordedEvents()`: returns a copy of the buffer, safe to iterate. The aggregate's own buffer is not mutated by
- external iteration. The buffer is **never cleared** by the library, the aggregate is use-once.
+* `pullEvents()`: drains the buffer. Returns the events recorded since the last drain and clears the buffer, so a
+ subsequent call returns an empty collection until new events are recorded. This is the persistence path: drain
+ into the outbox after the aggregate state has been saved.
```php
recordedEvents() as $record) {
+ foreach ($order->pullEvents() as $record) {
$outbox->append(record: $record);
}
```
+* `peekEvents()`: returns a fresh copy of the buffer without clearing it, safe to iterate. The aggregate's own buffer is
+ not mutated by external iteration, and a later `pullEvents()` still drains every recorded event. Use it to inspect the
+ buffer, for example in tests, without consuming it.
+
+ ```php
+ $order->peekEvents();
+ ```
+
#### Restoring aggregate version on reload
-* `reconstitute()`: static factory that state-based repositories invoke when rehydrating an
- `EventualAggregateRoot` from persistence. The default implementation provided by
- `EventualAggregateRootBehavior` instantiates the aggregate without invoking its constructor, assigns the
- identity to the property declared by `identityProperty()`, hydrates the remaining state by reflection
- from the `$state` map (entries with keys absent from the aggregate are silently ignored), and assigns
- the aggregate version so subsequent events advance from the correct value. The buffer of recorded events
- starts empty, the use-once contract still holds for any new operation.
+* `reconstituteStrict()`: the recommended static factory for repositories that rehydrate an
+ `EventualAggregateRoot` from a full persisted row. It delegates to `reconstitutePartial()` (honoring any
+ override), then verifies by reflection that hydration left no declared property uninitialized, throwing
+ `IncompleteAggregateState` when a required property is still unset. Properties that carry a default value,
+ and untyped properties, are always initialized by PHP, so they are never flagged.
+
+* `reconstitutePartial()`: the hydration step on its own, without the completeness check. The default
+ implementation provided by `EventualAggregateRootBehavior` instantiates the aggregate without invoking its
+ constructor, assigns the identity to the property declared by `identityProperty()`, hydrates the remaining
+ state by reflection from the `$aggregateState` map (entries with keys absent from the aggregate are silently
+ ignored), and assigns the aggregate version so subsequent events advance from the correct value. It throws
+ `MissingIdentityProperty` when the aggregate has no property named by `identityProperty()`. The buffer of
+ recorded events starts empty, so events emitted after reconstitution are drained with `pullEvents()` exactly as for a
+ freshly created aggregate.
```php
'pending']
+ aggregateState: ['status' => 'pending'],
+ aggregateVersion: AggregateVersion::of(value: 7)
);
```
- Aggregates may override the factory to enforce a concrete identity type at the entry point. The static
- signature cannot narrow the parameter type per LSP, so the override keeps `Identity` in the signature
- and guards with `instanceof` inside:
+ Call `reconstitutePartial(...)` with the same arguments when the persisted state is intentionally partial and
+ the completeness check should be skipped.
+
+ Aggregates may override `reconstitutePartial()` to enforce a concrete identity type at the entry point.
+ `reconstituteStrict()` delegates to it, so the override is honored on both paths. The static signature cannot
+ narrow the parameter type per LSP, so the override keeps `Identity` in the signature and guards with
+ `instanceof` inside:
```php
, got <%s>.';
@@ -452,19 +472,21 @@ Every envelope carries `$id`, `$event`, `$revision`, `$eventType`, `$occurredAt`
`$aggregateType`, and `$aggregateVersion`. The aggregate normally builds the record, so consumers
read these fields off `EventRecord` directly without instantiating one.
-* `EventRecord::of()`: factory for the rare cases that require building an envelope outside the aggregate boundary,
+* `EventRecord::from()`: factory for the rare cases that require building an envelope outside the aggregate boundary,
typically test code that fabricates envelopes as inputs to handlers, or consumer-side code deserializing payloads
- from a wire format. The `id` and `occurredAt` parameters fall back to sensible defaults (`Uuid::uuid4()` and
- `Instant::now()`) when omitted.
-
- | Parameter | Type | Required | Description |
- |--------------------|--------------------|----------|--------------------------------------------------------------------|
- | `id` | `?UuidInterface` | No | Explicit envelope identifier; defaults to a fresh `Uuid::uuid4()`. |
- | `event` | `DomainEvent` | Yes | The event being recorded. |
- | `occurredAt` | `?Instant` | No | Explicit occurrence timestamp; defaults to `Instant::now()`. |
- | `aggregateId` | `Identity` | Yes | The aggregate identity that produced the event. |
- | `aggregateType` | `string` | Yes | The short class name of the aggregate. |
- | `aggregateVersion` | `AggregateVersion` | Yes | The aggregate version assigned to this envelope. |
+ from a wire format. The constructor is private, so `from()` is the only construction path. The `id` and
+ `occurredAt` parameters fall back to sensible defaults (`Uuid::generateV7()` and `Utc::now()`) when omitted. The id
+ and
+ occurrence timestamp are the value objects `TinyBlocks\BuildingBlocks\Uuid` and `TinyBlocks\BuildingBlocks\Utc`.
+
+ | Parameter | Type | Required | Description |
+ |--------------------|--------------------|----------|-------------------------------------------------------------------------|
+ | `id` | `?Uuid` | No | Explicit envelope identifier. Defaults to a fresh `Uuid::generateV7()`. |
+ | `event` | `DomainEvent` | Yes | The event being recorded. |
+ | `occurredAt` | `?Utc` | No | Explicit occurrence timestamp. Defaults to `Utc::now()`. |
+ | `aggregateId` | `Identity` | Yes | The aggregate identity that produced the event. |
+ | `aggregateType` | `string` | Yes | The short class name of the aggregate. |
+ | `aggregateVersion` | `AggregateVersion` | Yes | The aggregate version assigned to this envelope. |
```php
'prod-1']
@@ -947,7 +969,7 @@ minimal prevents infrastructure concerns from leaking into the domain model.
Only the aggregate has the context needed to build the complete envelope: identity, aggregate version, aggregate
type name. Storing raw events and wrapping them later would either duplicate that context or require a second
-pass. `push()` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no
+pass. `pushEvent()` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no
translation.
> Gregor Hohpe and Bobby Woolf, *Enterprise Integration Patterns* (Addison-Wesley, 2003), "Envelope Wrapper".
@@ -963,7 +985,7 @@ and emits events as side effects, or persists only its events as the source of t
### 04. Why does `Revision` live on the `DomainEvent` instead of the call site?
-The revision of an event is a property of the event's schema. Keeping it on the event means the call site (`push`,
+The revision of an event is a property of the event's schema. Keeping it on the event means the call site (`pushEvent`,
`when`) does not need to know the schema version, the event class is the single source of truth. Bumping a
revision is always paired with a payload change (added field, removed field, renamed field), so creating a new
event class to carry the new revision is the natural unit of work.
@@ -1004,15 +1026,16 @@ objects to prevent accidental comparisons across them at compile time.
> Lock", source of `AggregateVersion` semantics.
> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017), source of `ModelVersion` semantics.
-### 08. Why is the `EventualAggregateRoot` use-once?
+### 08. How are recorded events drained from an `EventualAggregateRoot`?
-The recorded-events buffer is never cleared by the library. After the application service drains
-`recordedEvents()` into the outbox, the aggregate instance must be discarded. Re-saving the same instance pushes
-the same envelopes again and deterministically fails with a duplicate-event error from the outbox.
+After the aggregate state has been persisted, the application service calls `pullEvents()`, which returns the events
+recorded since the last drain and clears the buffer. Draining through `pullEvents()` publishes each event once: a
+second save of the same instance finds an empty buffer and re-emits nothing. `peekEvents()` is the non-destructive
+counterpart, returning a fresh copy for inspection (in tests, for example) while leaving the buffer intact.
-This is intentional. It surfaces re-save bugs at the database layer instead of hiding them via implicit state
-mutation. Applications that genuinely need to mutate the same logical aggregate twice in one process must reload
-from the repository between operations.
+An instance models a single transactional unit of work. Reload from the repository before operating on the same
+logical aggregate again rather than reusing a drained instance, so its aggregate version and state reflect what
+storage holds.
> Eric Evans, *Domain-Driven Design* (Addison-Wesley, 2003), Chapter 6, "Aggregates" (single transactional unit
> per aggregate per request).
@@ -1043,16 +1066,19 @@ code has a single source of truth to branch on when older shapes show up in stor
> Lock".
> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017).
-### 11. Why is `reconstitute()` static on the interface even though PHP's polymorphism for static methods is limited?
+### 11. Why are `reconstitutePartial()` and
+
+`reconstituteStrict()` static on the interface even though PHP's polymorphism for static methods is limited?
-The interface declaration documents the contract: every `EventualAggregateRoot` exposes a static factory with the
-shape `(Identity, AggregateVersion, array): static` that repositories can call. PHP does not dispatch static calls
-through interfaces at runtime, so the consumer always names the concrete class (`Order::reconstitute(...)`,
-`Reservation::reconstitute(...)`). The interface still earns its keep: it forces aggregates to expose the factory,
-the trait default provides one for free, and overrides remain bound to the declared signature. The parameter name
-is free per LSP, so an override can rename `$identity` to `$orderId` for readability, but the type must remain
-`Identity` — narrowing to a concrete identity class would break LSP. Concrete types are enforced inside the
-override with `instanceof`.
+The interface declaration documents the contract: every `EventualAggregateRoot` exposes two static factories with
+the shape `(Identity, array, AggregateVersion): static` that repositories can call. PHP does not dispatch static
+calls through interfaces at runtime, so the consumer always names the concrete class
+(`Order::reconstituteStrict(...)`, `Reservation::reconstitutePartial(...)`). The interface still earns its keep: it
+forces aggregates to expose the factories, the trait default provides both for free (`reconstituteStrict` delegates
+to `reconstitutePartial`), and overrides remain bound to the declared signature. The parameter name is free per
+LSP, so an override of `reconstitutePartial` can rename `$identity` to `$orderId` for readability, but the type
+must remain `Identity`, narrowing to a concrete identity class would break LSP. Concrete types are enforced inside
+the override with `instanceof`.
> Barbara Liskov and Jeannette Wing, *A Behavioral Notion of Subtyping* (ACM TOPLAS, 1994).
diff --git a/composer.json b/composer.json
index 4667730..d773afc 100644
--- a/composer.json
+++ b/composer.json
@@ -30,17 +30,18 @@
"require": {
"php": "^8.5",
"ramsey/uuid": "^4.9",
- "tiny-blocks/collection": "^2.3",
- "tiny-blocks/mapper": "^2.1",
- "tiny-blocks/time": "^2.0",
+ "tiny-blocks/collection": "^2.4",
+ "tiny-blocks/mapper": "^3.0",
+ "tiny-blocks/time": "^2.2",
"tiny-blocks/value-object": "^5.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
"infection/infection": "^0.33",
- "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.1",
- "squizlabs/php_codesniffer": "^4.0"
+ "squizlabs/php_codesniffer": "^4.0",
+ "symfony/event-dispatcher-contracts": "^3.7"
},
"minimum-stability": "stable",
"prefer-stable": true,
diff --git a/infection.json.dist b/infection.json.dist
index 3307d81..aab8c7e 100644
--- a/infection.json.dist
+++ b/infection.json.dist
@@ -16,8 +16,7 @@
"customPath": "./vendor/bin/phpunit"
},
"mutators": {
- "@default": true,
- "ProtectedVisibility": false
+ "@default": true
},
"minCoveredMsi": 100,
"testFramework": "phpunit"
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index a583202..9c748f1 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -25,6 +25,21 @@ parameters:
identifier: argument.type
path: src/Upcast/SingleUpcasterBehavior.php
+ # Constructor parameter $propertyNames cannot carry PHPDoc per code-style rule;
+ # the implode() argument-type error is the downstream symptom of that constraint.
+ -
+ identifier: missingType.iterableValue
+ path: src/Exceptions/IncompleteAggregateState.php
+ -
+ identifier: argument.type
+ path: src/Exceptions/IncompleteAggregateState.php
+
+ # ReflectionClass requires class-string|object; ClassName::shortName accepts the wider
+ # object|string so call sites can pass static::class. The narrowing is safe at every call.
+ -
+ identifier: argument.type
+ path: src/Internal/ClassName.php
+
# tests/ — PHPDoc and @var are prohibited inside tests/, so PHPStan errors
# for typed arrays in fixtures and helpers route through ignoreErrors.
-
@@ -39,6 +54,3 @@ parameters:
-
identifier: property.nonObject
path: tests/Unit/*
- -
- identifier: method.nonObject
- path: tests/Unit/*
diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php
index bd47dc5..66743f8 100644
--- a/src/Aggregate/AggregateRootBehavior.php
+++ b/src/Aggregate/AggregateRootBehavior.php
@@ -4,14 +4,13 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
-use Ramsey\Uuid\Uuid;
-use ReflectionClass;
use TinyBlocks\BuildingBlocks\Entity\EntityBehavior;
+use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
-use TinyBlocks\BuildingBlocks\Event\EventType;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Internal\AggregateReflection;
+use TinyBlocks\BuildingBlocks\Internal\ClassName;
trait AggregateRootBehavior
{
@@ -21,6 +20,15 @@ trait AggregateRootBehavior
private AggregateVersion $aggregateVersion;
+ protected static function createBlank(Identity $identity): static
+ {
+ $aggregate = AggregateReflection::instantiate(class: static::class);
+ AggregateReflection::assignProperty(target: $aggregate, property: $aggregate->identityName(), value: $identity);
+ $aggregate->recordedEvents = EventRecords::createFromEmpty();
+
+ return $aggregate;
+ }
+
public function modelVersion(): ModelVersion
{
return ModelVersion::initial();
@@ -28,7 +36,7 @@ public function modelVersion(): ModelVersion
public function aggregateType(): string
{
- return new ReflectionClass(objectOrClass: static::class)->getShortName();
+ return ClassName::shortName(target: static::class);
}
public function aggregateVersion(): AggregateVersion
@@ -36,26 +44,35 @@ public function aggregateVersion(): AggregateVersion
return $this->aggregateVersion ?? AggregateVersion::initial();
}
- public function recordedEvents(): EventRecords
+ public function peekEvents(): EventRecords
{
$records = $this->recordedEvents ?? EventRecords::createFromEmpty();
return EventRecords::createFrom(elements: $records);
}
+ public function pullEvents(): EventRecords
+ {
+ $records = $this->recordedEvents ?? EventRecords::createFromEmpty();
+ $this->recordedEvents = EventRecords::createFromEmpty();
+
+ return $records;
+ }
+
private function nextAggregateVersion(): void
{
$this->aggregateVersion = $this->aggregateVersion()->next();
}
+ private function appendRecordedEvent(EventRecord $record): void
+ {
+ $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())->add(elements: $record);
+ }
+
private function buildEventRecord(DomainEvent $event): EventRecord
{
- return new EventRecord(
- id: Uuid::uuid4(),
+ return EventRecord::from(
event: $event,
- revision: $event->revision(),
- eventType: EventType::fromDomainEvent(event: $event),
- occurredAt: Instant::now(),
aggregateId: $this->identity(),
aggregateType: $this->aggregateType(),
aggregateVersion: $this->aggregateVersion()
diff --git a/src/Aggregate/AggregateVersion.php b/src/Aggregate/AggregateVersion.php
index bb1101d..0ec1160 100644
--- a/src/Aggregate/AggregateVersion.php
+++ b/src/Aggregate/AggregateVersion.php
@@ -5,12 +5,15 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
use TinyBlocks\BuildingBlocks\Exceptions\InvalidAggregateVersion;
+use TinyBlocks\BuildingBlocks\Ordinal;
+use TinyBlocks\BuildingBlocks\OrdinalBehavior;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
-final readonly class AggregateVersion implements ValueObject
+final readonly class AggregateVersion implements ValueObject, Ordinal
{
use ValueObjectBehavior;
+ use OrdinalBehavior;
private function __construct(public int $value)
{
@@ -61,25 +64,8 @@ public function next(): AggregateVersion
return new AggregateVersion(value: $this->value + 1);
}
- /**
- * Tells whether this aggregate version is strictly after the given one.
- *
- * @param AggregateVersion $other The aggregate version to compare against.
- * @return bool True when this value is greater than the other's.
- */
- public function isAfter(AggregateVersion $other): bool
- {
- return $this->value > $other->value;
- }
-
- /**
- * Tells whether this aggregate version is strictly before the given one.
- *
- * @param AggregateVersion $other The aggregate version to compare against.
- * @return bool True when this value is less than the other's.
- */
- public function isBefore(AggregateVersion $other): bool
+ public function value(): int
{
- return $this->value < $other->value;
+ return $this->value;
}
}
diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php
index 4c8b43b..f05af14 100644
--- a/src/Aggregate/EventSourcingRoot.php
+++ b/src/Aggregate/EventSourcingRoot.php
@@ -46,13 +46,13 @@ public static function blank(Identity $identity): static;
* aggregate version 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 aggregate version.
+ * @param Identity $identity The identity of the aggregate.
* @param Snapshot|null $snapshot Optional snapshot to restore from before replay.
* @return static The reconstituted aggregate.
* @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist.
*/
- public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static;
+ public static function reconstitute(iterable $records, Identity $identity, ?Snapshot $snapshot = null): static;
/**
* Returns the aggregate state to persist in a snapshot.
@@ -90,9 +90,23 @@ public function applySnapshot(Snapshot $snapshot): void;
public function eventHandlers(): array;
/**
- * Returns the events recorded during the current unit of work.
+ * Returns a fresh copy of the recorded events without clearing the buffer.
+ *
+ *
A non-destructive read. The returned collection is a copy, so external mutation does not leak
+ * into the aggregate's internal buffer, and the buffer is left intact for a later {@see pullEvents()} to
+ * drain.
+ *
+ * @return EventRecords A copy of the recorded events, safe to iterate and mutate.
+ */
+ public function peekEvents(): EventRecords;
+
+ /**
+ * Returns the recorded events and clears the internal buffer.
+ *
+ *
Drains the buffer in a single step: the returned collection holds every event recorded since the
+ * last drain, and a subsequent call returns an empty collection until new events are recorded.
*
- * @return EventRecords The events awaiting append to the event store.
+ * @return EventRecords The events recorded since the last drain.
*/
- public function recordedEvents(): EventRecords;
+ public function pullEvents(): EventRecords;
}
diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php
index 97f7d7d..ce63494 100644
--- a/src/Aggregate/EventSourcingRootBehavior.php
+++ b/src/Aggregate/EventSourcingRootBehavior.php
@@ -4,12 +4,9 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
-use ReflectionClass;
-use ReflectionProperty;
use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
-use TinyBlocks\BuildingBlocks\Event\EventRecords;
use TinyBlocks\BuildingBlocks\Exceptions\EventHandlerMethodNotFound;
use TinyBlocks\BuildingBlocks\Exceptions\NoEventHandlerRegistered;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
@@ -20,18 +17,15 @@ trait EventSourcingRootBehavior
public static function blank(Identity $identity): static
{
- $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
- new ReflectionProperty(class: $aggregate, property: $aggregate->identityName())
- ->setValue($aggregate, $identity);
+ $aggregate = static::createBlank(identity: $identity);
$aggregate->aggregateVersion = AggregateVersion::initial();
- $aggregate->recordedEvents = EventRecords::createFromEmpty();
return $aggregate;
}
public static function reconstitute(
- Identity $identity,
iterable $records,
+ Identity $identity,
?Snapshot $snapshot = null
): static {
$aggregate = static::blank(identity: $identity);
@@ -77,8 +71,7 @@ protected function when(DomainEvent $event): void
$this->nextAggregateVersion();
$record = $this->buildEventRecord(event: $event);
$this->applyEvent(record: $record);
- $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
- ->add(elements: $record);
+ $this->appendRecordedEvent(record: $record);
}
private function applyEvent(EventRecord $record): void
diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php
index e058bec..6b1789b 100644
--- a/src/Aggregate/EventualAggregateRoot.php
+++ b/src/Aggregate/EventualAggregateRoot.php
@@ -6,21 +6,21 @@
use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
+use TinyBlocks\BuildingBlocks\Exceptions\IncompleteAggregateState;
use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty;
/**
* Aggregate root variant that records domain events for eventual publication via transactional outbox.
*
*
State is persisted as the source of truth. Events are emitted as side effects and delivered
- * at-least-once to external consumers. The repository drains recordedEvents() after
- * persisting the aggregate state.
+ * at-least-once to external consumers. After persisting the aggregate state, the repository drains the
+ * recorded-events buffer with pullEvents(), which returns the events and clears the buffer, so a
+ * second save of the same instance does not re-emit the events already drained. peekEvents() is the
+ * non-destructive counterpart: it returns a fresh copy for inspection and leaves the buffer untouched.
*
- *
Use-once contract: the recorded-events buffer is never cleared. After the
- * repository drains recordedEvents() and persists the records to the outbox, the aggregate
- * instance must be discarded. Re-saving the same instance attempts to push the same envelopes again and
- * fails with a duplicate-event error from the outbox. Applications that need to perform multiple
- * operations on the same logical aggregate within one process must reload from the repository between
- * operations.
+ *
An instance models a single unit of work. Once its events have been drained and persisted, reload from
+ * the repository before operating on the same logical aggregate again rather than reusing the drained
+ * instance.
*
*
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
@@ -33,53 +33,70 @@
interface EventualAggregateRoot extends AggregateRoot
{
/**
- * Reconstitutes the aggregate from persisted state.
+ * Reconstitutes the aggregate from persisted state without verifying completeness.
*
- *
Default factory for state-based aggregates. Used by repositories that load aggregate state
- * from external storage (a relational row, a document, an in-memory cache) and need an instance
- * ready to emit further events from the correct aggregate version.
+ *
Default factory for state-based aggregates. Used by repositories that load aggregate state from
+ * external storage (a relational row, a document, an in-memory cache) and need an instance ready to
+ * emit further events from the correct aggregate version. The default implementation provided by
+ * {@see EventualAggregateRootBehavior} hydrates state properties by reflection from the
+ * $aggregateState map.
*
- *
The default implementation provided by {@see EventualAggregateRootBehavior} hydrates state
- * properties by reflection from the $state map. Aggregates may override this factory
- * to customize the hydration or to rename the identity parameter to reflect their specific
- * identity type. For example:
+ *
Aggregates may override this factory to customize the hydration or to rename the identity
+ * parameter to reflect their specific identity type. The type must remain Identity per
+ * LSP rules on static methods. Concrete identity types can be enforced inside the override via
+ * instanceof.
*
- *
- * // Override with a custom parameter name:
- * public static function reconstitute(
- * Identity $orderId,
- * AggregateVersion $aggregateVersion,
- * array $state = []
- * ): static
- *
+ * @param Identity $identity The aggregate's identity.
+ * @param array $aggregateState Map of property name to value. Unknown keys are ignored.
+ * @param AggregateVersion $aggregateVersion The version to restore. Emitted events advance from this value.
+ * @return static The reconstituted aggregate.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
+ */
+ public static function reconstitutePartial(
+ Identity $identity,
+ array $aggregateState,
+ AggregateVersion $aggregateVersion
+ ): static;
+
+ /**
+ * Reconstitutes the aggregate and verifies every property was initialized.
*
- *
The parameter name is free, but the type must remain Identity per LSP rules on
- * static methods. Concrete identity types (e.g. OrderId) can be enforced inside the
- * override via instanceof.
+ *
Strict variant of {@see reconstitutePartial()}. It delegates to reconstitutePartial,
+ * honoring any override, then checks by reflection that hydration left no declared property
+ * uninitialized. Properties that carry a default value, and untyped properties, are always initialized
+ * by PHP and so are never flagged.
*
* @param Identity $identity The aggregate's identity.
- * @param AggregateVersion $aggregateVersion The version to restore. Subsequent emitted events
- * advance from this value.
- * @param array $state Optional map of property name to value. Entries whose key
- * does not match a declared property are silently ignored by
- * the default implementation.
- * @return static The reconstituted aggregate.
- * @throws MissingIdentityProperty When the property referenced by identityProperty()
- * does not exist on the aggregate class.
+ * @param array $aggregateState Map of property name to value. Unknown keys are ignored.
+ * @param AggregateVersion $aggregateVersion The version to restore. Emitted events advance from this value.
+ * @return static The reconstituted aggregate, with every property guaranteed initialized.
+ * @throws IncompleteAggregateState If hydration left any property uninitialized.
+ * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist.
*/
- public static function reconstitute(
+ public static function reconstituteStrict(
Identity $identity,
- AggregateVersion $aggregateVersion,
- array $state = []
+ array $aggregateState,
+ AggregateVersion $aggregateVersion
): static;
/**
- * Returns a copy of all events recorded since the aggregate was created.
+ * Returns a fresh copy of the recorded events without clearing the buffer.
+ *
+ *
A non-destructive read. The returned collection is a copy, so external mutation does not leak
+ * into the aggregate's internal buffer, and the buffer is left intact for a later {@see pullEvents()} to
+ * drain.
+ *
+ * @return EventRecords A copy of the recorded events, safe to iterate and mutate.
+ */
+ public function peekEvents(): EventRecords;
+
+ /**
+ * Returns the recorded events and clears the internal buffer.
*
- *
Always returns a fresh copy: external mutation of the returned collection does not leak into
- * the aggregate's internal buffer.
+ *
Drains the buffer in a single step: the returned collection holds every event recorded since the
+ * last drain, and a subsequent call returns an empty collection until new events are recorded.
*
- * @return EventRecords A snapshot of the recorded events, safe to iterate and mutate.
+ * @return EventRecords The events recorded since the last drain.
*/
- public function recordedEvents(): EventRecords;
+ public function pullEvents(): EventRecords;
}
diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php
index 31f8c15..becf611 100644
--- a/src/Aggregate/EventualAggregateRootBehavior.php
+++ b/src/Aggregate/EventualAggregateRootBehavior.php
@@ -4,38 +4,48 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
-use ReflectionClass;
-use ReflectionProperty;
use TinyBlocks\BuildingBlocks\Entity\Identity;
use TinyBlocks\BuildingBlocks\Event\DomainEvent;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
-use TinyBlocks\BuildingBlocks\Event\EventRecords;
+use TinyBlocks\BuildingBlocks\Exceptions\IncompleteAggregateState;
+use TinyBlocks\BuildingBlocks\Internal\AggregateReflection;
trait EventualAggregateRootBehavior
{
use AggregateRootBehavior;
- public static function reconstitute(
+ public static function reconstitutePartial(
Identity $identity,
- AggregateVersion $aggregateVersion,
- array $state = []
+ array $aggregateState,
+ AggregateVersion $aggregateVersion
): static {
- $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor();
- new ReflectionProperty(class: $aggregate, property: $aggregate->identityName())
- ->setValue($aggregate, $identity);
-
- foreach ($state as $property => $value) {
- if (property_exists($aggregate, $property)) {
- new ReflectionProperty(class: $aggregate, property: $property)
- ->setValue($aggregate, $value);
- }
- }
-
+ $aggregate = static::createBlank(identity: $identity);
+ AggregateReflection::hydrate(target: $aggregate, state: $aggregateState);
$aggregate->aggregateVersion = $aggregateVersion;
return $aggregate;
}
+ public static function reconstituteStrict(
+ Identity $identity,
+ array $aggregateState,
+ AggregateVersion $aggregateVersion
+ ): static {
+ $aggregate = static::reconstitutePartial(
+ identity: $identity,
+ aggregateState: $aggregateState,
+ aggregateVersion: $aggregateVersion
+ );
+
+ $missingProperties = AggregateReflection::uninitializedRequiredProperties(target: $aggregate);
+
+ if ($missingProperties !== []) {
+ throw new IncompleteAggregateState(className: static::class, propertyNames: $missingProperties);
+ }
+
+ return $aggregate;
+ }
+
/**
* Records a domain event on the aggregate's internal buffer.
*
@@ -46,10 +56,9 @@ public static function reconstitute(
*
* @param DomainEvent $event The event to record.
*/
- protected function push(DomainEvent $event): void
+ protected function pushEvent(DomainEvent $event): void
{
$this->nextAggregateVersion();
- $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty())
- ->add(elements: $this->buildEventRecord(event: $event));
+ $this->appendRecordedEvent(record: $this->buildEventRecord(event: $event));
}
}
diff --git a/src/Aggregate/ModelVersion.php b/src/Aggregate/ModelVersion.php
index 1b284fc..5b9b4a2 100644
--- a/src/Aggregate/ModelVersion.php
+++ b/src/Aggregate/ModelVersion.php
@@ -5,12 +5,15 @@
namespace TinyBlocks\BuildingBlocks\Aggregate;
use TinyBlocks\BuildingBlocks\Exceptions\InvalidModelVersion;
+use TinyBlocks\BuildingBlocks\Ordinal;
+use TinyBlocks\BuildingBlocks\OrdinalBehavior;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
-final readonly class ModelVersion implements ValueObject
+final readonly class ModelVersion implements ValueObject, Ordinal
{
use ValueObjectBehavior;
+ use OrdinalBehavior;
private function __construct(public int $value)
{
@@ -41,25 +44,8 @@ public static function initial(): ModelVersion
return new ModelVersion(value: 0);
}
- /**
- * Tells whether this model version is strictly after the given one.
- *
- * @param ModelVersion $other The model version to compare against.
- * @return bool True when this value is greater than the other's.
- */
- public function isAfter(ModelVersion $other): bool
- {
- return $this->value > $other->value;
- }
-
- /**
- * Tells whether this model version is strictly before the given one.
- *
- * @param ModelVersion $other The model version to compare against.
- * @return bool True when this value is less than the other's.
- */
- public function isBefore(ModelVersion $other): bool
+ public function value(): int
{
- return $this->value < $other->value;
+ return $this->value;
}
}
diff --git a/src/Event/DomainEvent.php b/src/Event/DomainEvent.php
index ab7efd5..2061c4e 100644
--- a/src/Event/DomainEvent.php
+++ b/src/Event/DomainEvent.php
@@ -38,4 +38,14 @@ interface DomainEvent
* @return Revision The current schema revision. Defaults to {@see Revision::initial}.
*/
public function revision(): Revision;
+
+ /**
+ * Returns the stable type identifier of this event.
+ *
+ *
The value is decoupled from the class name so it stays constant across renames and refactors,
+ * keeping already-persisted event types valid. It must match the pattern enforced by {@see EventType}.
+ *
+ * @return string The stable PascalCase type identifier.
+ */
+ public function eventType(): string;
}
diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php
index 2393323..c27d024 100644
--- a/src/Event/EventRecord.php
+++ b/src/Event/EventRecord.php
@@ -4,11 +4,10 @@
namespace TinyBlocks\BuildingBlocks\Event;
-use Ramsey\Uuid\Uuid;
-use Ramsey\Uuid\UuidInterface;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Entity\Identity;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
+use TinyBlocks\BuildingBlocks\Uuid;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -16,12 +15,12 @@
{
use ValueObjectBehavior;
- public function __construct(
- public UuidInterface $id,
+ private function __construct(
+ public Uuid $id,
public DomainEvent $event,
public Revision $revision,
public EventType $eventType,
- public Instant $occurredAt,
+ public Utc $occurredAt,
public Identity $aggregateId,
public string $aggregateType,
public AggregateVersion $aggregateVersion
@@ -35,24 +34,24 @@ public function __construct(
* @param Identity $aggregateId The aggregate identity that produced the event.
* @param string $aggregateType The short class name of the aggregate.
* @param AggregateVersion $aggregateVersion The aggregate version assigned to this envelope.
- * @param UuidInterface|null $id Optional explicit identifier. Defaults to a fresh UUIDv4.
- * @param Instant|null $occurredAt Optional explicit occurrence timestamp. Defaults to now.
+ * @param Uuid|null $id Optional explicit identifier. Defaults to a fresh UUIDv7.
+ * @param Utc|null $occurredAt Optional explicit occurrence timestamp. Defaults to now.
* @return EventRecord The constructed envelope.
*/
- public static function of(
+ public static function from(
DomainEvent $event,
Identity $aggregateId,
string $aggregateType,
AggregateVersion $aggregateVersion,
- ?UuidInterface $id = null,
- ?Instant $occurredAt = null
+ ?Uuid $id = null,
+ ?Utc $occurredAt = null
): EventRecord {
return new EventRecord(
- id: $id ?? Uuid::uuid4(),
+ id: $id ?? Uuid::generateV7(),
event: $event,
revision: $event->revision(),
eventType: EventType::fromDomainEvent(event: $event),
- occurredAt: $occurredAt ?? Instant::now(),
+ occurredAt: $occurredAt ?? Utc::now(),
aggregateId: $aggregateId,
aggregateType: $aggregateType,
aggregateVersion: $aggregateVersion
diff --git a/src/Event/EventRecords.php b/src/Event/EventRecords.php
index cd36075..590f773 100644
--- a/src/Event/EventRecords.php
+++ b/src/Event/EventRecords.php
@@ -5,7 +5,12 @@
namespace TinyBlocks\BuildingBlocks\Event;
use TinyBlocks\Collection\Collection;
+use TinyBlocks\Mapper\ElementType;
+/**
+ * @extends Collection
+ */
+#[ElementType(EventRecord::class)]
final class EventRecords extends Collection
{
}
diff --git a/src/Event/EventType.php b/src/Event/EventType.php
index 083e890..a419d2d 100644
--- a/src/Event/EventType.php
+++ b/src/Event/EventType.php
@@ -4,8 +4,8 @@
namespace TinyBlocks\BuildingBlocks\Event;
-use ReflectionClass;
use TinyBlocks\BuildingBlocks\Exceptions\InvalidEventType;
+use TinyBlocks\BuildingBlocks\Internal\ClassName;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -35,15 +35,15 @@ public static function fromString(string $value): EventType
}
/**
- * Creates an EventType from a domain event using its short class name.
+ * Creates an EventType from a domain event using its declared type identifier.
*
- * @param DomainEvent $event The domain event whose class name carries the type.
+ * @param DomainEvent $event The domain event whose eventType() carries the type.
* @return EventType The created instance.
- * @throws InvalidEventType If the resolved class name does not match the required pattern.
+ * @throws InvalidEventType If the declared type does not match the required pattern.
*/
public static function fromDomainEvent(DomainEvent $event): EventType
{
- return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName());
+ return new EventType(value: $event->eventType());
}
/**
@@ -55,6 +55,6 @@ public static function fromDomainEvent(DomainEvent $event): EventType
*/
public static function fromIntegrationEvent(IntegrationEvent $event): EventType
{
- return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName());
+ return new EventType(value: ClassName::shortName(target: $event));
}
}
diff --git a/src/Event/IntegrationEventRecord.php b/src/Event/IntegrationEventRecord.php
index 7d0a6dd..f980b7e 100644
--- a/src/Event/IntegrationEventRecord.php
+++ b/src/Event/IntegrationEventRecord.php
@@ -4,10 +4,10 @@
namespace TinyBlocks\BuildingBlocks\Event;
-use Ramsey\Uuid\UuidInterface;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Entity\Identity;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
+use TinyBlocks\BuildingBlocks\Uuid;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -32,11 +32,11 @@
use ValueObjectBehavior;
private function __construct(
- public UuidInterface $id,
+ public Uuid $id,
public IntegrationEvent $event,
public Revision $revision,
public EventType $eventType,
- public Instant $occurredAt,
+ public Utc $occurredAt,
public Identity $aggregateId,
public string $aggregateType,
public AggregateVersion $aggregateVersion
diff --git a/src/Event/IntegrationEventTranslators.php b/src/Event/IntegrationEventTranslators.php
index b37cf7e..c183c02 100644
--- a/src/Event/IntegrationEventTranslators.php
+++ b/src/Event/IntegrationEventTranslators.php
@@ -14,6 +14,8 @@
* true for the given record, or null when no translator handles
* it. A null result is the canonical signal that the event is purely internal and must
* not cross the bounded-context boundary.
+ *
+ * @extends Collection
*/
final class IntegrationEventTranslators extends Collection
{
@@ -26,12 +28,10 @@ final class IntegrationEventTranslators extends Collection
*/
public function findFor(EventRecord $record): ?IntegrationEventTranslator
{
- $translator = $this->findBy(
+ return $this->findBy(
predicates: static fn(IntegrationEventTranslator $translator): bool => $translator->supports(
record: $record
)
);
-
- return $translator instanceof IntegrationEventTranslator ? $translator : null;
}
}
diff --git a/src/Event/Revision.php b/src/Event/Revision.php
index b2890ab..49cbe4c 100644
--- a/src/Event/Revision.php
+++ b/src/Event/Revision.php
@@ -5,12 +5,15 @@
namespace TinyBlocks\BuildingBlocks\Event;
use TinyBlocks\BuildingBlocks\Exceptions\InvalidRevision;
+use TinyBlocks\BuildingBlocks\Ordinal;
+use TinyBlocks\BuildingBlocks\OrdinalBehavior;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
-final readonly class Revision implements ValueObject
+final readonly class Revision implements ValueObject, Ordinal
{
use ValueObjectBehavior;
+ use OrdinalBehavior;
private function __construct(public int $value)
{
@@ -41,25 +44,8 @@ public static function initial(): Revision
return new Revision(value: 1);
}
- /**
- * Tells whether this revision is strictly after the given one.
- *
- * @param Revision $other The revision to compare against.
- * @return bool True when this revision's value is greater than the other's.
- */
- public function isAfter(Revision $other): bool
- {
- return $this->value > $other->value;
- }
-
- /**
- * Tells whether this revision is strictly before the given one.
- *
- * @param Revision $other The revision to compare against.
- * @return bool True when this revision's value is less than the other's.
- */
- public function isBefore(Revision $other): bool
+ public function value(): int
{
- return $this->value < $other->value;
+ return $this->value;
}
}
diff --git a/src/Exceptions/IncompleteAggregateState.php b/src/Exceptions/IncompleteAggregateState.php
new file mode 100644
index 0000000..745789b
--- /dev/null
+++ b/src/Exceptions/IncompleteAggregateState.php
@@ -0,0 +1,17 @@
+ left required properties uninitialized: <%s>.';
+
+ parent::__construct(message: sprintf($template, $className, implode(', ', $propertyNames)));
+ }
+}
diff --git a/src/Exceptions/InvalidUtc.php b/src/Exceptions/InvalidUtc.php
new file mode 100644
index 0000000..e24ae84
--- /dev/null
+++ b/src/Exceptions/InvalidUtc.php
@@ -0,0 +1,17 @@
+ is not a valid ISO 8601 instant.';
+
+ parent::__construct(message: sprintf($template, $value));
+ }
+}
diff --git a/src/Exceptions/InvalidUuid.php b/src/Exceptions/InvalidUuid.php
new file mode 100644
index 0000000..adb1caa
--- /dev/null
+++ b/src/Exceptions/InvalidUuid.php
@@ -0,0 +1,17 @@
+ is not a valid UUID.';
+
+ parent::__construct(message: sprintf($template, $value));
+ }
+}
diff --git a/src/Internal/AggregateReflection.php b/src/Internal/AggregateReflection.php
new file mode 100644
index 0000000..cc0a6c0
--- /dev/null
+++ b/src/Internal/AggregateReflection.php
@@ -0,0 +1,64 @@
+ $class The fully qualified class name to instantiate.
+ * @return T The instance created without running the constructor.
+ */
+ public static function instantiate(string $class): object
+ {
+ return new ReflectionClass(objectOrClass: $class)->newInstanceWithoutConstructor();
+ }
+
+ public static function assignProperty(object $target, string $property, mixed $value): void
+ {
+ new ReflectionProperty(class: $target, property: $property)->setValue($target, $value);
+ }
+
+ /**
+ * Assigns each state entry to its matching property on the target.
+ *
+ * @param array $state The property-keyed state to assign.
+ */
+ public static function hydrate(object $target, array $state): void
+ {
+ foreach ($state as $property => $value) {
+ if (property_exists($target, $property)) {
+ AggregateReflection::assignProperty(target: $target, property: $property, value: $value);
+ }
+ }
+ }
+
+ /**
+ * Returns the target's required properties that remain uninitialized.
+ *
+ * @return list The names of the uninitialized properties.
+ */
+ public static function uninitializedRequiredProperties(object $target): array
+ {
+ $missing = [];
+
+ foreach (new ReflectionClass(objectOrClass: $target)->getProperties() as $property) {
+ if (!$property->isInitialized($target)) {
+ $missing[] = $property->getName();
+ }
+ }
+
+ return $missing;
+ }
+}
diff --git a/src/Internal/ClassName.php b/src/Internal/ClassName.php
new file mode 100644
index 0000000..25443de
--- /dev/null
+++ b/src/Internal/ClassName.php
@@ -0,0 +1,19 @@
+getShortName();
+ }
+}
diff --git a/src/Ordinal.php b/src/Ordinal.php
new file mode 100644
index 0000000..7239496
--- /dev/null
+++ b/src/Ordinal.php
@@ -0,0 +1,21 @@
+Implementations expose their position through {@see Ordinal::value()}, which {@see OrdinalBehavior}
+ * uses to provide the isAfter and isBefore comparisons.
+ */
+interface Ordinal
+{
+ /**
+ * Returns the integer that backs the ordering.
+ *
+ * @return int The ordinal value.
+ */
+ public function value(): int;
+}
diff --git a/src/OrdinalBehavior.php b/src/OrdinalBehavior.php
new file mode 100644
index 0000000..0aaecfe
--- /dev/null
+++ b/src/OrdinalBehavior.php
@@ -0,0 +1,20 @@
+value() > $other->value();
+ }
+
+ public function isBefore(Ordinal $other): bool
+ {
+ return $this->value() < $other->value();
+ }
+}
diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php
index 8f0733f..5049b0f 100644
--- a/src/Snapshot/Snapshot.php
+++ b/src/Snapshot/Snapshot.php
@@ -6,7 +6,7 @@
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
@@ -16,7 +16,7 @@
private function __construct(
public string $aggregateType,
- public Instant $createdAt,
+ public Utc $createdAt,
public mixed $aggregateId,
public array $aggregateState,
public AggregateVersion $aggregateVersion
@@ -27,7 +27,7 @@ private function __construct(
* Creates a Snapshot from the persisted fields.
*
* @param string $aggregateType The short class name of the aggregate.
- * @param Instant $createdAt The instant the snapshot was taken.
+ * @param Utc $createdAt The instant the snapshot was taken.
* @param mixed $aggregateId The aggregate identity raw value.
* @param array $aggregateState The captured aggregate state keyed by property name.
* @param AggregateVersion $aggregateVersion The aggregate version captured with the snapshot.
@@ -35,7 +35,7 @@ private function __construct(
*/
public static function restore(
string $aggregateType,
- Instant $createdAt,
+ Utc $createdAt,
mixed $aggregateId,
array $aggregateState,
AggregateVersion $aggregateVersion
@@ -59,7 +59,7 @@ public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot
{
return new Snapshot(
aggregateType: $aggregate->aggregateType(),
- createdAt: Instant::now(),
+ createdAt: Utc::now(),
aggregateId: $aggregate->identityValue(),
aggregateState: $aggregate->snapshotState(),
aggregateVersion: $aggregate->aggregateVersion()
@@ -79,9 +79,9 @@ public function aggregateType(): string
/**
* Returns the creation timestamp.
*
- * @return Instant The instant the snapshot was taken.
+ * @return Utc The instant the snapshot was taken.
*/
- public function createdAt(): Instant
+ public function createdAt(): Utc
{
return $this->createdAt;
}
diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php
index 2035e84..147bce3 100644
--- a/src/Upcast/IntermediateEvent.php
+++ b/src/Upcast/IntermediateEvent.php
@@ -6,23 +6,36 @@
use TinyBlocks\BuildingBlocks\Event\EventType;
use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\Mapper\ObjectMappability;
-use TinyBlocks\Mapper\ObjectMapper;
+use TinyBlocks\Mapper\Mappable;
+use TinyBlocks\Mapper\MappableBehavior;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;
-final readonly class IntermediateEvent implements ValueObject, ObjectMapper
+final readonly class IntermediateEvent implements ValueObject, Mappable
{
use ValueObjectBehavior;
- use ObjectMappability;
+ use MappableBehavior;
- public function __construct(
+ private function __construct(
public EventType $type,
public Revision $revision,
public array $serializedEvent
) {
}
+ /**
+ * Creates an IntermediateEvent from its type, revision, and serialized payload.
+ *
+ * @param EventType $type The event type identifier.
+ * @param Revision $revision The schema revision.
+ * @param array $serializedEvent The serialized event payload.
+ * @return IntermediateEvent The created instance.
+ */
+ public static function from(EventType $type, Revision $revision, array $serializedEvent): IntermediateEvent
+ {
+ return new IntermediateEvent(type: $type, revision: $revision, serializedEvent: $serializedEvent);
+ }
+
public function equals(ValueObject $other): bool
{
if ($other::class !== static::class) {
diff --git a/src/Upcast/Upcasters.php b/src/Upcast/Upcasters.php
index 185fb68..09a1db2 100644
--- a/src/Upcast/Upcasters.php
+++ b/src/Upcast/Upcasters.php
@@ -6,6 +6,9 @@
use TinyBlocks\Collection\Collection;
+/**
+ * @extends Collection
+ */
final class Upcasters extends Collection
{
/**
@@ -24,7 +27,6 @@ public function chain(IntermediateEvent $event): IntermediateEvent
return $upcaster->upcast(event: $carried);
};
- /** @var IntermediateEvent $upcasted */
$upcasted = $this->reduce(accumulator: $upcast, initial: $event);
return $upcasted;
diff --git a/src/Utc.php b/src/Utc.php
new file mode 100644
index 0000000..f8f1ae0
--- /dev/null
+++ b/src/Utc.php
@@ -0,0 +1,62 @@
+Normalized to UTC with second precision so that two values representing the same point in
+ * time are equal regardless of how they were created.
+ */
+final readonly class Utc implements ValueObject
+{
+ use ValueObjectBehavior;
+
+ private function __construct(private string $iso)
+ {
+ }
+
+ /**
+ * Creates a UTC representing the current moment.
+ *
+ * @return Utc The current moment in UTC.
+ */
+ public static function now(): Utc
+ {
+ return new Utc(iso: Instant::now()->toIso8601());
+ }
+
+ /**
+ * Creates a UTC from an ISO 8601 representation.
+ *
+ * @param string $value An ISO 8601 date-time string (e.g. 2026-02-17T10:30:00+00:00).
+ * @return Utc The created moment in UTC.
+ * @throws InvalidUtc If the value is not a valid ISO 8601 instant.
+ */
+ public static function fromIso8601(string $value): Utc
+ {
+ try {
+ return new Utc(iso: Instant::fromString(value: $value)->toIso8601());
+ } catch (InvalidInstant) {
+ throw new InvalidUtc(value: $value);
+ }
+ }
+
+ /**
+ * Returns the UTC as an ISO 8601 string with second precision.
+ *
+ * @return string The ISO 8601 representation in UTC.
+ */
+ public function toIso8601(): string
+ {
+ return $this->iso;
+ }
+}
diff --git a/src/Uuid.php b/src/Uuid.php
new file mode 100644
index 0000000..b05f427
--- /dev/null
+++ b/src/Uuid.php
@@ -0,0 +1,81 @@
+toString());
+ } catch (InvalidArgumentException) {
+ throw new InvalidUuid(value: $value);
+ }
+ }
+
+ /**
+ * Creates an Uuid from its canonical version 7 string representation.
+ *
+ * @param string $value The canonical string representation of a version 7 UUID.
+ * @return Uuid The created identifier.
+ * @throws InvalidUuid If the value is not a valid UUID or its version is not 7.
+ */
+ public static function fromV7(string $value): Uuid
+ {
+ try {
+ $parsed = RamseyUuid::fromString(uuid: $value);
+ } catch (InvalidArgumentException) {
+ throw new InvalidUuid(value: $value);
+ }
+
+ if ($parsed->getVersion() !== 7) {
+ throw new InvalidUuid(value: $value);
+ }
+
+ return new Uuid(value: $parsed->toString());
+ }
+
+ /**
+ * Creates an Uuid generated as a version 7 (Unix Epoch time) identifier.
+ *
+ * @return Uuid The generated version 7 identifier.
+ */
+ public static function generateV7(): Uuid
+ {
+ return new Uuid(value: RamseyUuid::uuid7()->toString());
+ }
+
+ /**
+ * Returns the Uuid as its canonical string representation.
+ *
+ * @return string The canonical string representation.
+ */
+ public function toString(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/tests/Models/BaseCart.php b/tests/Models/BaseCart.php
new file mode 100644
index 0000000..9baf811
--- /dev/null
+++ b/tests/Models/BaseCart.php
@@ -0,0 +1,38 @@
+when(event: new ProductAdded(productId: $productId));
+ }
+
+ public function applySnapshot(Snapshot $snapshot): void
+ {
+ $this->productIds = $snapshot->aggregateState()['productIds'] ?? [];
+ }
+
+ public function productIds(): array
+ {
+ return $this->productIds;
+ }
+
+ protected function whenProductAdded(ProductAdded $event): void
+ {
+ $this->productIds[] = $event->productId;
+ }
+}
diff --git a/tests/Models/BaseReservation.php b/tests/Models/BaseReservation.php
new file mode 100644
index 0000000..0505233
--- /dev/null
+++ b/tests/Models/BaseReservation.php
@@ -0,0 +1,17 @@
+guest = $guest;
+ $this->status = $status;
+ }
+}
diff --git a/tests/Models/Order.php b/tests/Models/Order.php
index 97ecee4..7bf0340 100644
--- a/tests/Models/Order.php
+++ b/tests/Models/Order.php
@@ -9,6 +9,7 @@
use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot;
use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior;
use TinyBlocks\BuildingBlocks\Entity\Identity;
+use TinyBlocks\BuildingBlocks\Event\EventRecords;
final class Order implements EventualAggregateRoot
{
@@ -20,10 +21,10 @@ private function __construct(private readonly OrderId $id)
{
}
- public static function reconstitute(
+ public static function reconstitutePartial(
Identity $identity,
- AggregateVersion $aggregateVersion,
- array $state = []
+ array $aggregateState,
+ AggregateVersion $aggregateVersion
): static {
if (!$identity instanceof OrderId) {
$template = 'Expected identity of type <%s>, got <%s>.';
@@ -33,6 +34,7 @@ public static function reconstitute(
$order = new Order(id: $identity);
$order->aggregateVersion = $aggregateVersion;
+ $order->recordedEvents = EventRecords::createFromEmpty();
return $order;
}
@@ -41,7 +43,7 @@ public static function place(OrderId $orderId, string $item): Order
{
$order = new Order(id: $orderId);
$order->status = 'placed';
- $order->push(event: new OrderPlaced(item: $item));
+ $order->pushEvent(event: new OrderPlaced(item: $item));
return $order;
}
@@ -49,6 +51,6 @@ public static function place(OrderId $orderId, string $item): Order
public function ship(string $carrier): void
{
$this->status = 'shipped';
- $this->push(event: new OrderShipped(carrier: $carrier));
+ $this->pushEvent(event: new OrderShipped(carrier: $carrier));
}
}
diff --git a/tests/Models/OrderPlaced.php b/tests/Models/OrderPlaced.php
index 422f378..079a84e 100644
--- a/tests/Models/OrderPlaced.php
+++ b/tests/Models/OrderPlaced.php
@@ -14,4 +14,9 @@
public function __construct(public string $item)
{
}
+
+ public function eventType(): string
+ {
+ return 'OrderPlaced';
+ }
}
diff --git a/tests/Models/OrderShipped.php b/tests/Models/OrderShipped.php
index 33fb7ec..7a0b384 100644
--- a/tests/Models/OrderShipped.php
+++ b/tests/Models/OrderShipped.php
@@ -14,4 +14,9 @@
public function __construct(public string $carrier)
{
}
+
+ public function eventType(): string
+ {
+ return 'OrderShipped';
+ }
}
diff --git a/tests/Models/OrderWithMissingIdentityProperty.php b/tests/Models/OrderWithMissingIdentityProperty.php
index 5019d08..998b96b 100644
--- a/tests/Models/OrderWithMissingIdentityProperty.php
+++ b/tests/Models/OrderWithMissingIdentityProperty.php
@@ -13,7 +13,7 @@ final class OrderWithMissingIdentityProperty implements EventualAggregateRoot
public function ship(): void
{
- $this->push(event: new OrderShipped(carrier: 'DHL'));
+ $this->pushEvent(event: new OrderShipped(carrier: 'DHL'));
}
protected function identityProperty(): string
diff --git a/tests/Models/ProductAdded.php b/tests/Models/ProductAdded.php
index 5052eba..6c223b4 100644
--- a/tests/Models/ProductAdded.php
+++ b/tests/Models/ProductAdded.php
@@ -14,4 +14,9 @@
public function __construct(public string $productId)
{
}
+
+ public function eventType(): string
+ {
+ return 'ProductAdded';
+ }
}
diff --git a/tests/Models/ProductAddedV2.php b/tests/Models/ProductAddedV2.php
index bfba964..054931d 100644
--- a/tests/Models/ProductAddedV2.php
+++ b/tests/Models/ProductAddedV2.php
@@ -20,4 +20,9 @@ public function revision(): Revision
{
return Revision::of(value: 2);
}
+
+ public function eventType(): string
+ {
+ return 'ProductAddedV2';
+ }
}
diff --git a/tests/Models/Reservation.php b/tests/Models/Reservation.php
index 85d7933..c5629e3 100644
--- a/tests/Models/Reservation.php
+++ b/tests/Models/Reservation.php
@@ -22,7 +22,7 @@ private function __construct(private readonly ReservationId $id, string $status)
public static function book(ReservationId $id): Reservation
{
$reservation = new Reservation(id: $id, status: 'pending');
- $reservation->push(event: new ReservationBooked());
+ $reservation->pushEvent(event: new ReservationBooked());
return $reservation;
}
@@ -36,6 +36,6 @@ public function confirm(): void
}
$this->status = 'confirmed';
- $this->push(event: new ReservationConfirmed());
+ $this->pushEvent(event: new ReservationConfirmed());
}
}
diff --git a/tests/Models/ReservationBooked.php b/tests/Models/ReservationBooked.php
index b125d0d..ef249ad 100644
--- a/tests/Models/ReservationBooked.php
+++ b/tests/Models/ReservationBooked.php
@@ -10,4 +10,9 @@
final readonly class ReservationBooked implements DomainEvent
{
use DomainEventBehavior;
+
+ public function eventType(): string
+ {
+ return 'ReservationBooked';
+ }
}
diff --git a/tests/Models/ReservationConfirmed.php b/tests/Models/ReservationConfirmed.php
index a7428b7..065d483 100644
--- a/tests/Models/ReservationConfirmed.php
+++ b/tests/Models/ReservationConfirmed.php
@@ -10,4 +10,9 @@
final readonly class ReservationConfirmed implements DomainEvent
{
use DomainEventBehavior;
+
+ public function eventType(): string
+ {
+ return 'ReservationConfirmed';
+ }
}
diff --git a/tests/Models/SpecializedCart.php b/tests/Models/SpecializedCart.php
new file mode 100644
index 0000000..3c74565
--- /dev/null
+++ b/tests/Models/SpecializedCart.php
@@ -0,0 +1,23 @@
+when(event: new ProductAdded(productId: $productId));
+ }
+
+ public function identityPropertyName(): string
+ {
+ return $this->identityProperty();
+ }
+}
diff --git a/tests/Models/SpecializedReservation.php b/tests/Models/SpecializedReservation.php
new file mode 100644
index 0000000..d0c867d
--- /dev/null
+++ b/tests/Models/SpecializedReservation.php
@@ -0,0 +1,21 @@
+pushEvent(event: new ReservationBooked());
+
+ return $reservation;
+ }
+
+ public function confirm(): void
+ {
+ $this->pushEvent(event: new ReservationConfirmed());
+ }
+}
diff --git a/tests/Unit/Aggregate/AggregateExtensionBehaviorTest.php b/tests/Unit/Aggregate/AggregateExtensionBehaviorTest.php
new file mode 100644
index 0000000..40e8177
--- /dev/null
+++ b/tests/Unit/Aggregate/AggregateExtensionBehaviorTest.php
@@ -0,0 +1,62 @@
+confirm();
+
+ /** @Then the aggregate version reflects both the booking and the confirmation */
+ self::assertSame(2, $reservation->aggregateVersion()->value);
+ }
+
+ public function testAddGiftProductWhenSubclassRecordsEventThenStateIsApplied(): void
+ {
+ /** @Given a blank cart specialized by a subclass */
+ $cart = SpecializedCart::blank(identity: new CartId(value: 'cart-1'));
+
+ /** @When the subclass command records an event through the inherited recording seam */
+ $cart->addGiftProduct(productId: 'gift-1');
+
+ /** @Then the recorded event is applied to the aggregate state */
+ self::assertSame(['gift-1'], $cart->productIds());
+ }
+
+ public function testStartEmptyWhenSubclassCallsInheritedFactoryThenVersionIsInitial(): void
+ {
+ /** @Given a cart identity */
+ $cartId = new CartId(value: 'cart-2');
+
+ /** @When the subclass custom factory builds a blank instance through the inherited factory seam */
+ $cart = SpecializedCart::startEmpty(cartId: $cartId);
+
+ /** @Then the aggregate starts at the initial version carrying the given identity */
+ self::assertSame(0, $cart->aggregateVersion()->value);
+ }
+
+ public function testIdentityPropertyNameWhenSubclassReadsInheritedSeamThenDefaultResolves(): void
+ {
+ /** @Given a blank cart specialized by a subclass that reads its inherited identity convention */
+ $cart = SpecializedCart::blank(identity: new CartId(value: 'cart-3'));
+
+ /** @When the subclass resolves its backing property name through the inherited seam */
+ $name = $cart->identityPropertyName();
+
+ /** @Then it resolves to the default property declared by the inherited behavior */
+ self::assertSame('id', $name);
+ }
+}
diff --git a/tests/Unit/Aggregate/AggregateRootBehaviorTest.php b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php
index 0e75b9b..b9e87d7 100644
--- a/tests/Unit/Aggregate/AggregateRootBehaviorTest.php
+++ b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php
@@ -76,8 +76,9 @@ public function testAggregateTypeForOutboxAggregate(): void
public function testReconstitutedAggregateVersionMatchesPersistedValue(): void
{
/** @Given an Order reconstituted with a persisted aggregate version of 5 */
- $order = Order::reconstitute(
+ $order = Order::reconstitutePartial(
identity: new OrderId(value: 'ord-3'),
+ aggregateState: [],
aggregateVersion: AggregateVersion::of(value: 5)
);
@@ -91,8 +92,9 @@ public function testReconstitutedAggregateVersionMatchesPersistedValue(): void
public function testPushAfterReconstituteAdvancesVersionByOne(): void
{
/** @Given an Order reconstituted with a persisted aggregate version of 5 */
- $order = Order::reconstitute(
+ $order = Order::reconstitutePartial(
identity: new OrderId(value: 'ord-4'),
+ aggregateState: [],
aggregateVersion: AggregateVersion::of(value: 5)
);
@@ -102,4 +104,19 @@ public function testPushAfterReconstituteAdvancesVersionByOne(): void
/** @Then the aggregate version advances by one */
self::assertSame(6, $order->aggregateVersion()->value);
}
+
+ public function testPullDrainsRecordedEventsAndClearsTheBuffer(): void
+ {
+ /** @Given an order with a recorded event */
+ $order = Order::place(orderId: new OrderId(value: 'ord-pull'), item: 'pen');
+
+ /** @When pulling the recorded events */
+ $pulled = $order->pullEvents();
+
+ /** @Then the pulled batch holds the recorded event */
+ self::assertSame(1, $pulled->count());
+
+ /** @And the aggregate buffer is now empty */
+ self::assertTrue($order->peekEvents()->isEmpty());
+ }
}
diff --git a/tests/Unit/Aggregate/AggregateVersionTest.php b/tests/Unit/Aggregate/AggregateVersionTest.php
index b34a884..6f7be1a 100644
--- a/tests/Unit/Aggregate/AggregateVersionTest.php
+++ b/tests/Unit/Aggregate/AggregateVersionTest.php
@@ -74,6 +74,18 @@ public function testNextDoesNotMutateTheSource(): void
self::assertSame(5, $aggregateVersion->value);
}
+ public function testValueReturnsTheBackingInteger(): void
+ {
+ /** @Given an aggregate version of 5 */
+ $aggregateVersion = AggregateVersion::of(value: 5);
+
+ /** @When retrieving its ordinal value */
+ $value = $aggregateVersion->value();
+
+ /** @Then the backing integer is returned */
+ self::assertSame(5, $value);
+ }
+
public function testIsAfterReturnsTrueWhenStrictlyGreater(): void
{
/** @Given a larger aggregate version */
diff --git a/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
index d8be17a..74d60b8 100644
--- a/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
+++ b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php
@@ -63,7 +63,7 @@ public function testBlankAggregateStartsWithNoRecordedEvents(): void
$cart = Cart::blank(identity: $cartId);
/** @Then the recorded events buffer is empty */
- self::assertTrue($cart->recordedEvents()->isEmpty());
+ self::assertTrue($cart->peekEvents()->isEmpty());
}
public function testDomainOperationAppliesStateFromEmittedEvent(): void
@@ -102,7 +102,7 @@ public function testDomainOperationAppendsToRecordedEvents(): void
$cart->addProduct(productId: 'prod-1');
/** @Then one event is recorded */
- self::assertSame(1, $cart->recordedEvents()->count());
+ self::assertSame(1, $cart->peekEvents()->count());
}
public function testFirstRecordedEventCarriesEnvelopeMetadata(): void
@@ -117,7 +117,7 @@ public function testFirstRecordedEventCarriesEnvelopeMetadata(): void
$cart->addProduct(productId: 'prod-abc');
/** @When inspecting the first recorded event */
- $record = $cart->recordedEvents()->first();
+ $record = $cart->peekEvents()->first();
/** @Then the envelope carries the expected metadata */
self::assertSame('ProductAdded', $record->eventType->value);
@@ -138,7 +138,7 @@ public function testReconstituteReplaysEventsInOrder(): void
$original = Cart::withProducts(cartId: $cartId, count: 2);
/** @When reconstituting from the event stream */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+ $reconstituted = Cart::reconstitute(records: $original->peekEvents(), identity: $cartId);
/** @Then the replayed state preserves event order */
self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds());
@@ -162,7 +162,7 @@ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream
$original->addProduct(productId: 'mango');
/** @When reconstituting from the event stream */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+ $reconstituted = Cart::reconstitute(records: $original->peekEvents(), identity: $cartId);
/** @Then the replayed state preserves the exact insertion order */
self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->productIds());
@@ -177,7 +177,7 @@ public function testReconstituteAdvancesAggregateVersionToLastEvent(): void
$original = Cart::withProducts(cartId: $cartId, count: 2);
/** @When reconstituting from the event stream */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+ $reconstituted = Cart::reconstitute(records: $original->peekEvents(), identity: $cartId);
/** @Then the aggregate version equals the last event's */
self::assertSame(2, $reconstituted->aggregateVersion()->value);
@@ -189,7 +189,7 @@ public function testReconstituteWithEmptyStreamYieldsBlankState(): void
$cartId = new CartId(value: 'cart-7');
/** @When reconstituting with no events */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: []);
+ $reconstituted = Cart::reconstitute(records: [], identity: $cartId);
/** @Then the state matches a blank aggregate */
self::assertSame([], $reconstituted->productIds());
@@ -201,7 +201,7 @@ public function testReconstituteWithEmptyStreamYieldsInitialAggregateVersion():
$cartId = new CartId(value: 'cart-7b');
/** @When reconstituting with no events */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: []);
+ $reconstituted = Cart::reconstitute(records: [], identity: $cartId);
/** @Then the aggregate version remains at the initial value */
self::assertSame(0, $reconstituted->aggregateVersion()->value);
@@ -222,7 +222,7 @@ public function testReconstituteFromSnapshotRestoresDomainState(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @When reconstituting from the snapshot only */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+ $reconstituted = Cart::reconstitute(records: [], identity: $cartId, snapshot: $snapshot);
/** @Then the domain state is fully restored */
self::assertSame(['prod-snapshot'], $reconstituted->productIds());
@@ -243,7 +243,7 @@ public function testReconstituteFromSnapshotAppliesTheSnapshotAggregateVersion()
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @When reconstituting from the snapshot only */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+ $reconstituted = Cart::reconstitute(records: [], identity: $cartId, snapshot: $snapshot);
/** @Then the aggregate version matches the snapshot's */
self::assertSame(1, $reconstituted->aggregateVersion()->value);
@@ -267,14 +267,14 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void
$cart->addProduct(productId: 'prod-2');
/** @And the records after the snapshot filtered out */
- $laterRecords = $cart->recordedEvents()->filter(
+ $laterRecords = $cart->peekEvents()->filter(
predicates: static fn($record): bool => $record->aggregateVersion->isAfter(
other: $snapshot->aggregateVersion()
)
);
/** @When reconstituting from the snapshot and the later records */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
+ $reconstituted = Cart::reconstitute(records: $laterRecords, identity: $cartId, snapshot: $snapshot);
/** @Then the full state is restored */
self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds());
@@ -298,14 +298,14 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesAggreg
$cart->addProduct(productId: 'prod-2');
/** @And the records after the snapshot filtered out */
- $laterRecords = $cart->recordedEvents()->filter(
+ $laterRecords = $cart->peekEvents()->filter(
predicates: static fn($record): bool => $record->aggregateVersion->isAfter(
other: $snapshot->aggregateVersion()
)
);
/** @When reconstituting from the snapshot and the later records */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot);
+ $reconstituted = Cart::reconstitute(records: $laterRecords, identity: $cartId, snapshot: $snapshot);
/** @Then the aggregate version reflects the last applied event */
self::assertSame(2, $reconstituted->aggregateVersion()->value);
@@ -320,10 +320,10 @@ public function testReconstitutedAggregateHasNoRecordedEvents(): void
$original = Cart::withProducts(cartId: $cartId, count: 1);
/** @When reconstituting from that event stream */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents());
+ $reconstituted = Cart::reconstitute(records: $original->peekEvents(), identity: $cartId);
/** @Then the reconstituted aggregate has no fresh recorded events */
- self::assertTrue($reconstituted->recordedEvents()->isEmpty());
+ self::assertTrue($reconstituted->peekEvents()->isEmpty());
}
public function testExplicitHandlerIsInvokedForRegisteredEvent(): void
@@ -347,7 +347,7 @@ public function testRevisionOverrideIsCarriedOnEventRecord(): void
$cart->addProductV2(productId: 'prod-v2', quantity: 3);
/** @Then the recorded event carries revision 2 */
- self::assertSame(2, $cart->recordedEvents()->first()->revision->value);
+ self::assertSame(2, $cart->peekEvents()->first()->revision->value);
}
public function testExplicitCartThrowsForUnregisteredEvent(): void
@@ -356,7 +356,7 @@ public function testExplicitCartThrowsForUnregisteredEvent(): void
$cartId = new CartId(value: 'cart-explicit-err');
/** @And an OrderPlaced record from a foreign aggregate */
- $orderRecords = Order::place(orderId: new OrderId(value: 'ord-err'), item: 'book')->recordedEvents();
+ $orderRecords = Order::place(orderId: new OrderId(value: 'ord-err'), item: 'book')->peekEvents();
/** @Then a LogicException naming the unregistered event should be thrown */
$this->expectException(LogicException::class);
@@ -369,7 +369,7 @@ public function testExplicitCartThrowsForUnregisteredEvent(): void
);
/** @When reconstituting ExplicitCart from the OrderPlaced records */
- ExplicitCart::reconstitute(identity: $cartId, records: $orderRecords);
+ ExplicitCart::reconstitute(records: $orderRecords, identity: $cartId);
}
public function testReconstituteThrowsWhenHandlerMethodIsMissing(): void
@@ -390,6 +390,6 @@ public function testReconstituteThrowsWhenHandlerMethodIsMissing(): void
);
/** @When reconstituting an aggregate without the handler */
- CartWithoutHandler::reconstitute(identity: $cartId, records: $original->recordedEvents());
+ CartWithoutHandler::reconstitute(records: $original->peekEvents(), identity: $cartId);
}
}
diff --git a/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
index a37daf4..651c6b2 100644
--- a/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
+++ b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php
@@ -7,6 +7,7 @@
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
+use Test\TinyBlocks\BuildingBlocks\Models\GuestReservation;
use Test\TinyBlocks\BuildingBlocks\Models\Order;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
@@ -14,6 +15,7 @@
use Test\TinyBlocks\BuildingBlocks\Models\Reservation;
use Test\TinyBlocks\BuildingBlocks\Models\ReservationId;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
+use TinyBlocks\BuildingBlocks\Exceptions\IncompleteAggregateState;
final class EventualAggregateRootBehaviorTest extends TestCase
{
@@ -53,7 +55,7 @@ public function testRecordedEventsCountMatchesEmittedEvents(): void
$order->ship(carrier: 'FedEx');
/** @When retrieving recorded events */
- $records = $order->recordedEvents();
+ $records = $order->peekEvents();
/** @Then the count matches the number of events */
self::assertSame(2, $records->count());
@@ -68,7 +70,7 @@ public function testFirstRecordedEventCarriesPlacementMetadata(): void
$order = Order::place(orderId: $orderId, item: 'chair');
/** @When inspecting the first recorded record */
- $record = $order->recordedEvents()->first();
+ $record = $order->peekEvents()->first();
/** @Then the envelope carries the placement metadata */
self::assertSame('OrderPlaced', $record->eventType->value);
@@ -89,7 +91,7 @@ public function testSecondRecordedEventCarriesShippingMetadata(): void
$order->ship(carrier: 'UPS');
/** @When inspecting the last recorded record */
- $record = $order->recordedEvents()->last();
+ $record = $order->peekEvents()->last();
/** @Then the envelope carries the shipping metadata */
self::assertSame('OrderShipped', $record->eventType->value);
@@ -104,10 +106,10 @@ public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void
$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());
+ $order->peekEvents()->merge(other: $order->peekEvents());
/** @When retrieving the recorded events again */
- $secondCopy = $order->recordedEvents();
+ $secondCopy = $order->peekEvents();
/** @Then the aggregate's own buffer is unaffected by the external mutation */
self::assertSame(1, $secondCopy->count());
@@ -119,13 +121,13 @@ public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void
$order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle');
/** @And the buffer drained without clearing, simulating a save that reads but does not reset */
- $firstBatch = $order->recordedEvents();
+ $firstBatch = $order->peekEvents();
/** @When a second operation emits a further event on the same instance */
$order->ship(carrier: 'DHL');
/** @Then the buffer accumulates events from both operations */
- self::assertSame(2, $order->recordedEvents()->count());
+ self::assertSame(2, $order->peekEvents()->count());
self::assertSame(1, $firstBatch->count());
}
@@ -135,8 +137,9 @@ public function testReconstituteRestoresIdentityWhenNoStateIsProvided(): void
$reservationId = new ReservationId(value: 'res-1');
/** @When reconstituting via the trait default with no state */
- $reservation = Reservation::reconstitute(
+ $reservation = Reservation::reconstitutePartial(
identity: $reservationId,
+ aggregateState: [],
aggregateVersion: AggregateVersion::of(value: 5)
);
@@ -144,29 +147,45 @@ public function testReconstituteRestoresIdentityWhenNoStateIsProvided(): void
self::assertTrue($reservation->identity()->equals(other: $reservationId));
}
+ public function testReconstituteInitializesEmptyRecordedEventsBuffer(): void
+ {
+ /** @Given a reservation reconstituted via the trait default */
+ $reservation = Reservation::reconstitutePartial(
+ identity: new ReservationId(value: 'res-1'),
+ aggregateState: [],
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+
+ /** @When retrieving the recorded events */
+ $records = $reservation->peekEvents();
+
+ /** @Then the buffer starts empty */
+ self::assertTrue($records->isEmpty());
+ }
+
public function testReconstituteRestoresAggregateVersionForNextEvent(): void
{
/** @Given a reservation reconstituted at version 5 with pending status */
- $reservation = Reservation::reconstitute(
+ $reservation = Reservation::reconstitutePartial(
identity: new ReservationId(value: 'res-1'),
- aggregateVersion: AggregateVersion::of(value: 5),
- state: ['status' => 'pending']
+ aggregateState: ['status' => 'pending'],
+ aggregateVersion: AggregateVersion::of(value: 5)
);
/** @When confirming the reservation */
$reservation->confirm();
/** @Then the next recorded event carries version 6 */
- self::assertSame(6, $reservation->recordedEvents()->first()->aggregateVersion->value);
+ self::assertSame(6, $reservation->peekEvents()->first()->aggregateVersion->value);
}
public function testReconstituteHydratesStateSoCommandsBehaveCorrectly(): void
{
/** @Given a reservation reconstituted in confirmed status */
- $reservation = Reservation::reconstitute(
+ $reservation = Reservation::reconstitutePartial(
identity: new ReservationId(value: 'res-1'),
- aggregateVersion: AggregateVersion::of(value: 5),
- state: ['status' => 'confirmed']
+ aggregateState: ['status' => 'confirmed'],
+ aggregateVersion: AggregateVersion::of(value: 5)
);
/** @Then a RuntimeException is raised because state was correctly restored */
@@ -182,10 +201,10 @@ public function testReconstituteSilentlyIgnoresUnknownStateKeys(): void
$reservationId = new ReservationId(value: 'res-1');
/** @When reconstituting with a state map carrying a key absent from the aggregate */
- $reservation = Reservation::reconstitute(
+ $reservation = Reservation::reconstitutePartial(
identity: $reservationId,
- aggregateVersion: AggregateVersion::of(value: 5),
- state: ['status' => 'pending', 'unknownProperty' => 'value']
+ aggregateState: ['status' => 'pending', 'unknownProperty' => 'value'],
+ aggregateVersion: AggregateVersion::of(value: 5)
);
/** @Then no exception is raised and the known identity is still restored */
@@ -201,14 +220,19 @@ public function testOrderReconstituteRejectsForeignIdentityType(): void
$this->expectException(InvalidArgumentException::class);
/** @When reconstituting an Order with a non-OrderId identity */
- Order::reconstitute(identity: $foreignIdentity, aggregateVersion: AggregateVersion::of(value: 1));
+ Order::reconstitutePartial(
+ identity: $foreignIdentity,
+ aggregateState: [],
+ aggregateVersion: AggregateVersion::of(value: 1)
+ );
}
public function testOrderReconstituteRestoresVersionForNextEvent(): void
{
/** @Given an Order reconstituted at version 9 */
- $order = Order::reconstitute(
+ $order = Order::reconstitutePartial(
identity: new OrderId(value: 'ord-rec-1'),
+ aggregateState: [],
aggregateVersion: AggregateVersion::of(value: 9)
);
@@ -216,9 +240,100 @@ public function testOrderReconstituteRestoresVersionForNextEvent(): void
$order->ship(carrier: 'DHL');
/** @When inspecting the recorded event */
- $record = $order->recordedEvents()->first();
+ $record = $order->peekEvents()->first();
/** @Then the event carries version 10 */
self::assertSame(10, $record->aggregateVersion->value);
}
+
+ public function testReconstituteStrictWhenAllRequiredStateProvidedThenMatchesLenientResult(): void
+ {
+ /** @Given a reservation reconstituted leniently with all required state */
+ $lenient = Reservation::reconstitutePartial(
+ identity: new ReservationId(value: 'res-1'),
+ aggregateState: ['status' => 'pending'],
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+
+ /** @When reconstituting the same reservation strictly */
+ $strict = Reservation::reconstituteStrict(
+ identity: new ReservationId(value: 'res-1'),
+ aggregateState: ['status' => 'pending'],
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+
+ /** @Then the strict result equals the lenient result */
+ self::assertEquals($lenient, $strict);
+ }
+
+ public function testReconstituteStrictWhenRequiredPropertyOmittedThenNamesItIncomplete(): void
+ {
+ /** @Given an identity for an aggregate reconstituted without its required state */
+ $reservationId = new ReservationId(value: 'res-1');
+
+ try {
+ /** @When reconstituting strictly with the required status omitted */
+ Reservation::reconstituteStrict(
+ identity: $reservationId,
+ aggregateState: [],
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+ } catch (IncompleteAggregateState $exception) {
+ /** @Then the exception names the uninitialized required property */
+ self::assertSame(['status'], $exception->propertyNames);
+
+ /** @And the message identifies that property */
+ self::assertStringContainsString('status', $exception->getMessage());
+ }
+ }
+
+ public function testReconstituteStrictWhenUnknownKeyProvidedThenStillSucceeds(): void
+ {
+ /** @Given an identity for an aggregate reconstituted with an unknown state key */
+ $reservationId = new ReservationId(value: 'res-1');
+
+ /** @When reconstituting strictly with all required state plus an unknown key */
+ $reservation = Reservation::reconstituteStrict(
+ identity: $reservationId,
+ aggregateState: ['status' => 'pending', 'unknownProperty' => 'value'],
+ aggregateVersion: AggregateVersion::of(value: 5)
+ );
+
+ /** @Then the unknown key is ignored and reconstitution succeeds */
+ self::assertTrue($reservation->identity()->equals(other: $reservationId));
+ }
+
+ public function testReconstituteStrictWhenPropertyHasDefaultThenNotFlagged(): void
+ {
+ /** @Given an identity for an Order whose status property carries a default */
+ $orderId = new OrderId(value: 'ord-strict-1');
+
+ /** @When reconstituting the Order strictly with no state */
+ $order = Order::reconstituteStrict(
+ identity: $orderId,
+ aggregateState: [],
+ aggregateVersion: AggregateVersion::of(value: 1)
+ );
+
+ /** @Then the defaulted status does not trigger an incomplete-state failure */
+ self::assertTrue($order->identity()->equals(other: $orderId));
+ }
+
+ public function testReconstituteStrictWhenMultipleRequiredPropertiesMissingThenNamesThemAll(): void
+ {
+ /** @Given an identity for an aggregate carrying two required properties */
+ $reservationId = new ReservationId(value: 'res-multi');
+
+ try {
+ /** @When reconstituting strictly with both required properties omitted */
+ GuestReservation::reconstituteStrict(
+ identity: $reservationId,
+ aggregateState: [],
+ aggregateVersion: AggregateVersion::of(value: 1)
+ );
+ } catch (IncompleteAggregateState $exception) {
+ /** @Then the exception names every uninitialized required property */
+ self::assertEqualsCanonicalizing(['status', 'guest'], $exception->propertyNames);
+ }
+ }
}
diff --git a/tests/Unit/Aggregate/ModelVersionTest.php b/tests/Unit/Aggregate/ModelVersionTest.php
index d10e859..a57e457 100644
--- a/tests/Unit/Aggregate/ModelVersionTest.php
+++ b/tests/Unit/Aggregate/ModelVersionTest.php
@@ -29,6 +29,18 @@ public function testOfReturnsVersionWithGivenValue(): void
self::assertSame(2, $version->value);
}
+ public function testValueReturnsTheBackingInteger(): void
+ {
+ /** @Given a model version of 2 */
+ $version = ModelVersion::of(value: 2);
+
+ /** @When retrieving its ordinal value */
+ $value = $version->value();
+
+ /** @Then the backing integer is returned */
+ self::assertSame(2, $value);
+ }
+
public function testEqualsReturnsTrueForSameValue(): void
{
/** @Given two model versions with the same value */
diff --git a/tests/Unit/Event/EventRecordTest.php b/tests/Unit/Event/EventRecordTest.php
index 763685b..f59e6d6 100644
--- a/tests/Unit/Event/EventRecordTest.php
+++ b/tests/Unit/Event/EventRecordTest.php
@@ -5,21 +5,19 @@
namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
-use Ramsey\Uuid\Uuid;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
-use TinyBlocks\BuildingBlocks\Event\EventType;
-use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
+use TinyBlocks\BuildingBlocks\Uuid;
final class EventRecordTest extends TestCase
{
- public function testEventRecordExposesEveryConstructorField(): void
+ public function testEventRecordExposesEveryField(): void
{
/** @Given an event identifier */
- $id = Uuid::uuid4();
+ $id = Uuid::generateV7();
/** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
@@ -27,45 +25,37 @@ public function testEventRecordExposesEveryConstructorField(): void
/** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
- /** @And the matching event type */
- $eventType = EventType::fromString(value: 'OrderPlaced');
-
- /** @And the initial revision */
- $revision = Revision::initial();
-
/** @And the occurrence timestamp */
- $occurredAt = Instant::now();
+ $occurredAt = Utc::now();
/** @And the first aggregate version */
$aggregateVersion = AggregateVersion::first();
- /** @When constructing the EventRecord */
- $record = new EventRecord(
- id: $id,
+ /** @When building the EventRecord via the factory */
+ $record = EventRecord::from(
event: $placedEvent,
- revision: $revision,
- eventType: $eventType,
- occurredAt: $occurredAt,
aggregateId: $orderId,
aggregateType: 'Order',
- aggregateVersion: $aggregateVersion
+ aggregateVersion: $aggregateVersion,
+ id: $id,
+ occurredAt: $occurredAt
);
/** @Then each public field is accessible with the expected value */
self::assertSame($id, $record->id);
- self::assertSame($eventType, $record->eventType);
self::assertSame($placedEvent, $record->event);
self::assertSame($orderId, $record->aggregateId);
- self::assertSame($revision, $record->revision);
+ self::assertSame(1, $record->revision->value);
self::assertSame($occurredAt, $record->occurredAt);
self::assertSame('Order', $record->aggregateType);
+ self::assertSame('OrderPlaced', $record->eventType->value);
self::assertSame($aggregateVersion, $record->aggregateVersion);
}
public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void
{
/** @Given an event identifier */
- $id = Uuid::uuid4();
+ $id = Uuid::generateV7();
/** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
@@ -73,40 +63,30 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void
/** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
- /** @And the matching event type */
- $eventType = EventType::fromString(value: 'OrderPlaced');
-
- /** @And the initial revision */
- $revision = Revision::initial();
-
/** @And the occurrence timestamp */
- $occurredAt = Instant::now();
+ $occurredAt = Utc::now();
/** @And the first aggregate version */
$aggregateVersion = AggregateVersion::first();
/** @And a first record built from those values */
- $first = new EventRecord(
- id: $id,
+ $first = EventRecord::from(
event: $placedEvent,
- revision: $revision,
- eventType: $eventType,
- occurredAt: $occurredAt,
aggregateId: $orderId,
aggregateType: 'Order',
- aggregateVersion: $aggregateVersion
+ aggregateVersion: $aggregateVersion,
+ id: $id,
+ occurredAt: $occurredAt
);
/** @And a second record built from the same values */
- $second = new EventRecord(
- id: $id,
+ $second = EventRecord::from(
event: $placedEvent,
- revision: $revision,
- eventType: $eventType,
- occurredAt: $occurredAt,
aggregateId: $orderId,
aggregateType: 'Order',
- aggregateVersion: $aggregateVersion
+ aggregateVersion: $aggregateVersion,
+ id: $id,
+ occurredAt: $occurredAt
);
/** @When comparing them */
@@ -124,40 +104,30 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
/** @And a domain event */
$placedEvent = new OrderPlaced(item: 'book');
- /** @And the matching event type */
- $eventType = EventType::fromString(value: 'OrderPlaced');
-
- /** @And the initial revision */
- $revision = Revision::initial();
-
/** @And the occurrence timestamp */
- $occurredAt = Instant::now();
+ $occurredAt = Utc::now();
/** @And the first aggregate version */
$aggregateVersion = AggregateVersion::first();
/** @And a first record with a unique identifier */
- $first = new EventRecord(
- id: Uuid::uuid4(),
+ $first = EventRecord::from(
event: $placedEvent,
- revision: $revision,
- eventType: $eventType,
- occurredAt: $occurredAt,
aggregateId: $orderId,
aggregateType: 'Order',
- aggregateVersion: $aggregateVersion
+ aggregateVersion: $aggregateVersion,
+ id: Uuid::generateV7(),
+ occurredAt: $occurredAt
);
/** @And a second record with a different identifier */
- $second = new EventRecord(
- id: Uuid::uuid4(),
+ $second = EventRecord::from(
event: $placedEvent,
- revision: $revision,
- eventType: $eventType,
- occurredAt: $occurredAt,
aggregateId: $orderId,
aggregateType: 'Order',
- aggregateVersion: $aggregateVersion
+ aggregateVersion: $aggregateVersion,
+ id: Uuid::generateV7(),
+ occurredAt: $occurredAt
);
/** @When comparing them */
@@ -167,7 +137,7 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void
self::assertFalse($areEqual);
}
- public function testOfFactoryBuildsRecordWithRequiredFields(): void
+ public function testFromFactoryBuildsRecordWithRequiredFields(): void
{
/** @Given an aggregate identity */
$orderId = new OrderId(value: 'ord-of-1');
@@ -179,7 +149,7 @@ public function testOfFactoryBuildsRecordWithRequiredFields(): void
$aggregateVersion = AggregateVersion::first();
/** @When building the record via the factory */
- $record = EventRecord::of(
+ $record = EventRecord::from(
event: $placedEvent,
aggregateId: $orderId,
aggregateType: 'Order',
@@ -195,10 +165,10 @@ public function testOfFactoryBuildsRecordWithRequiredFields(): void
self::assertSame($aggregateVersion, $record->aggregateVersion);
}
- public function testOfFactoryUsesProvidedOptionalFields(): void
+ public function testFromFactoryUsesProvidedOptionalFields(): void
{
/** @Given an explicit identifier */
- $id = Uuid::uuid4();
+ $id = Uuid::generateV7();
/** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-of-2');
@@ -207,13 +177,13 @@ public function testOfFactoryUsesProvidedOptionalFields(): void
$placedEvent = new OrderPlaced(item: 'pen');
/** @And an explicit occurrence timestamp */
- $occurredAt = Instant::now();
+ $occurredAt = Utc::now();
/** @And the first aggregate version */
$aggregateVersion = AggregateVersion::first();
/** @When building the record via the factory with all optional fields */
- $record = EventRecord::of(
+ $record = EventRecord::from(
event: $placedEvent,
aggregateId: $orderId,
aggregateType: 'Order',
diff --git a/tests/Unit/Event/EventRecordsTest.php b/tests/Unit/Event/EventRecordsTest.php
index d84065c..ea6dff8 100644
--- a/tests/Unit/Event/EventRecordsTest.php
+++ b/tests/Unit/Event/EventRecordsTest.php
@@ -5,15 +5,11 @@
namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
-use Ramsey\Uuid\Uuid;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\EventRecords;
-use TinyBlocks\BuildingBlocks\Event\EventType;
-use TinyBlocks\BuildingBlocks\Event\Revision;
-use TinyBlocks\Time\Instant;
final class EventRecordsTest extends TestCase
{
@@ -35,12 +31,8 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void
$records = EventRecords::createFromEmpty();
/** @And a freshly built event record */
- $record = new EventRecord(
- id: Uuid::uuid4(),
+ $record = EventRecord::from(
event: new OrderPlaced(item: 'book'),
- revision: Revision::initial(),
- eventType: EventType::fromString(value: 'OrderPlaced'),
- occurredAt: Instant::now(),
aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
aggregateVersion: AggregateVersion::first()
@@ -56,12 +48,8 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void
public function testFirstElementRoundTripsTheAddedRecord(): void
{
/** @Given a freshly built event record */
- $record = new EventRecord(
- id: Uuid::uuid4(),
+ $record = EventRecord::from(
event: new OrderPlaced(item: 'book'),
- revision: Revision::initial(),
- eventType: EventType::fromString(value: 'OrderPlaced'),
- occurredAt: Instant::now(),
aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
aggregateVersion: AggregateVersion::first()
diff --git a/tests/Unit/Event/EventTypeTest.php b/tests/Unit/Event/EventTypeTest.php
index a5c2111..8c0b621 100644
--- a/tests/Unit/Event/EventTypeTest.php
+++ b/tests/Unit/Event/EventTypeTest.php
@@ -27,7 +27,7 @@ public function testConstructorIsPrivate(): void
self::assertTrue($constructor->isPrivate());
}
- public function testFromDomainEventUsesTheShortClassNameOfTheDomainEvent(): void
+ public function testFromDomainEventUsesTheDeclaredEventTypeIdentifier(): void
{
/** @Given a domain event instance */
$placedEvent = new OrderPlaced(item: 'book');
@@ -35,7 +35,7 @@ public function testFromDomainEventUsesTheShortClassNameOfTheDomainEvent(): void
/** @When creating an EventType from the domain event */
$eventType = EventType::fromDomainEvent(event: $placedEvent);
- /** @Then the value matches the short class name */
+ /** @Then the value matches the event's declared type identifier */
self::assertSame('OrderPlaced', $eventType->value);
}
diff --git a/tests/Unit/Event/IntegrationEventRecordTest.php b/tests/Unit/Event/IntegrationEventRecordTest.php
index 149594f..548620f 100644
--- a/tests/Unit/Event/IntegrationEventRecordTest.php
+++ b/tests/Unit/Event/IntegrationEventRecordTest.php
@@ -5,7 +5,6 @@
namespace Test\TinyBlocks\BuildingBlocks\Unit\Event;
use PHPUnit\Framework\TestCase;
-use Ramsey\Uuid\Uuid;
use Test\TinyBlocks\BuildingBlocks\Models\OrderId;
use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced;
use Test\TinyBlocks\BuildingBlocks\Models\PaymentConfirmed;
@@ -13,26 +12,27 @@
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Event\EventRecord;
use TinyBlocks\BuildingBlocks\Event\IntegrationEventRecord;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
+use TinyBlocks\BuildingBlocks\Uuid;
final class IntegrationEventRecordTest extends TestCase
{
public function testFromReusesOriginatingRecordMetadataAndCarriesIntegrationEvent(): void
{
/** @Given an explicit event identifier */
- $id = Uuid::uuid4();
+ $id = Uuid::generateV7();
/** @And an aggregate identity */
$orderId = new OrderId(value: 'ord-1');
/** @And an explicit occurrence timestamp */
- $occurredAt = Instant::now();
+ $occurredAt = Utc::now();
/** @And the first aggregate version */
$aggregateVersion = AggregateVersion::first();
/** @And an event record built with explicit metadata */
- $eventRecord = EventRecord::of(
+ $eventRecord = EventRecord::from(
event: new OrderPlaced(item: 'book'),
aggregateId: $orderId,
aggregateType: 'Order',
@@ -67,7 +67,7 @@ public function testFromDerivesRevisionFromIntegrationEvent(): void
$domainEvent = new OrderPlaced(item: 'notebook');
/** @And an event record wrapping the domain event */
- $eventRecord = EventRecord::of(
+ $eventRecord = EventRecord::from(
event: $domainEvent,
aggregateId: new OrderId(value: 'ord-2'),
aggregateType: 'Order',
@@ -90,7 +90,7 @@ public function testFromDerivesRevisionFromIntegrationEvent(): void
public function testFromDerivesEventTypeFromIntegrationEventClassName(): void
{
/** @Given an event record wrapping an OrderPlaced domain event */
- $eventRecord = EventRecord::of(
+ $eventRecord = EventRecord::from(
event: new OrderPlaced(item: 'pen'),
aggregateId: new OrderId(value: 'ord-3'),
aggregateType: 'Order',
diff --git a/tests/Unit/Event/IntegrationEventTranslatorsTest.php b/tests/Unit/Event/IntegrationEventTranslatorsTest.php
index 1a20f87..0131e53 100644
--- a/tests/Unit/Event/IntegrationEventTranslatorsTest.php
+++ b/tests/Unit/Event/IntegrationEventTranslatorsTest.php
@@ -20,7 +20,7 @@ final class IntegrationEventTranslatorsTest extends TestCase
public function testFindForReturnsFirstMatchingTranslatorAmongMultiple(): void
{
/** @Given an event record for an OrderPlaced event */
- $record = EventRecord::of(
+ $record = EventRecord::from(
event: new OrderPlaced(item: 'book'),
aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
@@ -69,7 +69,7 @@ public function translate(EventRecord $record): IntegrationEvent
public function testFindForReturnsNullWhenNoTranslatorSupportsTheRecord(): void
{
/** @Given an event record */
- $record = EventRecord::of(
+ $record = EventRecord::from(
event: new OrderPlaced(item: 'book'),
aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
@@ -103,7 +103,7 @@ public function translate(EventRecord $record): IntegrationEvent
public function testFindForReturnsNullForEmptyCollection(): void
{
/** @Given an event record */
- $record = EventRecord::of(
+ $record = EventRecord::from(
event: new OrderPlaced(item: 'book'),
aggregateId: new OrderId(value: 'ord-1'),
aggregateType: 'Order',
diff --git a/tests/Unit/Event/RevisionTest.php b/tests/Unit/Event/RevisionTest.php
index 4413fb0..72fe097 100644
--- a/tests/Unit/Event/RevisionTest.php
+++ b/tests/Unit/Event/RevisionTest.php
@@ -50,6 +50,18 @@ public function testOfStoresTheMinimumValidValue(): void
self::assertSame(1, $revision->value);
}
+ public function testValueReturnsTheBackingInteger(): void
+ {
+ /** @Given a revision of 42 */
+ $revision = Revision::of(value: 42);
+
+ /** @When retrieving its ordinal value */
+ $value = $revision->value();
+
+ /** @Then the backing integer is returned */
+ self::assertSame(42, $value);
+ }
+
public function testEqualsReturnsTrueForSameRevision(): void
{
/** @Given two revisions with the same value */
diff --git a/tests/Unit/Internal/AggregateReflectionTest.php b/tests/Unit/Internal/AggregateReflectionTest.php
new file mode 100644
index 0000000..1112ebe
--- /dev/null
+++ b/tests/Unit/Internal/AggregateReflectionTest.php
@@ -0,0 +1,24 @@
+invoke($constructor->getDeclaringClass()->newInstanceWithoutConstructor());
+
+ /** @Then the constructor is private */
+ self::assertTrue($constructor->isPrivate());
+ }
+}
diff --git a/tests/Unit/Internal/ClassNameTest.php b/tests/Unit/Internal/ClassNameTest.php
new file mode 100644
index 0000000..4c1208b
--- /dev/null
+++ b/tests/Unit/Internal/ClassNameTest.php
@@ -0,0 +1,24 @@
+invoke($constructor->getDeclaringClass()->newInstanceWithoutConstructor());
+
+ /** @Then the constructor is private */
+ self::assertTrue($constructor->isPrivate());
+ }
+}
diff --git a/tests/Unit/Snapshot/SnapshotTest.php b/tests/Unit/Snapshot/SnapshotTest.php
index 9f78b87..5df9bdd 100644
--- a/tests/Unit/Snapshot/SnapshotTest.php
+++ b/tests/Unit/Snapshot/SnapshotTest.php
@@ -10,7 +10,7 @@
use Test\TinyBlocks\BuildingBlocks\Models\CartWithLogger;
use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion;
use TinyBlocks\BuildingBlocks\Snapshot\Snapshot;
-use TinyBlocks\Time\Instant;
+use TinyBlocks\BuildingBlocks\Utc;
final class SnapshotTest extends TestCase
{
@@ -62,7 +62,7 @@ public function testFromAggregateCapturesCreatedAt(): void
$snapshot = Snapshot::fromAggregate(aggregate: $cart);
/** @Then the createdAt timestamp is set */
- self::assertInstanceOf(Instant::class, $snapshot->createdAt());
+ self::assertInstanceOf(Utc::class, $snapshot->createdAt());
}
public function testFromAggregateCarriesDomainFieldsInState(): void
@@ -125,7 +125,7 @@ public function testRoundTripThroughSnapshotRestoresDomainState(): void
$snapshot = Snapshot::fromAggregate(aggregate: $original);
/** @When reconstituting from the snapshot */
- $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot);
+ $reconstituted = Cart::reconstitute(records: [], identity: $cartId, snapshot: $snapshot);
/** @Then the reconstituted aggregate carries the same domain state */
self::assertSame(['prod-roundtrip'], $reconstituted->productIds());
@@ -173,7 +173,7 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void
$aggregateVersion = AggregateVersion::first();
/** @And a known creation timestamp */
- $createdAt = Instant::now();
+ $createdAt = Utc::now();
/** @And the first snapshot built from those fields */
$first = Snapshot::restore(
@@ -206,7 +206,7 @@ public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void
$aggregateVersion = AggregateVersion::first();
/** @And a known creation timestamp */
- $createdAt = Instant::now();
+ $createdAt = Utc::now();
/** @And the first snapshot with type Cart */
$first = Snapshot::restore(
diff --git a/tests/Unit/Upcast/IntermediateEventTest.php b/tests/Unit/Upcast/IntermediateEventTest.php
index eb67bf6..8e36f13 100644
--- a/tests/Unit/Upcast/IntermediateEventTest.php
+++ b/tests/Unit/Upcast/IntermediateEventTest.php
@@ -12,7 +12,7 @@
final class IntermediateEventTest extends TestCase
{
- public function testIntermediateEventExposesEveryConstructorField(): void
+ public function testIntermediateEventExposesEveryField(): void
{
/** @Given an event type */
$eventType = EventType::fromString(value: 'ProductAdded');
@@ -23,8 +23,8 @@ public function testIntermediateEventExposesEveryConstructorField(): void
/** @And a serialized event payload */
$serializedEvent = ['productId' => 'prod-1'];
- /** @When constructing the intermediate event */
- $event = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $serializedEvent);
+ /** @When building the intermediate event via the factory */
+ $event = IntermediateEvent::from(type: $eventType, revision: $revision, serializedEvent: $serializedEvent);
/** @Then each public field is accessible */
self::assertSame($eventType, $event->type);
@@ -35,7 +35,7 @@ public function testIntermediateEventExposesEveryConstructorField(): void
public function testWithRevisionOnlyReplacesTheRevision(): void
{
/** @Given an intermediate event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -51,7 +51,7 @@ public function testWithRevisionOnlyReplacesTheRevision(): void
public function testWithRevisionPreservesTheTypeAndPayload(): void
{
/** @Given an intermediate event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -68,7 +68,7 @@ public function testWithRevisionPreservesTheTypeAndPayload(): void
public function testWithRevisionReturnsANewInstance(): void
{
/** @Given an intermediate event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -85,7 +85,7 @@ public function testWithRevisionReturnsANewInstance(): void
public function testWithSerializedEventOnlyReplacesThePayload(): void
{
/** @Given an intermediate event with an original payload */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -101,7 +101,7 @@ public function testWithSerializedEventOnlyReplacesThePayload(): void
public function testWithSerializedEventPreservesTheTypeAndRevision(): void
{
/** @Given an intermediate event with an original payload */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -127,10 +127,10 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void
$payload = ['productId' => 'prod-1'];
/** @And a first intermediate event built from those values */
- $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
+ $first = IntermediateEvent::from(type: $eventType, revision: $revision, serializedEvent: $payload);
/** @And a second intermediate event built from the same values */
- $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload);
+ $second = IntermediateEvent::from(type: $eventType, revision: $revision, serializedEvent: $payload);
/** @When comparing them */
$areEqual = $first->equals(other: $second);
@@ -148,10 +148,10 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void
$revision = Revision::initial();
/** @And a first event carrying payload a */
- $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']);
+ $first = IntermediateEvent::from(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']);
/** @And a second event carrying payload b */
- $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']);
+ $second = IntermediateEvent::from(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']);
/** @When comparing them */
$areEqual = $first->equals(other: $second);
@@ -166,14 +166,14 @@ public function testEqualsReturnsFalseWhenOnlyTypeDiffers(): void
$revision = Revision::initial();
/** @And differing only by type */
- $first = new IntermediateEvent(
+ $first = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: $revision,
serializedEvent: ['productId' => 'prod-1']
);
/** @And a counterpart with a different type */
- $second = new IntermediateEvent(
+ $second = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductRemoved'),
revision: $revision,
serializedEvent: ['productId' => 'prod-1']
@@ -192,14 +192,14 @@ public function testEqualsReturnsFalseWhenOnlyRevisionDiffers(): void
$eventType = EventType::fromString(value: 'ProductAdded');
/** @And differing only by revision */
- $first = new IntermediateEvent(
+ $first = IntermediateEvent::from(
type: $eventType,
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
/** @And a counterpart at a later revision */
- $second = new IntermediateEvent(
+ $second = IntermediateEvent::from(
type: $eventType,
revision: Revision::of(value: 2),
serializedEvent: ['productId' => 'prod-1']
@@ -215,7 +215,7 @@ public function testEqualsReturnsFalseWhenOnlyRevisionDiffers(): void
public function testEqualsReturnsFalseWhenOtherIsDifferentValueObjectType(): void
{
/** @Given an intermediate event */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -234,14 +234,14 @@ public function testEqualsReturnsFalseWhenOtherIsDifferentValueObjectType(): voi
public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void
{
/** @Given an existing intermediate event */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
);
/** @When reconstituting from an iterable of typed values */
- $restored = IntermediateEvent::fromIterable(iterable: [
+ $restored = IntermediateEvent::buildFrom(source: [
'type' => EventType::fromString(value: 'ProductAdded'),
'revision' => Revision::initial(),
'serializedEvent' => ['productId' => 'prod-1']
@@ -254,7 +254,7 @@ public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void
public function testToArraySerializesTypeAndRevisionToScalars(): void
{
/** @Given an intermediate event */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -272,7 +272,7 @@ public function testToArraySerializesTypeAndRevisionToScalars(): void
public function testToJsonSerializesToJsonString(): void
{
/** @Given an intermediate event */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
diff --git a/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
index ec7e298..7b6701c 100644
--- a/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
+++ b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php
@@ -15,7 +15,7 @@ final class SingleUpcasterBehaviorTest extends TestCase
public function testUpcastBumpsTheRevisionOfAMatchingEvent(): void
{
/** @Given a ProductAdded event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -31,7 +31,7 @@ public function testUpcastBumpsTheRevisionOfAMatchingEvent(): void
public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void
{
/** @Given a ProductAdded event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -47,7 +47,7 @@ public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void
public function testUpcastReturnsUnchangedEventForMismatchedType(): void
{
/** @Given an event whose type is not the one the upcaster handles */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'OrderPlaced'),
revision: Revision::initial(),
serializedEvent: ['item' => 'book']
@@ -63,7 +63,7 @@ public function testUpcastReturnsUnchangedEventForMismatchedType(): void
public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void
{
/** @Given a ProductAdded event at revision 2, past the upcaster's FROM_REVISION */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::of(value: 2),
serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
diff --git a/tests/Unit/Upcast/UpcastersTest.php b/tests/Unit/Upcast/UpcastersTest.php
index fa0c6ed..886bcbc 100644
--- a/tests/Unit/Upcast/UpcastersTest.php
+++ b/tests/Unit/Upcast/UpcastersTest.php
@@ -17,7 +17,7 @@ final class UpcastersTest extends TestCase
public function testEmptyChainReturnsEventUnchanged(): void
{
/** @Given an event at revision 1 */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -33,7 +33,7 @@ public function testEmptyChainReturnsEventUnchanged(): void
public function testSingleMatchingUpcasterTransformsEvent(): void
{
/** @Given an event at revision 1 eligible for V1 migration */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -53,7 +53,7 @@ public function testSingleMatchingUpcasterTransformsEvent(): void
public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void
{
/** @Given an event at revision 2 — past the V1 migration window */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::of(value: 2),
serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
@@ -72,7 +72,7 @@ public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void
public function testChainedUpcastersApplySequentially(): void
{
/** @Given an event at revision 1 eligible for both V1 and V2 migrations */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::initial(),
serializedEvent: ['productId' => 'prod-1']
@@ -92,7 +92,7 @@ public function testChainedUpcastersApplySequentially(): void
public function testOnlyMatchingUpcastersInChainApply(): void
{
/** @Given an event at revision 2 — only eligible for V2 migration */
- $event = new IntermediateEvent(
+ $event = IntermediateEvent::from(
type: EventType::fromString(value: 'ProductAdded'),
revision: Revision::of(value: 2),
serializedEvent: ['productId' => 'prod-1', 'quantity' => 1]
diff --git a/tests/Unit/UtcTest.php b/tests/Unit/UtcTest.php
new file mode 100644
index 0000000..77bbd23
--- /dev/null
+++ b/tests/Unit/UtcTest.php
@@ -0,0 +1,73 @@
+equals(other: $other);
+
+ /** @Then they are equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testEqualsWhenDifferentPointInTimeThenReturnsFalse(): void
+ {
+ /** @Given a moment in UTC */
+ $utc = Utc::fromIso8601(value: '2026-02-17T10:30:00+00:00');
+
+ /** @And another moment at a different point in time */
+ $other = Utc::fromIso8601(value: '2020-01-01T00:00:00+00:00');
+
+ /** @When comparing them */
+ $areEqual = $utc->equals(other: $other);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testNowThenProducesUtcThatRoundTripsThroughIso8601(): void
+ {
+ /** @When the current moment is created */
+ $utc = Utc::now();
+
+ /** @Then it round-trips through its ISO 8601 representation */
+ self::assertTrue($utc->equals(other: Utc::fromIso8601(value: $utc->toIso8601())));
+ }
+
+ public function testFromIso8601WhenValueIsNotValidThenThrowsInvalidUtc(): void
+ {
+ /** @Then an exception indicating an invalid instant should be thrown */
+ $this->expectException(InvalidUtc::class);
+ $this->expectExceptionMessage('Value is not a valid ISO 8601 instant.');
+
+ /** @When creating a moment from a value that is not a valid date-time */
+ Utc::fromIso8601(value: 'not-a-valid-instant');
+ }
+
+ public function testFromIso8601WhenGivenIso8601StringThenExposesSecondPrecision(): void
+ {
+ /** @Given an ISO 8601 date-time string without sub-second precision */
+ $value = '2026-02-17T10:30:00+00:00';
+
+ /** @When a moment is created from it and read back */
+ $iso = Utc::fromIso8601(value: $value)->toIso8601();
+
+ /** @Then it is exposed in UTC with second precision */
+ self::assertSame('2026-02-17T10:30:00+00:00', $iso);
+ }
+}
diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php
new file mode 100644
index 0000000..5b915e8
--- /dev/null
+++ b/tests/Unit/UuidTest.php
@@ -0,0 +1,110 @@
+equals(other: $other);
+
+ /** @Then they are equal */
+ self::assertTrue($areEqual);
+ }
+
+ public function testGenerateV7ThenProducesVersion7Identifier(): void
+ {
+ /** @When a new identifier is generated */
+ $uuid = Uuid::generateV7();
+
+ /** @Then its version is 7 */
+ self::assertSame(7, RamseyUuid::fromString(uuid: $uuid->toString())->getVersion());
+ }
+
+ public function testFromThenExposesCanonicalStringRepresentation(): void
+ {
+ /** @Given a canonical UUID string */
+ $value = self::IDENTIFIER;
+
+ /** @When an identifier is created from it */
+ $uuid = Uuid::from(value: $value);
+
+ /** @Then it exposes the same canonical string representation */
+ self::assertSame($value, $uuid->toString());
+ }
+
+ public function testFromWhenValueIsNotValidThenThrowsInvalidUuid(): void
+ {
+ /** @Then an exception indicating an invalid UUID should be thrown */
+ $this->expectException(InvalidUuid::class);
+ $this->expectExceptionMessage('Value is not a valid UUID.');
+
+ /** @When creating an identifier from a value that is not a valid UUID */
+ Uuid::from(value: 'not-a-valid-uuid');
+ }
+
+ public function testEqualsWhenDifferentIdentifierThenReturnsFalse(): void
+ {
+ /** @Given an identifier */
+ $uuid = Uuid::from(value: self::IDENTIFIER);
+
+ /** @And another identifier holding a different value */
+ $other = Uuid::from(value: self::OTHER_IDENTIFIER);
+
+ /** @When comparing them */
+ $areEqual = $uuid->equals(other: $other);
+
+ /** @Then they are not equal */
+ self::assertFalse($areEqual);
+ }
+
+ public function testFromV7WhenValueIsNotValidThenThrowsInvalidUuid(): void
+ {
+ /** @Then an exception indicating an invalid UUID should be thrown */
+ $this->expectException(InvalidUuid::class);
+ $this->expectExceptionMessage('Value is not a valid UUID.');
+
+ /** @When creating a version 7 identifier from a value that is not a valid UUID */
+ Uuid::fromV7(value: 'not-a-valid-uuid');
+ }
+
+ public function testFromV7WhenVersionIsNotSevenThenThrowsInvalidUuid(): void
+ {
+ /** @Then an exception indicating an invalid UUID should be thrown */
+ $this->expectException(InvalidUuid::class);
+ $this->expectExceptionMessage('Value is not a valid UUID.');
+
+ /** @When creating a version 7 identifier from a valid UUID whose version is not 7 */
+ Uuid::fromV7(value: self::VERSION_4_IDENTIFIER);
+ }
+
+ public function testFromV7WhenValueIsVersion7ThenExposesCanonicalStringRepresentation(): void
+ {
+ /** @Given a canonical version 7 UUID string */
+ $value = self::IDENTIFIER;
+
+ /** @When a version 7 identifier is created from it */
+ $uuid = Uuid::fromV7(value: $value);
+
+ /** @Then it exposes the same canonical string representation */
+ self::assertSame($value, $uuid->toString());
+ }
+}