From 8d2c1fa2013e88cd3ea49768827620421eee2ac1 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:46:40 -0300
Subject: [PATCH 1/8] chore: Revamp Claude Code agent tooling and contributor
guidelines.
---
.claude/CLAUDE.md | 16 -
.claude/rules/php-library-architecture.md | 10 +-
.claude/rules/php-library-code-style.md | 430 ++++++++++++--
.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 | 92 ++-
.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 | 7 +-
.github/copilot-instructions.md | 2 +-
.gitignore | 5 +
CLAUDE.md | 61 ++
33 files changed, 1967 insertions(+), 1037 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
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 997b294..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,7 +46,8 @@ 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
@@ -63,9 +70,9 @@ Verify every item before producing any PHP code. If any item fails, revise befor
**Terminal methods that change the receiver type** stay at the end of the chain regardless
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
+ 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
@@ -73,7 +80,7 @@ Verify every item before producing any PHP code. If any item fails, revise befor
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.
+ 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).
@@ -81,7 +88,7 @@ Verify every item before producing any PHP code. If any item fails, revise befor
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
+ 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,
@@ -92,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
@@ -107,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
@@ -122,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
@@ -146,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
@@ -154,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.
@@ -243,12 +289,22 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
### When required
-- Every method of an interface.
+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
@@ -257,10 +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/`.
-- 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,
@@ -273,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`,
@@ -282,7 +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/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
+- 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
@@ -478,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
@@ -487,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();
```
@@ -495,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 81fdfb8..30a329f 100644
--- a/.claude/rules/php-library-testing.md
+++ b/.claude/rules/php-library-testing.md
@@ -40,6 +40,8 @@ Verify every item before producing any test code. If any item fails, revise befo
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
@@ -51,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
@@ -60,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
@@ -73,6 +81,21 @@ Verify every item before producing any test code. If any item fails, revise befo
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)
Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments
@@ -119,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
@@ -205,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:
@@ -230,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:
@@ -321,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..1978d84 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
/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/.gitignore b/.gitignore
index 6107765..5896bc3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,10 @@ composer.lock
.php-cs-fixer.cache
.phpunit.result.cache
+# Local config overrides
+/phpstan.neon
+/infection.json
+
# Coverage and reports
build/
reports/
@@ -18,6 +22,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.
From 19ced4ab6ad55624d1c9a51f8749af0f4995b00c Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:46:48 -0300
Subject: [PATCH 2/8] ci: Rename the pull request concurrency group.
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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:
From 066e6629c240b8dfa75c825e8eb004ebce362c88 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:47:30 -0300
Subject: [PATCH 3/8] build: Migrate to tiny-blocks/mapper 3.0.0.
Replace the local path repository with the published 3.0.0 release and
adopt its renamed API (Serializable, Mappable, IterableMappable,
ElementType). Bump guzzlehttp/guzzle and phpstan/phpstan in the same
dependency pass.
---
composer.json | 6 ++---
src/Exceptions/BodyTypeIsUnsupported.php | 6 ++---
src/Internal/Server/Stream/StreamFactory.php | 4 ++--
tests/Models/Order.php | 8 +++----
tests/Models/Product.php | 8 +++----
tests/Models/Products.php | 24 ++++++++++++++------
tests/Unit/Server/ResponseTest.php | 2 +-
7 files changed, 34 insertions(+), 24 deletions(-)
diff --git a/composer.json b/composer.json
index 731a01d..ee10499 100644
--- a/composer.json
+++ b/composer.json
@@ -31,15 +31,15 @@
"psr/http-client": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
- "tiny-blocks/mapper": "^2.1"
+ "tiny-blocks/mapper": "^3.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
- "guzzlehttp/guzzle": "^7.10",
+ "guzzlehttp/guzzle": "^7.11",
"infection/infection": "^0.33",
"laminas/laminas-httphandlerrunner": "^2.13",
"nyholm/psr7": "^1.8",
- "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.1",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
diff --git a/src/Exceptions/BodyTypeIsUnsupported.php b/src/Exceptions/BodyTypeIsUnsupported.php
index 2d16a2f..59a21bf 100644
--- a/src/Exceptions/BodyTypeIsUnsupported.php
+++ b/src/Exceptions/BodyTypeIsUnsupported.php
@@ -9,14 +9,14 @@
/**
* Raised when an arbitrary object is passed as a response body.
*
- * Only Mapper, BackedEnum, UnitEnum, scalar types, arrays,
+ * Only Serializable, BackedEnum, UnitEnum, scalar types, arrays,
* and null are accepted. Passing a generic object (such as a domain entity or a value
- * object that does not implement Mapper) is rejected to prevent unintentional leakage
+ * object that does not implement Serializable) is rejected to prevent unintentional leakage
* of object internals as JSON.
*/
final class BodyTypeIsUnsupported extends InvalidArgumentException implements HttpException
{
- private const string REASON_TEMPLATE = 'Response body type <%s> is not supported. Use a Mapper, BackedEnum, '
+ private const string REASON_TEMPLATE = 'Response body type <%s> is not supported. Use a Serializable, BackedEnum, '
. 'UnitEnum, scalar, array, or null.';
private function __construct(string $class)
diff --git a/src/Internal/Server/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php
index f525ee9..30b6a6f 100644
--- a/src/Internal/Server/Stream/StreamFactory.php
+++ b/src/Internal/Server/Stream/StreamFactory.php
@@ -7,7 +7,7 @@
use BackedEnum;
use Psr\Http\Message\StreamInterface;
use TinyBlocks\Http\Exceptions\BodyTypeIsUnsupported;
-use TinyBlocks\Mapper\Mapper;
+use TinyBlocks\Mapper\Serializable;
use UnitEnum;
final readonly class StreamFactory
@@ -23,7 +23,7 @@ private function __construct(private string $body)
public static function fromBody(mixed $body): StreamFactory
{
$dataToWrite = match (true) {
- $body instanceof Mapper => $body->toJson(),
+ $body instanceof Serializable => $body->toJson(),
$body instanceof BackedEnum => StreamFactory::toJsonFrom(body: $body->value),
$body instanceof UnitEnum => $body->name,
is_object($body) => throw BodyTypeIsUnsupported::for(class: $body::class),
diff --git a/tests/Models/Order.php b/tests/Models/Order.php
index 4a99e76..d6d6b4c 100644
--- a/tests/Models/Order.php
+++ b/tests/Models/Order.php
@@ -4,12 +4,12 @@
namespace Test\TinyBlocks\Http\Models;
-use TinyBlocks\Mapper\ObjectMappability;
-use TinyBlocks\Mapper\ObjectMapper;
+use TinyBlocks\Mapper\Mappable;
+use TinyBlocks\Mapper\MappableBehavior;
-final readonly class Order implements ObjectMapper
+final readonly class Order implements Mappable
{
- use ObjectMappability;
+ use MappableBehavior;
public function __construct(public int $id, public Products $products)
{
diff --git a/tests/Models/Product.php b/tests/Models/Product.php
index 385c125..f9cce94 100644
--- a/tests/Models/Product.php
+++ b/tests/Models/Product.php
@@ -4,12 +4,12 @@
namespace Test\TinyBlocks\Http\Models;
-use TinyBlocks\Mapper\ObjectMappability;
-use TinyBlocks\Mapper\ObjectMapper;
+use TinyBlocks\Mapper\Mappable;
+use TinyBlocks\Mapper\MappableBehavior;
-final readonly class Product implements ObjectMapper
+final readonly class Product implements Mappable
{
- use ObjectMappability;
+ use MappableBehavior;
public function __construct(public string $name, public Amount $amount)
{
diff --git a/tests/Models/Products.php b/tests/Models/Products.php
index ea4af4b..85a5b87 100644
--- a/tests/Models/Products.php
+++ b/tests/Models/Products.php
@@ -6,14 +6,14 @@
use ArrayIterator;
use IteratorAggregate;
-use TinyBlocks\Mapper\IterableMappability;
-use TinyBlocks\Mapper\IterableMapper;
+use TinyBlocks\Mapper\ElementType;
+use TinyBlocks\Mapper\IterableMappable;
+use TinyBlocks\Mapper\Mapper;
use Traversable;
-final class Products implements IterableMapper, IteratorAggregate
+#[ElementType(Product::class)]
+final class Products implements IterableMappable, IteratorAggregate
{
- use IterableMappability;
-
private array $elements;
public function __construct(iterable $elements = [])
@@ -21,9 +21,19 @@ public function __construct(iterable $elements = [])
$this->elements = is_array($elements) ? array_values($elements) : iterator_to_array($elements, false);
}
- public function getType(): string
+ public static function createFrom(iterable $elements): static
+ {
+ return new static(elements: $elements);
+ }
+
+ public function toJson(): string
+ {
+ return Mapper::create()->toJson(source: $this);
+ }
+
+ public function toArray(): array
{
- return Product::class;
+ return Mapper::create()->toArray(source: $this);
}
public function getIterator(): Traversable
diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php
index 24ecd62..05ccea3 100644
--- a/tests/Unit/Server/ResponseTest.php
+++ b/tests/Unit/Server/ResponseTest.php
@@ -550,7 +550,7 @@ public function testFromWhenCodeAndBodyGivenThenRendersBodyWithMatchingStatus(
public function testOkWhenArbitraryObjectGivenThenThrowsBodyTypeIsUnsupported(): void
{
- /** @Given an arbitrary object that is not a Mapper, BackedEnum, or UnitEnum */
+ /** @Given an arbitrary object that is not a Serializable, BackedEnum, or UnitEnum */
$body = new Dragon(name: 'Drakengard Firestorm', weight: 6000.0);
/** @Then an exception indicating the body type is unsupported is thrown */
From fa6ca7a3eebae751d375a0fd93959e71d4361b86 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:47:44 -0300
Subject: [PATCH 4/8] feat: Add Link and LinkRelation value objects.
---
src/Exceptions/LinkUriIsInvalid.php | 37 ++++++++++
src/Link.php | 57 ++++++++++++++++
src/LinkRelation.php | 19 ++++++
tests/Unit/LinkTest.php | 102 ++++++++++++++++++++++++++++
4 files changed, 215 insertions(+)
create mode 100644 src/Exceptions/LinkUriIsInvalid.php
create mode 100644 src/Link.php
create mode 100644 src/LinkRelation.php
create mode 100644 tests/Unit/LinkTest.php
diff --git a/src/Exceptions/LinkUriIsInvalid.php b/src/Exceptions/LinkUriIsInvalid.php
new file mode 100644
index 0000000..2aab894
--- /dev/null
+++ b/src/Exceptions/LinkUriIsInvalid.php
@@ -0,0 +1,37 @@
+ is invalid. A link URI must be non-empty and free of CR, '
+ . 'LF, and angle brackets that would break the RFC 8288 link target.';
+
+ private function __construct(string $uri)
+ {
+ $template = LinkUriIsInvalid::REASON_TEMPLATE;
+
+ parent::__construct(message: sprintf($template, $uri));
+ }
+
+ /**
+ * Creates a LinkUriIsInvalid signaling that the given link URI cannot serve as an RFC 8288 link target.
+ *
+ * @param string $uri The offending link URI.
+ * @return LinkUriIsInvalid The composed exception describing the invalid link URI.
+ */
+ public static function for(string $uri): LinkUriIsInvalid
+ {
+ return new LinkUriIsInvalid(uri: $uri);
+ }
+}
diff --git a/src/Link.php b/src/Link.php
new file mode 100644
index 0000000..3e6cbd2
--- /dev/null
+++ b/src/Link.php
@@ -0,0 +1,57 @@
+and(uri: $uri, relation: $relation);
+ }
+
+ /**
+ * Appends another web link and returns a new Link with every existing link plus the appended one.
+ *
+ * @param string $uri The link target URI.
+ * @param LinkRelation $relation The relation type folded into the link value.
+ * @return Link A new Link carrying every existing web link followed by the appended one.
+ * @throws LinkUriIsInvalid If the URI is blank or carries a CR, LF, or angle bracket.
+ */
+ public function and(string $uri, LinkRelation $relation): Link
+ {
+ if (trim($uri) === '' || preg_match('/[\r\n<>]/', $uri) === 1) {
+ throw LinkUriIsInvalid::for(uri: $uri);
+ }
+
+ $template = '<%s>; rel="%s"';
+ $link = sprintf($template, $uri, $relation->value);
+
+ return new Link(links: [...$this->links, $link]);
+ }
+
+ public function toArray(): array
+ {
+ return ['Link' => [implode(', ', $this->links)]];
+ }
+}
diff --git a/src/LinkRelation.php b/src/LinkRelation.php
new file mode 100644
index 0000000..79c9caf
--- /dev/null
+++ b/src/LinkRelation.php
@@ -0,0 +1,19 @@
+expectException(LinkUriIsInvalid::class);
+ $this->expectExceptionMessage('is invalid');
+
+ /** @When starting a Link to that URI */
+ Link::to(uri: $uri, relation: LinkRelation::NEXT);
+ }
+
+ public function testOkWhenLinkGivenThenResponseCarriesFoldedLinkHeader(): void
+ {
+ /** @Given a Link to a single next-page target */
+ $link = Link::to(uri: 'https://api.example.com/items?page=2&per_page=20', relation: LinkRelation::NEXT);
+
+ /** @When building an OK response that carries the Link header */
+ $response = Response::ok(['data' => []], $link);
+
+ /** @Then the response exposes the folded Link header value */
+ self::assertSame(
+ '; rel="next"',
+ $response->getHeaderLine('Link')
+ );
+ }
+
+ public function testToArrayWhenSingleLinkGivenThenRendersFoldedLinkValue(): void
+ {
+ /** @Given a Link to a single next-page target */
+ $link = Link::to(uri: 'https://api.example.com/items?page=2&per_page=20', relation: LinkRelation::NEXT);
+
+ /** @When converting it to the header map */
+ $actual = $link->toArray();
+
+ /** @Then the map carries the single folded RFC 8288 link value */
+ self::assertSame(['Link' => ['; rel="next"']], $actual);
+ }
+
+ public function testToArrayWhenMultipleLinksGivenThenJoinsThemInInsertionOrder(): void
+ {
+ /** @Given a Link chaining every pagination target in navigation order */
+ $links = Link::to(uri: 'https://api.example.com/items?page=1', relation: LinkRelation::FIRST)
+ ->and(uri: 'https://api.example.com/items?page=4', relation: LinkRelation::PREVIOUS)
+ ->and(uri: 'https://api.example.com/items?page=6', relation: LinkRelation::NEXT)
+ ->and(uri: 'https://api.example.com/items?page=9', relation: LinkRelation::LAST);
+
+ /** @When converting it to the header map */
+ $actual = $links->toArray();
+
+ /** @Then every relation is folded and the links join with a comma in insertion order */
+ self::assertSame(['Link' => [implode(', ', [
+ '; rel="first"',
+ '; rel="prev"',
+ '; rel="next"',
+ '; rel="last"'
+ ])]], $actual);
+ }
+
+ public function testAndWhenInvokedThenReturnsNewInstanceLeavingTheOriginalUnchanged(): void
+ {
+ /** @Given a Link to a single self target */
+ $original = Link::to(uri: 'https://api.example.com/items?page=2', relation: LinkRelation::SELF);
+
+ /** @When appending another link */
+ $extended = $original->and(uri: 'https://api.example.com/items?page=3', relation: LinkRelation::NEXT);
+
+ /** @Then a new Link instance is returned */
+ self::assertNotSame($original, $extended);
+
+ /** @And the original Link is left unchanged */
+ self::assertSame(['Link' => ['; rel="self"']], $original->toArray());
+ }
+
+ public static function invalidUrisProvider(): array
+ {
+ return [
+ 'Empty string' => ['uri' => ''],
+ 'Whitespace only' => ['uri' => ' '],
+ 'Opening angle bracket' => ['uri' => 'https://api.example.com/ ['uri' => 'https://api.example.com/items>'],
+ 'Carriage return' => ['uri' => "https://api.example.com/items\r"],
+ 'Line feed' => ['uri' => "https://api.example.com/items\n"]
+ ];
+ }
+}
From 56c5553e8bf0c84b00783878834002eb4f782b03 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:47:44 -0300
Subject: [PATCH 5/8] feat: Add an orFail guard to the client response.
---
src/Client/Response.php | 20 +++++++
src/Exceptions/HttpResponseUnsuccessful.php | 60 ++++++++++++++++++++
tests/Unit/Client/ResponseTest.php | 61 +++++++++++++++++++++
3 files changed, 141 insertions(+)
create mode 100644 src/Exceptions/HttpResponseUnsuccessful.php
diff --git a/src/Client/Response.php b/src/Client/Response.php
index a214174..3e9bd73 100644
--- a/src/Client/Response.php
+++ b/src/Client/Response.php
@@ -7,6 +7,7 @@
use Psr\Http\Message\ResponseInterface;
use TinyBlocks\Http\Body;
use TinyBlocks\Http\Code;
+use TinyBlocks\Http\Exceptions\HttpResponseUnsuccessful;
use TinyBlocks\Http\Exceptions\SynthesizedResponseHasNoRaw;
use TinyBlocks\Http\Headers;
@@ -98,6 +99,25 @@ public function code(): Code
return $this->code;
}
+ /**
+ * Returns this response when its status is in the 2xx range, otherwise throws.
+ *
+ *
Opt-in, explicit alternative to checking {@see Response::isSuccess()} at the call site. A 4xx or
+ * 5xx is a well-formed response, not a transport failure, so the {@see Transport} contract still
+ * returns it. The thrown {@see HttpResponseUnsuccessful} carries the {@see Code} and {@see Body}.
+ *
+ * @return Response This response, when the status is successful.
+ * @throws HttpResponseUnsuccessful When the status is not in the 2xx range.
+ */
+ public function orFail(): Response
+ {
+ if ($this->isSuccess()) {
+ return $this;
+ }
+
+ throw HttpResponseUnsuccessful::from(code: $this->code, body: $this->body);
+ }
+
/**
* Returns the headers.
*
diff --git a/src/Exceptions/HttpResponseUnsuccessful.php b/src/Exceptions/HttpResponseUnsuccessful.php
new file mode 100644
index 0000000..0f4292c
--- /dev/null
+++ b/src/Exceptions/HttpResponseUnsuccessful.php
@@ -0,0 +1,60 @@
+This is not a transport failure: the request was sent and a well-formed response came back. The
+ * exception carries the {@see Code} and the decoded {@see Body} so the caller can branch on the status
+ * and inspect the payload in one place, then map it to its own domain exception.
+ */
+final class HttpResponseUnsuccessful extends RuntimeException implements HttpException
+{
+ private const string REASON_TEMPLATE = 'HTTP response returned a non-success status: %d %s.';
+
+ private function __construct(private readonly Body $body, private readonly Code $statusCode)
+ {
+ $template = HttpResponseUnsuccessful::REASON_TEMPLATE;
+
+ parent::__construct(message: sprintf($template, $statusCode->value, $statusCode->message()));
+ }
+
+ /**
+ * Creates an HttpResponseUnsuccessful from the response status code and decoded body.
+ *
+ * @param Code $code The non-success status code carried by the response.
+ * @param Body $body The decoded response body, preserved for inspection by the caller.
+ * @return HttpResponseUnsuccessful The composed exception describing the unsuccessful response.
+ */
+ public static function from(Code $code, Body $body): HttpResponseUnsuccessful
+ {
+ return new HttpResponseUnsuccessful(body: $body, statusCode: $code);
+ }
+
+ /**
+ * Returns the decoded response body preserved for inspection.
+ *
+ * @return Body The decoded body of the unsuccessful response.
+ */
+ public function body(): Body
+ {
+ return $this->body;
+ }
+
+ /**
+ * Returns the non-success status code carried by the response.
+ *
+ * @return Code The status code that triggered the failure.
+ */
+ public function code(): Code
+ {
+ return $this->statusCode;
+ }
+}
diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php
index 84c92c7..f4b9639 100644
--- a/tests/Unit/Client/ResponseTest.php
+++ b/tests/Unit/Client/ResponseTest.php
@@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use TinyBlocks\Http\Client\Response;
use TinyBlocks\Http\Code;
+use TinyBlocks\Http\Exceptions\HttpResponseUnsuccessful;
use TinyBlocks\Http\Exceptions\SynthesizedResponseHasNoRaw;
use TinyBlocks\Http\Headers;
@@ -241,4 +242,64 @@ public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponse
/** @When calling raw() */
$response->raw();
}
+
+ public function testOrFailWhenSuccessfulResponseGivenThenReturnsSameInstance(): void
+ {
+ /** @Given a successful synthesized response */
+ $response = Response::with(code: Code::OK);
+
+ /** @When requiring a successful status via orFail */
+ $actual = $response->orFail();
+
+ /** @Then the same response instance is returned */
+ self::assertSame($response, $actual);
+ }
+
+ public function testOrFailWhenClientErrorResponseGivenThenThrowsHttpResponseUnsuccessful(): void
+ {
+ /** @Given a client-error synthesized response */
+ $response = Response::with(code: Code::BAD_REQUEST);
+
+ /** @Then an exception carrying the formatted status message is thrown */
+ $this->expectException(HttpResponseUnsuccessful::class);
+ $this->expectExceptionMessage('HTTP response returned a non-success status: 400 Bad Request.');
+
+ /** @When requiring a successful status via orFail */
+ $response->orFail();
+ }
+
+ public function testOrFailWhenServerErrorResponseGivenThenThrowsHttpResponseUnsuccessful(): void
+ {
+ /** @Given a server-error synthesized response */
+ $response = Response::with(code: Code::INTERNAL_SERVER_ERROR);
+
+ /** @Then an exception signaling the unsuccessful response is thrown */
+ $this->expectException(HttpResponseUnsuccessful::class);
+
+ /** @When requiring a successful status via orFail */
+ $response->orFail();
+ }
+
+ public function testOrFailWhenUnsuccessfulResponseGivenThenExceptionCarriesCodeAndBody(): void
+ {
+ /** @Given an unsuccessful status code */
+ $code = Code::UNPROCESSABLE_ENTITY;
+
+ /** @And a decoded error body */
+ $body = ['error' => 'Invalid payload.'];
+
+ /** @And a synthesized response carrying that code and body */
+ $response = Response::with(code: $code, body: $body);
+
+ try {
+ /** @When requiring a successful status via orFail */
+ $response->orFail();
+ } catch (HttpResponseUnsuccessful $exception) {
+ /** @Then the exception carries the response status code */
+ self::assertSame($code, $exception->code());
+
+ /** @And the exception carries the decoded response body */
+ self::assertSame($body['error'], $exception->body()->get(key: 'error')->toString());
+ }
+ }
}
From 65fef43b25a681b16d7dc32794aca2edb634fd9c Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:48:36 -0300
Subject: [PATCH 6/8] feat: Add default request headers and typed message
accessors.
- Add HttpBuilder::withDefaultHeaders, merged under per-request headers.
- Add typed header, query, and raw-body accessors on server requests.
- Add Code timeout predicates and ContentType media-type accessors.
---
src/Client/Transports/NetworkTransport.php | 2 +-
src/Code.php | 29 ++++
src/ContentType.php | 24 ++-
src/Headers.php | 20 ++-
src/Http.php | 19 ++-
src/HttpBuilder.php | 37 ++++-
src/Internal/Client/RequestResolver.php | 7 +-
src/Server/Decoded/Uri.php | 21 +++
src/Server/Request.php | 49 ++++++
tests/Unit/CodeTest.php | 44 ++++++
tests/Unit/ContentTypeTest.php | 85 +++++++++++
tests/Unit/HeadersTest.php | 37 +++++
tests/Unit/HttpBuilderTest.php | 44 +++++-
tests/Unit/HttpTest.php | 114 ++++++++++++++
tests/Unit/Server/RequestTest.php | 168 +++++++++++++++++++++
15 files changed, 673 insertions(+), 27 deletions(-)
create mode 100644 tests/Unit/ContentTypeTest.php
diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php
index f543a25..275ca51 100644
--- a/src/Client/Transports/NetworkTransport.php
+++ b/src/Client/Transports/NetworkTransport.php
@@ -57,7 +57,7 @@ public function send(Request $request): Response
if (!is_null($body)) {
$encoded = json_encode($body, NetworkTransport::JSON_FLAGS);
- $psrRequest = $psrRequest->withBody(body: $this->factory->createStream($encoded));
+ $psrRequest = $psrRequest->withBody($this->factory->createStream($encoded));
}
try {
diff --git a/src/Code.php b/src/Code.php
index 38745f1..99c8d4a 100644
--- a/src/Code.php
+++ b/src/Code.php
@@ -125,6 +125,21 @@ public static function isSuccessCode(int $code): bool
return $code >= Code::OK->value && $code <= Code::IM_USED->value;
}
+ /**
+ * Resolves a Code from a nullable status code.
+ *
+ * @param int|null $code The HTTP status code to resolve, or null when absent.
+ * @return Code|null The matching Code, or null when the input is null or not represented.
+ */
+ public static function tryFromNullable(?int $code): ?Code
+ {
+ if (is_null($code)) {
+ return null;
+ }
+
+ return Code::tryFrom($code);
+ }
+
/**
* Tells whether the status code falls in the 4xx or 5xx range.
*
@@ -165,6 +180,20 @@ public function isSuccess(): bool
return Code::isSuccessCode(code: $this->value);
}
+ /**
+ * Tells whether the status code represents a request or gateway timeout.
+ *
+ * @return bool True when the code is 408 Request Timeout or 504 Gateway Timeout.
+ */
+ public function isTimeout(): bool
+ {
+ return match ($this) {
+ Code::REQUEST_TIMEOUT,
+ Code::GATEWAY_TIMEOUT => true,
+ default => false
+ };
+ }
+
/**
* Tells whether the status code falls in the 4xx range.
*
diff --git a/src/ContentType.php b/src/ContentType.php
index 8743fa3..d54ebf1 100644
--- a/src/ContentType.php
+++ b/src/ContentType.php
@@ -82,13 +82,33 @@ public static function applicationFormUrlencoded(?Charset $charset = null): Cont
}
public function toArray(): array
+ {
+ return ['Content-Type' => [$this->toString()]];
+ }
+
+ /**
+ * Returns the media type as a typed enum, without charset parameters.
+ *
+ * @return MimeType The MIME type carried by this Content-Type.
+ */
+ public function mimeType(): MimeType
+ {
+ return $this->mimeType;
+ }
+
+ /**
+ * Returns the ContentType as a Content-Type header value.
+ *
+ * @return string The media type, with the charset parameter appended when present.
+ */
+ public function toString(): string
{
if (is_null($this->charset)) {
- return ['Content-Type' => [$this->mimeType->value]];
+ return $this->mimeType->value;
}
$template = '%s; %s';
- return ['Content-Type' => [sprintf($template, $this->mimeType->value, $this->charset->toString())]];
+ return sprintf($template, $this->mimeType->value, $this->charset->toString());
}
}
diff --git a/src/Headers.php b/src/Headers.php
index 5b73bee..a7dc666 100644
--- a/src/Headers.php
+++ b/src/Headers.php
@@ -147,11 +147,8 @@ public function with(string $name, string $value): Headers
$key = strtolower($name);
$entries = $this->entries;
- if (isset($this->lowerIndex[$key])) {
- $entries[$this->lowerIndex[$key]] = $value;
- } else {
- $entries[$name] = $value;
- }
+ $canonical = $this->lowerIndex[$key] ?? $name;
+ $entries[$canonical] = $value;
return Headers::fromArray(entries: $entries);
}
@@ -184,6 +181,19 @@ public function toArray(): array
return $this->entries;
}
+ /**
+ * Returns the header value wrapped as a typed Attribute, or null when no entry matches.
+ *
+ * @param string $name The header name to look up, case-insensitively.
+ * @return Attribute|null The Attribute wrapping the folded value, or null when absent.
+ */
+ public function attribute(string $name): ?Attribute
+ {
+ $value = $this->get(name: $name);
+
+ return is_null($value) ? null : Attribute::from(value: $value);
+ }
+
/**
* Returns a copy of these Headers merged with another instance, with existing entries winning on collision.
*
diff --git a/src/Http.php b/src/Http.php
index 583edf0..cd1fb1f 100644
--- a/src/Http.php
+++ b/src/Http.php
@@ -26,26 +26,31 @@
{
private RequestResolver $resolver;
- private function __construct(string $baseUrl, private Transport $transport)
+ private function __construct(string $baseUrl, private Transport $transport, ?Headers $defaultHeaders)
{
$baseUrl = BaseUrl::from(value: $baseUrl);
- $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl->toString());
+ $this->resolver = RequestResolver::withBaseUrl(
+ baseUrl: $baseUrl->toString(),
+ defaultHeaders: $defaultHeaders ?? Headers::empty()
+ );
}
/**
* Creates an Http instance directly from a base URL and transport.
*
- * Explicit single-call alternative to the fluent builder returned by
- * create(). Both arguments are required.
+ *
Explicit single-call alternative to the fluent builder returned by create(). The base
+ * URL and transport are required. Default headers are optional and merged beneath the
+ * per-request headers on every request.
*
* @param string $baseUrl The absolute base URL prepended to every request path.
* @param Transport $transport The transport that delivers resolved requests.
+ * @param Headers|null $defaultHeaders The headers merged under every request, or null for none.
* @return Http A configured Http facade.
* @throws BaseUrlIsInvalid If the base URL is not an accepted form.
*/
- public static function with(string $baseUrl, Transport $transport): Http
+ public static function with(string $baseUrl, Transport $transport, ?Headers $defaultHeaders = null): Http
{
- return new Http(baseUrl: $baseUrl, transport: $transport);
+ return new Http(baseUrl: $baseUrl, transport: $transport, defaultHeaders: $defaultHeaders);
}
/**
@@ -58,7 +63,7 @@ public static function with(string $baseUrl, Transport $transport): Http
*/
public static function create(): HttpBuilder
{
- return new HttpBuilder(baseUrl: null, transport: null);
+ return new HttpBuilder(baseUrl: null, transport: null, defaultHeaders: null);
}
/**
diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php
index 4a34152..05481e0 100644
--- a/src/HttpBuilder.php
+++ b/src/HttpBuilder.php
@@ -17,8 +17,11 @@
*/
final readonly class HttpBuilder
{
- public function __construct(private ?string $baseUrl, private ?Transport $transport)
- {
+ public function __construct(
+ private ?string $baseUrl,
+ private ?Transport $transport,
+ private ?Headers $defaultHeaders
+ ) {
}
/**
@@ -40,7 +43,11 @@ public function build(): Http
throw HttpConfigurationInvalid::missingBaseUrl();
}
- return Http::with(baseUrl: $this->baseUrl, transport: $this->transport);
+ return Http::with(
+ baseUrl: $this->baseUrl,
+ transport: $this->transport,
+ defaultHeaders: $this->defaultHeaders ?? Headers::empty()
+ );
}
/**
@@ -53,8 +60,12 @@ public function build(): Http
*/
public function withBaseUrl(string $url): HttpBuilder
{
- $baseUrl = BaseUrl::from($url);
- return new HttpBuilder(baseUrl: $baseUrl->toString(), transport: $this->transport);
+ $baseUrl = BaseUrl::from(value: $url);
+ return new HttpBuilder(
+ baseUrl: $baseUrl->toString(),
+ transport: $this->transport,
+ defaultHeaders: $this->defaultHeaders
+ );
}
/**
@@ -65,6 +76,20 @@ public function withBaseUrl(string $url): HttpBuilder
*/
public function withTransport(Transport $transport): HttpBuilder
{
- return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport);
+ return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport, defaultHeaders: $this->defaultHeaders);
+ }
+
+ /**
+ * Returns a new builder carrying the given default headers.
+ *
+ *
The default headers are merged beneath the per-request headers on every request, after
+ * the JSON defaults. A header set explicitly on a request always wins over a default.
+ *
+ * @param Headers $headers The default headers applied to every request.
+ * @return HttpBuilder A new builder instance.
+ */
+ public function withDefaultHeaders(Headers $headers): HttpBuilder
+ {
+ return new HttpBuilder(baseUrl: $this->baseUrl, transport: $this->transport, defaultHeaders: $headers);
}
}
diff --git a/src/Internal/Client/RequestResolver.php b/src/Internal/Client/RequestResolver.php
index 06dfcc9..589d344 100644
--- a/src/Internal/Client/RequestResolver.php
+++ b/src/Internal/Client/RequestResolver.php
@@ -17,13 +17,13 @@
'Content-Type' => 'application/json'
];
- private function __construct(private string $baseUrl)
+ private function __construct(private string $baseUrl, private Headers $defaultHeaders)
{
}
- public static function withBaseUrl(string $baseUrl): RequestResolver
+ public static function withBaseUrl(string $baseUrl, Headers $defaultHeaders): RequestResolver
{
- return new RequestResolver(baseUrl: $baseUrl);
+ return new RequestResolver(baseUrl: $baseUrl, defaultHeaders: $defaultHeaders);
}
public function resolve(Request $request): Request
@@ -40,6 +40,7 @@ public function resolve(Request $request): Request
return $request
->withUrl(url: $url)
+ ->withMergedHeaders(defaults: $this->defaultHeaders)
->withMergedHeaders(defaults: Headers::fromArray(entries: RequestResolver::JSON_DEFAULTS))
->withQueryParameters(queryParameters: null);
}
diff --git a/src/Server/Decoded/Uri.php b/src/Server/Decoded/Uri.php
index aa7047e..2f56c5c 100644
--- a/src/Server/Decoded/Uri.php
+++ b/src/Server/Decoded/Uri.php
@@ -56,6 +56,27 @@ public function get(string $key): Attribute
return Attribute::from(value: $attributeValue);
}
+ /**
+ * Returns the named route attributes wrapped as typed Attributes, keyed by name.
+ *
+ *
Each key is resolved through the same route resolution as {@see Uri::get()}, so an absent key
+ * yields an Attribute wrapping null rather than being omitted. Only the named keys are resolved,
+ * keeping the result deterministic and avoiding leaking unrelated PSR-7 request attributes.
+ *
+ * @param array $keys The route attribute keys to resolve.
+ * @return array A map from each requested key to its resolved Attribute.
+ */
+ public function only(array $keys): array
+ {
+ $resolved = [];
+
+ foreach ($keys as $key) {
+ $resolved[$key] = $this->get(key: $key);
+ }
+
+ return $resolved;
+ }
+
/**
* Returns a copy of the Uri scoped to a different route attribute name.
*
diff --git a/src/Server/Request.php b/src/Server/Request.php
index 7747662..fff9f4e 100644
--- a/src/Server/Request.php
+++ b/src/Server/Request.php
@@ -5,9 +5,13 @@
namespace TinyBlocks\Http\Server;
use Psr\Http\Message\ServerRequestInterface;
+use TinyBlocks\Http\Attribute;
+use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Internal\Server\Request\Decoder;
+use TinyBlocks\Http\Internal\Server\Stream\StreamFactory;
use TinyBlocks\Http\Method;
use TinyBlocks\Http\Server\Decoded\DecodedRequest;
+use TinyBlocks\Http\Server\Decoded\QueryParameters;
/**
* Typed wrapper around an incoming PSR-7 server request.
@@ -31,6 +35,16 @@ public static function from(ServerRequestInterface $request): Request
return new Request(request: $request);
}
+ /**
+ * Returns the query parameters carried by the request URI.
+ *
+ * @return QueryParameters The QueryParameters value object built from the request.
+ */
+ public function query(): QueryParameters
+ {
+ return QueryParameters::from(request: $this->request);
+ }
+
/**
* Decodes the PSR-7 server request into a typed view of URI and body.
*
@@ -41,6 +55,17 @@ public function decode(): DecodedRequest
return Decoder::from(request: $this->request)->decode();
}
+ /**
+ * Returns a single header value wrapped as a typed Attribute, or null when absent.
+ *
+ * @param string $name The header name to look up, case-insensitively.
+ * @return Attribute|null The Attribute wrapping the folded value, or null when the header is absent.
+ */
+ public function header(string $name): ?Attribute
+ {
+ return $this->headers()->attribute(name: $name);
+ }
+
/**
* Returns the HTTP method as a typed enum.
*
@@ -50,4 +75,28 @@ public function method(): Method
{
return Method::from($this->request->getMethod());
}
+
+ /**
+ * Returns the headers carried by the request.
+ *
+ * @return Headers The headers folded into a case-insensitive collection.
+ */
+ public function headers(): Headers
+ {
+ return Headers::fromMessage(message: $this->request);
+ }
+
+ /**
+ * Returns the raw, undecoded request body exactly as received.
+ *
+ *
The body is read without JSON decoding, preserving the exact bytes required to verify a
+ * signature computed over the raw payload. Seekable streams are rewound, so a later call to
+ * {@see Request::decode()} still observes the full body.
+ *
+ * @return string The raw request body, or an empty string when the body is empty.
+ */
+ public function rawBody(): string
+ {
+ return StreamFactory::fromStream(stream: $this->request->getBody())->content();
+ }
}
diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php
index 2616561..6f1a2cd 100644
--- a/tests/Unit/CodeTest.php
+++ b/tests/Unit/CodeTest.php
@@ -91,6 +91,17 @@ public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code,
self::assertSame($expected, $actual);
}
+ #[DataProvider('nullableCodesDataProvider')]
+ public function testTryFromNullableWhenIntegerOrNullGivenThenResolvesExpected(?int $code, ?Code $expected): void
+ {
+ /** @Given a nullable HTTP status code */
+ /** @When resolving it through tryFromNullable */
+ $actual = Code::tryFromNullable(code: $code);
+
+ /** @Then the resolved Code matches the expected value */
+ self::assertSame($expected, $actual);
+ }
+
#[DataProvider('messagesDataProvider')]
public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void
{
@@ -102,6 +113,17 @@ public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $cod
self::assertSame($expected, $actual);
}
+ #[DataProvider('timeoutCodesDataProvider')]
+ public function testIsTimeoutWhenCodeGivenThenReturnsExpected(Code $code, bool $expected): void
+ {
+ /** @Given an HTTP status code */
+ /** @When checking whether it represents a request or gateway timeout */
+ $actual = $code->isTimeout();
+
+ /** @Then the result matches the expected boolean */
+ self::assertSame($expected, $actual);
+ }
+
public function testIsClientErrorWhenCodeBadRequestGivenThenReturnsTrue(): void
{
/** @Given Code::BAD_REQUEST */
@@ -335,4 +357,26 @@ public static function successCodesDataProvider(): array
'Code 500 Internal Server Error' => ['code' => 500, 'expected' => false]
];
}
+
+ public static function timeoutCodesDataProvider(): array
+ {
+ return [
+ 'Code 408 Request Timeout' => ['code' => Code::REQUEST_TIMEOUT, 'expected' => true],
+ 'Code 504 Gateway Timeout' => ['code' => Code::GATEWAY_TIMEOUT, 'expected' => true],
+ 'Code 200 OK' => ['code' => Code::OK, 'expected' => false],
+ 'Code 400 Bad Request' => ['code' => Code::BAD_REQUEST, 'expected' => false],
+ 'Code 500 Internal Server Error' => ['code' => Code::INTERNAL_SERVER_ERROR, 'expected' => false],
+ 'Code 502 Bad Gateway' => ['code' => Code::BAD_GATEWAY, 'expected' => false]
+ ];
+ }
+
+ public static function nullableCodesDataProvider(): array
+ {
+ return [
+ 'Null code' => ['code' => null, 'expected' => null],
+ 'Unrepresented code 250' => ['code' => 250, 'expected' => null],
+ 'Represented code 200 OK' => ['code' => 200, 'expected' => Code::OK],
+ 'Represented code 500 Server Error' => ['code' => 500, 'expected' => Code::INTERNAL_SERVER_ERROR]
+ ];
+ }
}
diff --git a/tests/Unit/ContentTypeTest.php b/tests/Unit/ContentTypeTest.php
new file mode 100644
index 0000000..c02e628
--- /dev/null
+++ b/tests/Unit/ContentTypeTest.php
@@ -0,0 +1,85 @@
+toArray();
+
+ /** @Then the map carries the bare media type */
+ self::assertSame(['Content-Type' => ['application/json']], $actual);
+ }
+
+ public function testToStringWhenNoCharsetGivenThenReturnsBareMediaType(): void
+ {
+ /** @Given a ContentType without a charset */
+ $contentType = ContentType::applicationJson();
+
+ /** @When rendering it as a header value */
+ $actual = $contentType->toString();
+
+ /** @Then the bare media type is returned */
+ self::assertSame('application/json', $actual);
+ }
+
+ public function testToStringWhenCharsetGivenThenAppendsCharsetParameter(): void
+ {
+ /** @Given a ContentType carrying a charset */
+ $contentType = ContentType::applicationJson(charset: Charset::UTF_8);
+
+ /** @When rendering it as a header value */
+ $actual = $contentType->toString();
+
+ /** @Then the charset parameter is appended to the media type */
+ self::assertSame('application/json; charset=utf-8', $actual);
+ }
+
+ public function testToArrayWhenCharsetGivenThenReturnsHeaderMapWithCharset(): void
+ {
+ /** @Given a ContentType carrying a charset */
+ $contentType = ContentType::applicationJson(charset: Charset::UTF_8);
+
+ /** @When converting it to the header map */
+ $actual = $contentType->toArray();
+
+ /** @Then the map carries the media type with the charset parameter */
+ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual);
+ }
+
+ public function testMimeTypeWhenJsonContentTypeGivenThenReturnsApplicationJsonMimeType(): void
+ {
+ /** @Given a ContentType for application/json */
+ $contentType = ContentType::applicationJson();
+
+ /** @When asking for its MIME type */
+ $actual = $contentType->mimeType();
+
+ /** @Then the application/json MIME type is returned */
+ self::assertSame(MimeType::APPLICATION_JSON, $actual);
+ }
+
+ public function testMimeTypeWhenCharsetGivenThenReturnsMimeTypeWithoutCharset(): void
+ {
+ /** @Given a ContentType for text/plain carrying a charset */
+ $contentType = ContentType::textPlain(charset: Charset::UTF_8);
+
+ /** @When asking for its MIME type */
+ $actual = $contentType->mimeType();
+
+ /** @Then the bare text/plain MIME type is returned regardless of the charset */
+ self::assertSame(MimeType::TEXT_PLAIN, $actual);
+ }
+}
diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php
index 155ac9d..d2ecc9e 100644
--- a/tests/Unit/HeadersTest.php
+++ b/tests/Unit/HeadersTest.php
@@ -372,6 +372,43 @@ public function testWithWhenMultipleHeadersExistThenOtherHeadersArePreserved():
self::assertCount(2, $updated->toArray());
}
+ public function testAttributeWhenHeaderPresentThenReturnsAttributeWrappingValue(): void
+ {
+ /** @Given headers carrying a single entry */
+ $headers = Headers::fromArray(entries: ['X-Trace-Id' => 'abc-123']);
+
+ /** @When asking for the header as a typed attribute */
+ $actual = $headers->attribute(name: 'X-Trace-Id');
+
+ /** @Then the attribute wraps the folded header value */
+ self::assertSame('abc-123', $actual?->toString());
+ }
+
+ public function testAttributeWhenHeaderAbsentThenReturnsNull(): void
+ {
+ /** @Given headers carrying a single entry */
+ $headers = Headers::fromArray(entries: ['X-Trace-Id' => 'abc-123']);
+
+ /** @When asking for a header that is not present */
+ /** @Then null is returned */
+ self::assertNull($headers->attribute(name: 'X-Missing'));
+ }
+
+ public function testAttributeWhenHeaderPresentButEmptyThenReturnsAttributeWrappingEmptyString(): void
+ {
+ /** @Given headers carrying an entry with an empty value */
+ $headers = Headers::fromArray(entries: ['X-Empty' => '']);
+
+ /** @When asking for the header as a typed attribute */
+ $actual = $headers->attribute(name: 'X-Empty');
+
+ /** @Then a non-null attribute is returned, distinct from an absent header */
+ self::assertNotNull($actual);
+
+ /** @And it wraps the empty string */
+ self::assertSame('', $actual->toString());
+ }
+
public static function validHeaderNameProvider(): array
{
return [
diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php
index ade94f8..bf1b36a 100644
--- a/tests/Unit/HttpBuilderTest.php
+++ b/tests/Unit/HttpBuilderTest.php
@@ -13,6 +13,7 @@
use TinyBlocks\Http\Code;
use TinyBlocks\Http\Exceptions\BaseUrlIsInvalid;
use TinyBlocks\Http\Exceptions\HttpConfigurationInvalid;
+use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Http;
use TinyBlocks\Http\HttpBuilder;
@@ -164,10 +165,13 @@ public function testWithTransportWhenInvokedThenOriginalBuilderStillThrows(): vo
factory: new Psr17Factory()
);
- /** @And the original builder receives a new transport */
- $original->withTransport(transport: $transport);
+ /** @And a copy derived by adding the transport to the original */
+ $configured = $original->withTransport(transport: $transport);
- /** @Then the original builder still throws on build */
+ /** @Then the derived copy is a separate instance from the original */
+ self::assertNotSame($original, $configured);
+
+ /** @And the original builder still throws on build */
$this->expectException(HttpConfigurationInvalid::class);
/** @When calling build on the original builder */
@@ -262,4 +266,38 @@ public function testWithBaseUrlWhenSchemeEmbeddedInPathGivenThenThrowsBaseUrlIsI
/** @When setting a base URL with the scheme embedded mid-string */
$builder->withBaseUrl(url: 'example.com?redirect=https://api.example.com');
}
+
+ public function testWithDefaultHeadersWhenInvokedThenReturnsNewBuilder(): void
+ {
+ /** @Given an empty builder */
+ $original = Http::create();
+
+ /** @When calling withDefaultHeaders */
+ $updated = $original->withDefaultHeaders(
+ headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token'])
+ );
+
+ /** @Then a new builder instance is returned */
+ self::assertNotSame($original, $updated);
+ }
+
+ public function testBuildWhenDefaultHeadersProvidedThenReachTransport(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And a builder configured with a default header */
+ $http = Http::create()
+ ->withBaseUrl(url: 'https://api.example.com')
+ ->withTransport(transport: NetworkTransport::with(client: $client, factory: new Psr17Factory()))
+ ->withDefaultHeaders(headers: Headers::fromArray(entries: ['Authorization' => 'Bearer token']))
+ ->build();
+
+ /** @When sending a request */
+ $http->send(request: Request::get(url: '/dragons'));
+
+ /** @Then the default header reaches the transport */
+ self::assertNotNull($client->captured);
+ self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization'));
+ }
}
diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php
index b093287..c53f111 100644
--- a/tests/Unit/HttpTest.php
+++ b/tests/Unit/HttpTest.php
@@ -15,6 +15,7 @@
use TinyBlocks\Http\Exceptions\HttpRequestFailed;
use TinyBlocks\Http\Exceptions\HttpRequestInvalid;
use TinyBlocks\Http\Exceptions\MalformedPath;
+use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Http;
use TinyBlocks\Http\Method;
@@ -585,4 +586,117 @@ public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlas
self::assertNotNull($client->captured);
self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri());
}
+
+ public function testSendWhenDefaultHeaderProvidedThenReachesTransport(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And an Http instance carrying a default Authorization header */
+ $http = Http::with(
+ baseUrl: 'https://api.example.com',
+ transport: NetworkTransport::with(client: $client, factory: $this->factory),
+ defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer token'])
+ );
+
+ /** @When sending a request that does not set that header */
+ $http->send(request: Request::get(url: '/dragons'));
+
+ /** @Then the default header reaches the transport */
+ self::assertNotNull($client->captured);
+ self::assertSame('Bearer token', $client->captured->getHeaderLine('Authorization'));
+ }
+
+ public function testSendWhenNoDefaultHeadersGivenThenJsonDefaultsStillApply(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And an Http instance without default headers */
+ $http = Http::with(
+ baseUrl: 'https://api.example.com',
+ transport: NetworkTransport::with(client: $client, factory: $this->factory)
+ );
+
+ /** @When sending a plain request */
+ $http->send(request: Request::get(url: '/dragons'));
+
+ /** @Then the JSON defaults are applied */
+ self::assertNotNull($client->captured);
+ self::assertSame('application/json', $client->captured->getHeaderLine('Accept'));
+ self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type'));
+ }
+
+ public function testSendWhenPerRequestHeaderMatchesDefaultThenPerRequestWins(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And an Http instance carrying a default Authorization header */
+ $http = Http::with(
+ baseUrl: 'https://api.example.com',
+ transport: NetworkTransport::with(client: $client, factory: $this->factory),
+ defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer default'])
+ );
+
+ /** @And a request setting its own Authorization header */
+ $request = Request::get(
+ url: '/dragons',
+ headers: Headers::fromArray(entries: ['Authorization' => 'Bearer per-request'])
+ );
+
+ /** @When sending the request */
+ $http->send(request: $request);
+
+ /** @Then the per-request header wins over the default */
+ self::assertNotNull($client->captured);
+ self::assertSame('Bearer per-request', $client->captured->getHeaderLine('Authorization'));
+ }
+
+ public function testSendWhenDefaultHeaderMatchesJsonDefaultThenDefaultWins(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And an Http instance whose default Content-Type differs from the JSON default */
+ $http = Http::with(
+ baseUrl: 'https://api.example.com',
+ transport: NetworkTransport::with(client: $client, factory: $this->factory),
+ defaultHeaders: Headers::fromArray(entries: ['Content-Type' => 'application/xml'])
+ );
+
+ /** @When sending a request that does not set Content-Type */
+ $http->send(request: Request::get(url: '/dragons'));
+
+ /** @Then the default Content-Type wins while the JSON Accept default still applies */
+ self::assertNotNull($client->captured);
+ self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type'));
+ self::assertSame('application/json', $client->captured->getHeaderLine('Accept'));
+ }
+
+ public function testSendWhenPerRequestContentTypeGivenThenOverridesJsonDefault(): void
+ {
+ /** @Given a capturing client */
+ $client = CapturingClient::returningStatus(statusCode: 200);
+
+ /** @And an Http instance without default headers */
+ $http = Http::with(
+ baseUrl: 'https://api.example.com',
+ transport: NetworkTransport::with(client: $client, factory: $this->factory)
+ );
+
+ /** @And a request setting its own Content-Type */
+ $request = Request::post(
+ url: '/dragons',
+ body: ['name' => 'Hydra'],
+ headers: Headers::fromArray(entries: ['Content-Type' => 'application/xml'])
+ );
+
+ /** @When sending the request */
+ $http->send(request: $request);
+
+ /** @Then the per-request Content-Type overrides the JSON default */
+ self::assertNotNull($client->captured);
+ self::assertSame('application/xml', $client->captured->getHeaderLine('Content-Type'));
+ }
}
diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php
index 75d0403..a4c3bf9 100644
--- a/tests/Unit/Server/RequestTest.php
+++ b/tests/Unit/Server/RequestTest.php
@@ -419,6 +419,174 @@ public function getArguments(): string
self::assertSame('', $route->get(key: 'id')->toString());
}
+ public function testQueryWhenUriHasParametersThenReturnsThem(): void
+ {
+ /** @Given a server request carrying query string parameters */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withQueryParams(['sort' => 'name', 'order' => 'asc']);
+
+ /** @When asking for the query parameters */
+ $actual = Request::from(request: $serverRequest)->query();
+
+ /** @Then the query parameters are exposed */
+ self::assertSame(['sort' => 'name', 'order' => 'asc'], $actual->toArray());
+ self::assertSame('name', $actual->get(key: 'sort')->toString());
+ }
+
+ public function testRawBodyWhenBodyEmptyThenReturnsEmptyString(): void
+ {
+ /** @Given a server request with an empty body */
+ $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com')
+ ->withBody($this->factory->createStream(''));
+
+ /** @When reading the raw body */
+ $actual = Request::from(request: $serverRequest)->rawBody();
+
+ /** @Then an empty string is returned */
+ self::assertSame('', $actual);
+ }
+
+ public function testRawBodyWhenBodyGivenThenReturnsExactBytesWithoutDecoding(): void
+ {
+ /** @Given a server request with a raw body carrying insignificant whitespace */
+ $serverRequest = new ServerRequest(
+ method: 'POST',
+ uri: 'https://api.example.com',
+ body: $this->factory->createStream('{ "name" : "Hydra" }')
+ );
+
+ /** @When reading the raw body */
+ $actual = Request::from(request: $serverRequest)->rawBody();
+
+ /** @Then the exact bytes are returned without JSON decoding */
+ self::assertSame('{ "name" : "Hydra" }', $actual);
+ }
+
+ public function testHeadersWhenMessageHasHeaderThenExposesItCaseInsensitively(): void
+ {
+ /** @Given a server request carrying a Content-Type header */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withHeader('Content-Type', 'application/json');
+
+ /** @When asking for the typed headers */
+ $actual = Request::from(request: $serverRequest)->headers();
+
+ /** @Then the header is exposed and looked up case-insensitively */
+ self::assertSame('application/json', $actual->get('content-type'));
+ self::assertTrue($actual->has('Content-Type'));
+ }
+
+ public function testHeadersWhenMultiValueHeaderGivenThenFoldsWithComma(): void
+ {
+ /** @Given a server request carrying a multi-value Accept header */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withHeader('Accept', 'application/json')
+ ->withAddedHeader('Accept', 'text/html');
+
+ /** @When asking for the typed headers */
+ $actual = Request::from(request: $serverRequest)->headers();
+
+ /** @Then the multi-value header line is folded with a comma separator */
+ self::assertSame('application/json, text/html', $actual->get('Accept'));
+ }
+
+ public function testRawBodyWhenSeekableBodyReadThenDecodeStillSeesFullBody(): void
+ {
+ /** @Given a server request with a seekable JSON body */
+ $serverRequest = new ServerRequest(
+ method: 'POST',
+ uri: 'https://api.example.com',
+ body: $this->factory->createStream('{"name":"Hydra"}')
+ );
+
+ /** @And a typed wrapper over that request */
+ $request = Request::from(request: $serverRequest);
+
+ /** @When reading the raw body */
+ $actual = $request->rawBody();
+
+ /** @Then the raw body is returned */
+ self::assertSame('{"name":"Hydra"}', $actual);
+
+ /** @And a subsequent decode still reads the full body */
+ self::assertSame('Hydra', $request->decode()->body()->get(key: 'name')->toString());
+ }
+
+ public function testHeaderWhenHeaderPresentThenReturnsAttributeWrappingValue(): void
+ {
+ /** @Given a server request carrying a Content-Type header */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withHeader('Content-Type', 'application/json');
+
+ /** @When asking for the single header as a typed attribute */
+ $actual = Request::from(request: $serverRequest)->header(name: 'content-type');
+
+ /** @Then the attribute wraps the folded header value */
+ self::assertSame('application/json', $actual?->toString());
+ }
+
+ public function testHeaderWhenHeaderAbsentThenReturnsNull(): void
+ {
+ /** @Given a server request without the looked-up header */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com');
+
+ /** @When asking for a header that is not present */
+ /** @Then null is returned */
+ self::assertNull(Request::from(request: $serverRequest)->header(name: 'X-Missing'));
+ }
+
+ public function testOnlyWhenSeveralPresentKeysGivenThenReturnsMapOfAttributes(): void
+ {
+ /** @Given a set of route attributes */
+ $attributes = ['id' => 'dragon-id', 'skill' => 'fire-breath'];
+
+ /** @And a server request carrying those attributes under the canonical route key */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]);
+
+ /** @When extracting only the named route attributes */
+ $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'skill']);
+
+ /** @Then the resulting map is keyed by exactly the requested attributes */
+ self::assertSame(['id', 'skill'], array_keys($actual));
+
+ /** @And each requested key resolves to its typed Attribute value */
+ self::assertSame($attributes['id'], $actual['id']->toString());
+ self::assertSame($attributes['skill'], $actual['skill']->toString());
+ }
+
+ public function testOnlyWhenAbsentKeyRequestedThenMapWrapsNullForThatKey(): void
+ {
+ /** @Given a server request carrying a single route attribute */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']);
+
+ /** @When extracting a present key alongside an absent one */
+ $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: ['id', 'missing']);
+
+ /** @Then both requested keys are present in the map */
+ self::assertSame(['id', 'missing'], array_keys($actual));
+
+ /** @And the present key resolves to its value */
+ self::assertSame('dragon-id', $actual['id']->toString());
+
+ /** @And the absent key resolves to an Attribute wrapping null */
+ self::assertSame('', $actual['missing']->toString());
+ }
+
+ public function testOnlyWhenNoKeysGivenThenReturnsEmptyMap(): void
+ {
+ /** @Given a server request carrying route attributes */
+ $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com')
+ ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']);
+
+ /** @When extracting with no requested keys */
+ $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->only(keys: []);
+
+ /** @Then the resulting map is empty */
+ self::assertSame([], $actual);
+ }
+
public static function httpMethodsProvider(): array
{
return [
From 821ea0224a8576e706e353e27381e2dcecd622a2 Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:48:43 -0300
Subject: [PATCH 7/8] test: Strengthen Cookie immutability assertions.
---
tests/Unit/CookieTest.php | 23 ++++++++++++-----------
1 file changed, 12 insertions(+), 11 deletions(-)
diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php
index 88bbceb..a929269 100644
--- a/tests/Unit/CookieTest.php
+++ b/tests/Unit/CookieTest.php
@@ -17,13 +17,12 @@ final class CookieTest extends TestCase
#[DataProvider('invalidNameProvider')]
public function testCreateWhenInvalidNameGivenThenThrows(string $name): void
{
- /**
- * @Given an invalid cookie name
- * @When Cookie::create is called with that name
- * @Then it throws CookieNameIsInvalid
- */
+ /** @Given an invalid cookie name */
+
+ /** @Then it throws CookieNameIsInvalid */
$this->expectException(InvalidArgumentException::class);
+ /** @When Cookie::create is called with that name */
Cookie::create(name: $name, value: 'value');
}
@@ -40,13 +39,12 @@ public function testExpireWhenInvalidNameGivenThenThrows(): void
#[DataProvider('invalidValueProvider')]
public function testCreateWhenInvalidValueGivenThenThrows(string $value): void
{
- /**
- * @Given an invalid cookie value
- * @When Cookie::create is called with that value
- * @Then it throws CookieValueIsInvalid
- */
+ /** @Given an invalid cookie value */
+
+ /** @Then it throws CookieValueIsInvalid */
$this->expectException(InvalidArgumentException::class);
+ /** @When Cookie::create is called with that value */
Cookie::create(name: 'session', value: $value);
}
@@ -70,10 +68,13 @@ public function testSecureWhenInvokedThenLeavesBaseUntouched(): void
$base = Cookie::create(name: 'session', value: 'abc');
/** @When the secure flag is applied */
- $base->secure();
+ $secured = $base->secure();
/** @Then the base instance remains unchanged */
self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray());
+
+ /** @And the derived cookie is a separate instance from the base */
+ self::assertNotSame($base, $secured);
}
public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void
From e86665c4e2e9bdf85805d5e230b596f1ab33a88e Mon Sep 17 00:00:00 2001
From: Gustavo Freze
Date: Sat, 6 Jun 2026 12:48:43 -0300
Subject: [PATCH 8/8] docs: Document the new client and server capabilities.
---
README.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 126 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 3806a6d..3c9effa 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
+ [Server](#server)
- [Decoding a request](#decoding-a-request)
- [Creating a response](#creating-a-response)
+ - [Pagination links](#pagination-links)
- [Setting cookies](#setting-cookies)
- [Status code](#status-code)
+ [Client](#client)
@@ -16,6 +17,7 @@
- [Reading the response](#reading-the-response)
- [Query parameters](#query-parameters)
- [Custom headers and content type](#custom-headers-and-content-type)
+ - [Default headers](#default-headers)
- [Setting the User-Agent](#setting-the-user-agent)
- [Error handling](#error-handling)
- [Configuring timeouts](#configuring-timeouts)
@@ -35,7 +37,7 @@ The library covers both sides of an HTTP exchange:
by any PSR-18 client, and exposes responses with typed body and header access.
Shared primitives at `TinyBlocks\Http\`: `Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `MimeType`,
-`Charset`, `Cookie`, `SameSite`, `CacheControl`, `ResponseCacheDirectives`, `UserAgent`.
+`Charset`, `Cookie`, `SameSite`, `CacheControl`, `ResponseCacheDirectives`, `Link`, `LinkRelation`, `UserAgent`.
## Installation
@@ -68,6 +70,17 @@ $name = $decoded->body()->get(key: 'name')->toString();
$amount = $decoded->body()->get(key: 'amount')->toFloat();
```
+To pull several route parameters in one call, `only(...)` returns a map of typed `Attribute`
+instances keyed by name. A key with no matching route parameter resolves to an `Attribute`
+wrapping `null` rather than being omitted:
+
+```php
+$attributes = $decoded->uri()->route()->only(keys: ['id', 'slug']);
+
+$id = $attributes['id']->toInteger();
+$slug = $attributes['slug']->toString();
+```
+
The HTTP method is available as a typed `Method` enum:
```php
@@ -82,6 +95,27 @@ use TinyBlocks\Http\Server\Request;
$method = Request::from(request: $psrRequest)->method();
```
+`Server\Request` also exposes the headers, the query parameters, and the raw body directly,
+without decoding the payload. `rawBody()` returns the exact bytes received (handy for verifying a
+signature) and rewinds seekable streams, so a later `decode()` still observes the full body.
+
+```php
+headers()->get(name: 'content-type'); # case-insensitive lookup
+$trace = $request->header(name: 'X-Trace-Id'); # typed Attribute, or null
+$sort = $request->query()->get(key: 'sort')->toString();
+$rawBody = $request->rawBody(); # exact bytes, undecoded
+```
+
#### Creating a response
Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`:
@@ -147,6 +181,31 @@ $response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good');
$response->getReasonPhrase(); # "All Good"
```
+#### Pagination links
+
+`Link` implements `Headerable` and renders an RFC 8288 `Link` response header. Chain `to(...)` and `and(...)` with
+`LinkRelation` targets to emit the standard pagination relations (`first`, `prev`, `next`, `last`, `self`), then attach
+it to any response.
+
+```php
+and(uri: 'https://api.example.com/items?page=4', relation: LinkRelation::PREVIOUS)
+ ->and(uri: 'https://api.example.com/items?page=6', relation: LinkRelation::NEXT)
+ ->and(uri: 'https://api.example.com/items?page=9', relation: LinkRelation::LAST);
+
+Response::ok(['data' => []], $links);
+```
+
+The four targets fold into a single comma-separated `Link` response header in the order they were added.
+
#### Setting cookies
`Cookie` implements `Headerable` and composes naturally with `Response`.
@@ -236,10 +295,15 @@ Code::MOVED_PERMANENTLY->isRedirection(); # true
Code::BAD_REQUEST->isClientError(); # true
Code::INTERNAL_SERVER_ERROR->isError(); # true
Code::INTERNAL_SERVER_ERROR->isServerError(); # true
+Code::GATEWAY_TIMEOUT->isTimeout(); # true
Code::isValidCode(code: 200); # true
Code::isErrorCode(code: 500); # true
Code::isSuccessCode(code: 200); # true
+
+Code::tryFromNullable(code: 200); # Code::OK
+Code::tryFromNullable(code: 250); # null (status code not represented)
+Code::tryFromNullable(code: null); # null
```
### Client
@@ -371,6 +435,15 @@ $response->headers(); # TinyBlocks\Http\Headers value object
```php
$contentType = $response->headers()->get(name: 'content-type'); # "application/json"
$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); # true
+$trace = $response->headers()->attribute(name: 'X-Trace-Id'); # Attribute, or null
+```
+
+`orFail()` returns the response unchanged on a 2xx status and throws `HttpResponseUnsuccessful`
+otherwise. The exception carries the `Code` and the decoded `Body`, so a non-2xx status can be
+branched on and its payload inspected in one place, then mapped to a domain error:
+
+```php
+$body = $response->orFail()->body(); # throws HttpResponseUnsuccessful when the status is not 2xx
```
#### Query parameters
@@ -452,6 +525,57 @@ $updated = Request::get(url: '/v1/charges')
->withHeader(name: 'X-Trace-Id', value: 'abc-123');
```
+`ContentType` renders its raw header value via `toString()`, useful when composing the header by
+hand:
+
+```php
+ContentType::applicationJson(charset: Charset::UTF_8)->toString(); # "application/json; charset=utf-8"
+ContentType::applicationJson(charset: Charset::UTF_8)->mimeType(); # MimeType::APPLICATION_JSON
+```
+
+#### Default headers
+
+Carry headers applied to every request (for example a static authorization header) by passing
+`defaultHeaders` to the builder or to `Http::with(...)`. Precedence per request is: a header set on
+the request wins over a default, and a default wins over the JSON defaults
+(`Accept`/`Content-Type: application/json`).
+
+```php
+ 'Bearer token']);
+
+$http = Http::create()
+ ->withBaseUrl(url: 'https://api.example.com')
+ ->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory))
+ ->withDefaultHeaders(headers: $defaultHeaders)
+ ->build();
+```
+
+The same headers can be supplied through the single-call factory:
+
+```php
+ 'Bearer token'])
+);
+```
+
#### Setting the User-Agent
The `UserAgent` value object implements `Headerable` and renders the standard
@@ -558,6 +682,7 @@ try {
| `NoMoreResponses` | `InMemoryTransport` exhausted (programmer error). |
| `HttpConfigurationInvalid` | Builder called without required dependencies. |
| `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. |
+| `HttpResponseUnsuccessful` | `Response::orFail()` called on a non-2xx response. |
#### Configuring timeouts