diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c47d255
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,23 @@
+/.gitattributes export-ignore
+/.github/ export-ignore
+/.gitignore export-ignore
+/.php-cs-fixer.dist.php export-ignore
+/renovate.json export-ignore
+/.laminas-ci.json export-ignore
+/phpunit.xml.dist export-ignore
+/test/ export-ignore
+/clover.xml export-ignore
+
+# Force our line endings to be LF, even for Windows
+* text eol=lf
+# Set certain files to be binary
+*.png binary
+*.jpg binary
+*.gif binary
+*.tgz binary
+*.zip binary
+*.tar.gz binary
+*.ttf binary
+*.svg binary
+*.dia binary
+*.xcf binary
\ No newline at end of file
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
new file mode 100644
index 0000000..25bc29b
--- /dev/null
+++ b/.github/workflows/continuous-integration.yml
@@ -0,0 +1,31 @@
+name: "Continuous Integration"
+
+on:
+ pull_request:
+ push:
+ branches:
+ tags:
+
+jobs:
+ matrix:
+ name: Generate job matrix
+ runs-on: ubuntu-latest
+ outputs:
+ matrix: ${{ steps.matrix.outputs.matrix }}
+ steps:
+ - name: Gather CI configuration
+ id: matrix
+ uses: laminas/laminas-ci-matrix-action@v1
+
+ qa:
+ name: QA Checks
+ needs: [matrix]
+ runs-on: ${{ matrix.operatingSystem }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }}
+ steps:
+ - name: ${{ matrix.name }}
+ uses: laminas/laminas-continuous-integration-action@v1
+ with:
+ job: ${{ matrix.job }}
\ No newline at end of file
diff --git a/.github/workflows/release-on-milestone-closed.yml b/.github/workflows/release-on-milestone-closed.yml
new file mode 100644
index 0000000..a52f436
--- /dev/null
+++ b/.github/workflows/release-on-milestone-closed.yml
@@ -0,0 +1,15 @@
+name: "Automatic Releases"
+
+on:
+ milestone:
+ types:
+ - "closed"
+
+jobs:
+ release:
+ uses: laminas/workflow-automatic-releases/.github/workflows/release-on-milestone-closed.yml@1.x
+ secrets:
+ GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
+ GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
+ ORGANIZATION_ADMIN_TOKEN: ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}
+ SIGNING_SECRET_KEY: ${{ secrets.SIGNING_SECRET_KEY }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0720b3f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+/.php-cs-fixer.cache
+/.phpunit.cache
+/.phpunit.result.cache
+/.phpstan-cache
+/phpstan.neon
+/phpbench.json
+/clover.xml
+/coveralls-upload.json
+/phpunit.xml
+/vendor/
+/.vscode
+*:Zone.Identifier
diff --git a/.laminas-ci.json b/.laminas-ci.json
new file mode 100644
index 0000000..10c530b
--- /dev/null
+++ b/.laminas-ci.json
@@ -0,0 +1,12 @@
+{
+ "additional_checks": [
+ {
+ "name": "PhpStan",
+ "job": {
+ "php": "8.2",
+ "dependencies": "latest",
+ "command": "composer require --dev phpstan/phpstan && vendor/bin/phpstan analyse"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..7e29bbc
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,52 @@
+registerCustomRuleSets([
+ new Webware1x0Set(),
+ new WebwareCopyRight(
+ packageName: $composerData['name'],
+ authorName: $composerData['authors'][0]['name'],
+ authorEmail: $composerData['authors'][0]['email'],
+ ),
+ ])
+ ->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
+ ->setRiskyAllowed(true)
+ ->setRules([
+ '@Webware/copyright-header' => true,
+ '@Webware/coding-standard-1.0' => true,
+ ])
+ // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
+ ->setFinder(
+ (new Finder())
+ // 💡 root folder to check
+ ->in(__DIR__)
+ ->notPath([
+ ])
+ ->exclude([
+ '.github',
+ 'stubs',
+ ])
+ // 💡 additional files, eg bin entry file
+ // ->append([__DIR__.'/bin-entry-file'])
+ // 💡 folders to exclude, if any
+ // ->exclude([/* ... */])
+ // 💡 path patterns to exclude, if any
+ // ->notPath([/* ... */])
+ // 💡 extra configs
+ // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
+ // ->ignoreVCS(true) // true by default
+ );
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..3814625
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,65 @@
+{
+ "name": "webware/webware-core",
+ "description": "Contracts and utilities for the webware-* ecosystem.",
+ "license": "BSD-3-Clause",
+ "keywords": [
+ "webware",
+ "core",
+ "mezzio"
+ ],
+ "authors": [
+ {
+ "name": "Joey Smith",
+ "email": "jsmith@webinertia.net",
+ "role": "Developer"
+ }
+ ],
+ "require": {
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "fig/http-message-util": "^1.1",
+ "psr/http-message": "^2.0",
+ "psr/http-server-handler": "^1.0",
+ "psr/http-server-middleware": "^1.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^13.0.0",
+ "roave/security-advisories": "dev-master",
+ "webware/coding-standard": "^0.1.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Webware\\Core\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Webware\\CoreTest\\": "test/unit/",
+ "Webware\\CoreIntegrationTest\\": "test/integration/"
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true
+ },
+ "scripts": {
+ "check-all": [
+ "@cs-check",
+ "@sa",
+ "@test"
+ ],
+ "cs-check": "vendor/bin/php-cs-fixer check",
+ "cs-fix": "vendor/bin/php-cs-fixer fix",
+ "test": "phpunit --colors=always",
+ "test-coverage": "phpunit --colors=always --coverage-clover clover.xml",
+ "sa": "vendor/bin/phpstan analyse --memory-limit=256M",
+ "sa-gen-baseline": "vendor/bin/phpstan analyse --memory-limit=256M --generate-baseline",
+ "sa-verbose": "vendor/bin/phpstan analyse --memory-limit=256M -vv",
+ "serve": [
+ "Composer\\Config::disableProcessTimeout",
+ "php -S 0.0.0.0:8080 -t public/"
+ ]
+ }
+}
diff --git a/docs/schema-driven-filter-architecture.md b/docs/schema-driven-filter-architecture.md
new file mode 100644
index 0000000..35b9af7
--- /dev/null
+++ b/docs/schema-driven-filter-architecture.md
@@ -0,0 +1,280 @@
+# Schema-Driven Filter Architecture
+
+_Authored: 2026-05-31_
+
+---
+
+## Problem Statement
+
+Across all web applications there is a fundamental pain point: a single logical
+field must be consistently named and typed across multiple layers — the HTML form
+attribute, the request body array key, the filter/validator input name, the
+command constructor parameter, and the database column name. In this codebase
+that problem manifested as scattered `is_array()` checks, `(int)` casts, and
+hardcoded key names duplicated independently in each layer with nothing enforcing
+consistency between them.
+
+The goal of this architecture is a strict convention with a single source of
+truth, so that no component independently decides what a field is called or what
+constraints apply to it.
+
+---
+
+## The Chain
+
+Every form submission travels through the following layers:
+
+```
+HTML Form (field name attributes)
+ │
+ â–¼
+Request body array (keys match form field names)
+ │
+ â–¼
+Filter class (PSR-7 boundary — the ONLY place raw body is touched)
+ │
+ â–¼
+Command / DTO (typed value transport)
+ │
+ â–¼
+CommandHandler (entry point to persistence)
+ │
+ â–¼
+Repository (DB column mapping)
+ │
+ â–¼
+Database
+```
+
+---
+
+## Agreed Filter Convention (interim — before webware-filter ships)
+
+Until the full schema-driven infrastructure exists, every form endpoint gets a
+`*Filter` class following this pattern:
+
+```php
+final class RuleFilter
+{
+ public readonly string $roleId;
+ // ...
+ private bool $valid = false;
+
+ private function __construct(array $body) { /* assign + validate */ }
+
+ public static function fromRequest(ServerRequestInterface $request): self
+ {
+ return new self((array) $request->getParsedBody());
+ }
+
+ public function isValid(): bool { return $this->valid; }
+
+ public function getValues(): array { /* keyed to command param names */ }
+}
+```
+
+**Hard rules:**
+1. `(array)` cast lives **only** in `fromRequest()` — never in middleware
+2. `isValid()` is the **only** gate — middleware never inspects individual properties
+3. `getValues()` keys **must** match the target command's constructor parameter names exactly
+4. No filter logic outside the filter class — ever
+
+Middleware pattern:
+```php
+$filter = RuleFilter::fromRequest($request);
+if (! $filter->isValid()) {
+ return $handler->handle($request); // early return, no CommandResult attribute
+}
+$result = $this->commandBus->handle(new SaveRuleCommand(...$filter->getValues()));
+```
+
+---
+
+## Long-Term Vision: Schema-Driven Architecture
+
+### Core Insight
+
+The database schema is the most stable contract in the system. Everything else
+— forms, filters, commands, repositories — must honour it anyway. Therefore the
+schema should be the single source of truth from which all other artefacts are
+derived or validated.
+
+`phpdb`'s `Metadata` layer already exposes everything needed:
+
+| `ColumnObject` method | Derivable rule |
+|---|---|
+| `getDataType()` | type coercion, base filter, form element type |
+| `getIsNullable()` | required vs optional |
+| `getCharacterMaximumLength()` | `MaxLength(n)` |
+| `getNumericPrecision()` / `getNumericScale()` | decimal constraints |
+| `isNumericUnsigned()` | min 0 |
+| `getColumnDefault()` | default value fallback |
+| `getErrata('permitted_values')` | `InArray([...])` for ENUM/SET columns |
+
+The MySQL metadata source (`phpdb-mysql`) populates `permitted_values` errata by
+parsing `INFORMATION_SCHEMA.COLUMN_TYPE` for `ENUM` and `SET` columns, returning
+`string[]` of the allowed values. No additional annotation is needed.
+
+### `ColumnMetadataInterface`
+
+Lives in **`webware-core`**. Defines the contract that the orchestrator and both
+managers consume:
+
+```php
+interface ColumnMetadataInterface
+{
+ public function getName(): string;
+ public function getDataType(): string;
+ public function getIsNullable(): bool;
+ public function getCharacterMaximumLength(): ?int;
+ public function isNumericUnsigned(): ?bool;
+ public function getColumnDefault(): mixed;
+ public function getErrata(string $name): mixed;
+}
+```
+
+`phpdb`'s `ColumnObject` already satisfies every method but cannot implement
+this interface directly (phpdb must not depend on webware packages). The adapter
+pattern resolves this — see `webware-orchestra` below.
+
+### Package Responsibilities
+
+```
+webware-core
+ Defines: ColumnMetadataInterface
+ All other webware packages already depend on this — no new dependency introduced.
+
+phpdb / phpdb-mysql
+ Unchanged. ColumnObject remains as-is.
+ Metadata\Source (MySQL) populates ColumnObject including errata for ENUM/SET.
+
+webware-filter
+ Depends on: webware-core
+ Owns: FilterManager, AggregateFilter, InputFilterInterface (mirrors Laminas)
+ Consumes: ColumnMetadataInterface
+ Knows nothing of: ColumnObject, phpdb, webware-form, webware-orchestra
+ FilterManager alias map: dataType string → filter chain configuration
+ Example aliases:
+ 'varchar' → StringTrim + MaxLength(n)
+ 'enum' → InArray(permitted_values)
+ 'json' → JsonDecode transform
+ 'int' → Digits + optional min 0 if unsigned
+ 'boolean' → Boolean
+
+webware-form (when laminas-form is compatible with laminas-servicemanager v4)
+ Depends on: webware-core
+ Owns: FormElementManager
+ Consumes: ColumnMetadataInterface
+ Knows nothing of: ColumnObject, phpdb, webware-filter, webware-orchestra
+ FormElementManager alias map: dataType string → form element class
+ Example aliases:
+ 'varchar' → Text
+ 'enum' → Select (options from permitted_values)
+ 'json' → Hidden
+ 'int' → Number
+ 'boolean' → Checkbox
+
+webware-orchestra
+ Depends on: webware-core, phpdb, webware-filter, (later) webware-form
+ Owns: ColumnObjectAdapter (implements ColumnMetadataInterface, wraps ColumnObject)
+ AggregateFilterFactory (orchestrator — takes table name, builds filter)
+ Column type → alias mapping config
+ ConfigProvider: opt-in — wires everything together, nothing breaks if absent
+ This is the ONLY package that knows about both phpdb and webware-filter.
+```
+
+### Dependency Graph
+
+```
+webware-core ◄──── webware-filter
+webware-core ◄──── webware-form
+webware-core ◄──── webware-orchestra
+phpdb ◄──── webware-orchestra
+webware-filter ◄── webware-orchestra
+webware-form ◄── webware-orchestra (future)
+
+phpdb (no webware dependencies)
+webware-filter (no phpdb dependency)
+webware-form (no phpdb dependency)
+```
+
+### The Adapter
+
+`webware-orchestra\Adapter\ColumnObjectAdapter` is the seam between `phpdb` and
+the rest of the ecosystem:
+
+```php
+final class ColumnObjectAdapter implements ColumnMetadataInterface
+{
+ public function __construct(private readonly ColumnObject $column) {}
+
+ public function getName(): string { return $this->column->getName(); }
+ public function getDataType(): string { return $this->column->getDataType(); }
+ public function getIsNullable(): bool { return $this->column->getIsNullable() ?? false; }
+ public function getCharacterMaximumLength(): ?int { return $this->column->getCharacterMaximumLength(); }
+ public function isNumericUnsigned(): ?bool { return $this->column->isNumericUnsigned(); }
+ public function getColumnDefault(): mixed { return $this->column->getColumnDefault(); }
+ public function getErrata(string $name): mixed { return $this->column->getErrata($name); }
+}
+```
+
+### Orchestrator Flow
+
+```
+AggregateFilterFactory::fromTable(string $table, AdapterInterface $adapter)
+ │
+ ├─ Metadata::getColumns($table)
+ │ returns ColumnObject[]
+ │
+ ├─ foreach ColumnObject → wrap in ColumnObjectAdapter
+ │
+ ├─ foreach ColumnMetadataInterface:
+ │ $dataType = $col->getDataType() // 'varchar', 'enum', etc.
+ │ $filterChain = FilterManager::get($dataType, $col)
+ │ configure chain: MaxLength, InArray, Digits etc from ColumnObject methods
+ │
+ └─ return AggregateFilter (isValid() / getValues())
+```
+
+### Laminas Interoperability
+
+`webware-filter` defines its own `InputFilterInterface` mirroring the Laminas
+`InputFilterInterface` signatures exactly:
+- `setData(iterable $data): void`
+- `isValid(): bool`
+- `getValues(): array`
+- `getMessages(): array`
+
+When `laminas/laminas-inputfilter` ships a release compatible with
+`laminas/laminas-servicemanager` v4, `webware-filter` makes its interface extend
+the Laminas one (or deprecates in favour of it). No consuming code changes because
+the method signatures are identical. The migration is a `composer require` swap,
+not a refactor.
+
+### Naming Convention — The Key Alignment Problem
+
+The one piece that cannot be derived from the schema automatically is the mapping
+between DB column names (`role_id`) and command constructor parameter names
+(`$roleId`). This is provided as a developer-owned array in the filter class or
+orchestrator configuration:
+
+```php
+['role_id' => 'roleId', 'resource_id' => 'resourceId', 'rule_type' => 'type']
+```
+
+By convention, `getValues()` on any filter class returns keys matching the target
+command's constructor parameter names exactly, so the command can be constructed
+with `new SaveRuleCommand(...$filter->getValues())`.
+
+---
+
+## Current Status
+
+- `ColumnMetadataInterface` — **not yet created** (defined here, to be implemented)
+- `webware-filter` package — **not yet created**
+- `webware-orchestra` package — **not yet created**
+- Interim `*Filter` convention — **agreed, being applied** starting with `RuleFilter`
+ in `webware-acl`
+- `laminas-form` / `laminas-inputfilter` / `laminas-validator` compatible with
+ servicemanager v4 — **not yet released**, architecture designed to accommodate
+ them when they land
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..364905f
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,2 @@
+parameters:
+ ignoreErrors:
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..1f64dd8
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,17 @@
+includes:
+ - phpstan-baseline.neon
+parameters:
+ level: 10
+ paths:
+ - src
+ - test
+ universalObjectCratesClasses:
+ - Laminas\Stdlib\ArrayObject
+ stubFiles:
+ - stubs/Laminas/ServiceManager/Factory/AbstractFactoryInterface.stub
+ - stubs/Laminas/ServiceManager/Factory/DelegatorFactoryInterface.stub
+ - stubs/Laminas/ServiceManager/Factory/FactoryInterface.stub
+ - stubs/Laminas/ServiceManager/Factory/InvokableFactory.stub
+ - stubs/Laminas/ServiceManager/Initializer/InitializerInterface.stub
+ - stubs/Psr/Container/ContainerInterface.stub
+ treatPhpDocTypesAsCertain: false
\ No newline at end of file
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..ab3e431
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,25 @@
+
+
+
+
+
+ test/unit
+
+
+ test/integration
+
+
+
+
+ src
+
+
+
\ No newline at end of file
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
new file mode 100644
index 0000000..589abbe
--- /dev/null
+++ b/src/ConfigProvider.php
@@ -0,0 +1,36 @@
+
+ * and contributors.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Webware\Core;
+
+final class ConfigProvider
+{
+ public function __invoke(): array
+ {
+ return [
+ 'dependencies' => $this->getDependencies(),
+ ];
+ }
+
+ public function getDependencies(): array
+ {
+ return [
+ 'aliases' => [],
+ 'invokables' => [],
+ 'factories' => [
+ Middleware\AttachCoreServicesMiddleware::class => Container\AttachCoreServicesMiddlewareFactory::class,
+ ],
+ ];
+ }
+}
diff --git a/src/Configuration.php b/src/Configuration.php
new file mode 100644
index 0000000..45d94e3
--- /dev/null
+++ b/src/Configuration.php
@@ -0,0 +1,102 @@
+has('config')) {
+ throw Exception\ContainerException::forMissingConfigService('config', $callingFactory);
+ }
+
+ $config = $container->get('config');
+ if (! isset($config[static::CONFIG_KEY])) {
+ throw Exception\ContainerException::forMissingConfigKey(static::CONFIG_KEY, $callingFactory);
+ }
+ if (
+ ! is_array($config[static::CONFIG_KEY]) || $config[static::CONFIG_KEY] === []
+ ) {
+ throw Exception\ContainerException::forInvalidConfigType(static::CONFIG_KEY, 'non-empty array', get_debug_type($config[static::CONFIG_KEY]), $callingFactory);
+ }
+
+ return $config[static::CONFIG_KEY];
+ }
+
+ public static function getAdminRouteSegment(ContainerInterface $container, string $callingFactory): string
+ {
+ $config = static::getConfig($container, $callingFactory);
+
+ if (! isset($config[static::ADMIN_ROUTE_SEGMENT_KEY])) {
+ throw Exception\ContainerException::forMissingConfigKey(static::ADMIN_ROUTE_SEGMENT_KEY, $callingFactory);
+ }
+
+ if (
+ ! is_string($config[static::ADMIN_ROUTE_SEGMENT_KEY])
+ || $config[static::ADMIN_ROUTE_SEGMENT_KEY] === ''
+ ) {
+ throw Exception\ContainerException::forInvalidConfigType(static::ADMIN_ROUTE_SEGMENT_KEY, 'non-empty string', get_debug_type($config[static::ADMIN_ROUTE_SEGMENT_KEY]), $callingFactory);
+ }
+
+ return $config[static::ADMIN_ROUTE_SEGMENT_KEY];
+ }
+
+ public static function getAdminRouteNamePrefix(ContainerInterface $container, string $callingFactory): string
+ {
+ $config = static::getConfig($container, $callingFactory);
+
+ if (! isset($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY])) {
+ throw Exception\ContainerException::forMissingConfigKey(static::ADMIN_ROUTE_NAME_PREFIX_KEY, $callingFactory);
+ }
+ if (
+ ! is_string($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY])
+ || $config[static::ADMIN_ROUTE_NAME_PREFIX_KEY] === ''
+ ) {
+ throw Exception\ContainerException::forInvalidConfigType(static::ADMIN_ROUTE_NAME_PREFIX_KEY, 'non-empty string', get_debug_type($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY]), $callingFactory);
+ }
+
+ return $config[static::ADMIN_ROUTE_NAME_PREFIX_KEY];
+ }
+
+ public static function getRouteSegment(ContainerInterface $container, string $callingFactory): string
+ {
+ $config = static::getConfig($container, $callingFactory);
+
+ if (! isset($config[static::ROUTE_SEGMENT_KEY])) {
+ throw Exception\ContainerException::forMissingConfigKey(static::ROUTE_SEGMENT_KEY, $callingFactory);
+ }
+ if (
+ ! is_string($config[static::ROUTE_SEGMENT_KEY])
+ || $config[static::ROUTE_SEGMENT_KEY] === ''
+ ) {
+ throw Exception\ContainerException::forInvalidConfigType(static::ROUTE_SEGMENT_KEY, 'non-empty string', get_debug_type($config[static::ROUTE_SEGMENT_KEY]), $callingFactory);
+ }
+
+ return $config[static::ROUTE_SEGMENT_KEY];
+ }
+
+ public static function getRouteNamePrefix(ContainerInterface $container, string $callingFactory): string
+ {
+ $config = static::getConfig($container, $callingFactory);
+
+ if (! isset($config[static::ROUTE_NAME_PREFIX_KEY])) {
+ throw Exception\ContainerException::forMissingConfigKey(static::ROUTE_NAME_PREFIX_KEY, $callingFactory);
+ }
+ if (
+ ! is_string($config[static::ROUTE_NAME_PREFIX_KEY])
+ || $config[static::ROUTE_NAME_PREFIX_KEY] === ''
+ ) {
+ throw Exception\ContainerException::forInvalidConfigType(static::ROUTE_NAME_PREFIX_KEY, 'non-empty string', get_debug_type($config[static::ROUTE_NAME_PREFIX_KEY]), $callingFactory);
+ }
+
+ return $config[static::ROUTE_NAME_PREFIX_KEY];
+ }
+}
diff --git a/src/ConfigurationInterface.php b/src/ConfigurationInterface.php
new file mode 100644
index 0000000..edeb986
--- /dev/null
+++ b/src/ConfigurationInterface.php
@@ -0,0 +1,34 @@
+get(InputFilterPluginManager::class)
+ );
+ }
+}
diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php
new file mode 100644
index 0000000..1282c15
--- /dev/null
+++ b/src/Exception/ContainerException.php
@@ -0,0 +1,63 @@
+getMethod()) {
+ RequestMethodInterface::METHOD_GET => $this->processGet($request, $handler),
+ RequestMethodInterface::METHOD_POST => $this->processPost($request, $handler),
+ RequestMethodInterface::METHOD_PATCH,
+ RequestMethodInterface::METHOD_PUT => $this->processPatch($request, $handler),
+ RequestMethodInterface::METHOD_DELETE => $this->processDelete($request, $handler),
+ default => throw new DomainException('Unsupported HTTP method: ' . $request->getMethod()),
+ };
+ }
+
+ public function processGet(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ return $handler->handle($request);
+ }
+
+ public function processPost(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ return $handler->handle($request);
+ }
+
+ public function processPatch(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ return $handler->handle($request);
+ }
+
+ public function processDelete(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ return $handler->handle($request);
+ }
+}
diff --git a/src/Middleware/AttachCoreServicesMiddleware.php b/src/Middleware/AttachCoreServicesMiddleware.php
new file mode 100644
index 0000000..e085702
--- /dev/null
+++ b/src/Middleware/AttachCoreServicesMiddleware.php
@@ -0,0 +1,29 @@
+handle(
+ $request
+ ->withAttribute(
+ InputFilterPluginManager::class,
+ $this->inputFilterPluginManager
+ )
+ );
+ }
+}
diff --git a/src/SchemaInterface.php b/src/SchemaInterface.php
new file mode 100644
index 0000000..5d0992c
--- /dev/null
+++ b/src/SchemaInterface.php
@@ -0,0 +1,12 @@
+middleware = new class() implements MiddlewareInterface {
+ use HttpMethodProcessorTrait;
+
+ /** @var string[] */
+ public array $called = [];
+
+ public function processGet(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ $this->called[] = 'GET';
+
+ return $handler->handle($request);
+ }
+
+ public function processPost(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ $this->called[] = 'POST';
+
+ return $handler->handle($request);
+ }
+
+ public function processPatch(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ $this->called[] = 'PATCH';
+
+ return $handler->handle($request);
+ }
+
+ public function processDelete(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler,
+ ): ResponseInterface {
+ $this->called[] = 'DELETE';
+
+ return $handler->handle($request);
+ }
+ };
+ }
+
+ /** @return array */
+ public static function verbProvider(): array
+ {
+ return [
+ 'GET dispatches to processGet' => ['GET', 'GET'],
+ 'POST dispatches to processPost' => ['POST', 'POST'],
+ 'PATCH dispatches to processPatch' => ['PATCH', 'PATCH'],
+ 'PUT dispatches to processPatch' => ['PUT', 'PATCH'],
+ 'DELETE dispatches to processDelete' => ['DELETE', 'DELETE'],
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('verbProvider')]
+ public function itDispatchesToCorrectMethod(string $httpMethod, string $expectedMethod): void
+ {
+ $request = new ServerRequest([], [], '/', $httpMethod);
+ $handler = $this->createStub(RequestHandlerInterface::class);
+ $handler->method('handle')->willReturn(new EmptyResponse());
+
+ $this->middleware->process($request, $handler);
+
+ self::assertSame([$expectedMethod], $this->middleware->called);
+ }
+
+ #[Test]
+ public function itThrowsDomainExceptionOnUnknownMethod(): void
+ {
+ $request = new ServerRequest([], [], '/', 'TRACE');
+ $handler = $this->createStub(RequestHandlerInterface::class);
+
+ $this->expectException(DomainException::class);
+ $this->expectExceptionMessage('Unsupported HTTP method: TRACE');
+
+ $this->middleware->process($request, $handler);
+ }
+
+ #[Test]
+ public function defaultPassThroughCallsHandler(): void
+ {
+ $middleware = new class() implements MiddlewareInterface {
+ use HttpMethodProcessorTrait;
+ };
+
+ $expectedResponse = new EmptyResponse();
+ $request = new ServerRequest([], [], '/', 'GET');
+ $handler = $this->createStub(RequestHandlerInterface::class);
+ $handler->method('handle')->willReturn($expectedResponse);
+
+ $response = $middleware->process($request, $handler);
+
+ self::assertSame($expectedResponse, $response);
+ }
+}