diff --git a/.claude/skills/migrate-to-1.0/SKILL.md b/.claude/skills/migrate-to-1.0/SKILL.md
new file mode 100644
index 0000000..c8cc95e
--- /dev/null
+++ b/.claude/skills/migrate-to-1.0/SKILL.md
@@ -0,0 +1,200 @@
+---
+name: migrate-to-1.0
+description: Migrate a project consuming ray/web-form-module from 0.x (Doctrine Annotations) to 1.0 (PHP 8 Attributes). Detects @FormValidation / @InputValidation / @VndError annotations, antiCsrf=true options, AuraInputInterceptor Reader arguments, and FormInterface signature mismatches; rewrites them and reports remaining manual steps.
+---
+
+# Migrate ray/web-form-module 0.x → 1.0
+
+Apply this skill in a project that depends on `ray/web-form-module` and is
+upgrading from `0.x` to `1.0`. Run it from the project root.
+
+## Scope
+
+You will rewrite consumer code only. Do NOT touch files under
+`vendor/`. Limit edits to:
+
+- `src/`, `app/`, `tests/`, `lib/` and similar source roots
+- PHP files only (`*.php`)
+
+Skip generated/cache directories: `var/`, `cache/`, `tmp/`, `build/`,
+`node_modules/`, `.git/`.
+
+## Preflight
+
+Before changing any file:
+
+1. Confirm the project's `composer.json` declares `ray/web-form-module`.
+ If not, abort and tell the user this skill does not apply.
+2. Confirm the working tree is clean (`git status`). If not, ask the user
+ whether to proceed — uncommitted changes will be mixed with this skill's
+ edits.
+3. Bump the constraint to `^1.0` in `composer.json` (`require` section). If
+ `doctrine/annotations` is in `require` and is no longer used elsewhere,
+ remove it.
+
+## Step 1 — Rewrite validation annotations to attributes
+
+For each PHP file under the source roots:
+
+### 1a. `@FormValidation`
+
+Find docblock annotations of the form:
+
+```php
+/**
+ * @FormValidation(form="contactForm", onFailure="badRequestAction")
+ */
+public function createAction()
+```
+
+Rewrite to a native attribute on the method:
+
+```php
+#[FormValidation(form: 'contactForm', onFailure: 'badRequestAction')]
+public function createAction()
+```
+
+Rules:
+
+- Remove the annotation line from the docblock. If the docblock becomes
+ empty (only `/**` and `*/`), delete the entire docblock.
+- Convert `=` to `:` and double-quoted strings to single-quoted PHP strings.
+- Preserve the existing `use Ray\WebFormModule\Annotation\FormValidation;`
+ import. Add it if missing.
+
+### 1b. `@FormValidation(... antiCsrf=true)` — **BC break**
+
+The `antiCsrf` option was removed. Split it into two attributes:
+
+```php
+/**
+ * @FormValidation(form="contactForm", antiCsrf=true)
+ */
+```
+
+becomes
+
+```php
+#[FormValidation(form: 'contactForm')]
+#[CsrfProtection]
+```
+
+Also add `use Ray\WebFormModule\Annotation\CsrfProtection;`.
+
+If `antiCsrf=false` (or omitted), drop the option without adding
+`#[CsrfProtection]` — methods without the attribute perform no CSRF check.
+
+### 1c. `@InputValidation` and `@VndError`
+
+Same treatment as `@FormValidation` (no `antiCsrf` concern).
+
+```php
+/** @InputValidation(form="form1") */
+public function createAction($name) {}
+```
+
+```php
+/**
+ * @VndError(message="...", logref="a1000", path="/p", href={"_self"="/p"})
+ */
+```
+
+become
+
+```php
+#[InputValidation(form: 'form1')]
+public function createAction($name) {}
+```
+
+```php
+#[VndError(message: '...', logref: 'a1000', path: '/p', href: ['_self' => '/p'])]
+```
+
+Note the `{...}` (Doctrine array literal) → `[...]` (PHP array) conversion
+in `VndError`'s `href`.
+
+## Step 2 — Drop `Reader` arguments
+
+`AuraInputInterceptor`, `InputValidationInterceptor`, and `VndErrorHandler`
+no longer accept a `Doctrine\Common\Annotations\Reader`.
+
+Find direct `new` calls and DI module bindings such as:
+
+```php
+new AuraInputInterceptor($injector, $reader)
+new InputValidationInterceptor($injector, $reader, $handler)
+new VndErrorHandler($reader)
+```
+
+Drop the `Reader` argument:
+
+```php
+new AuraInputInterceptor($injector)
+new InputValidationInterceptor($injector, $handler)
+new VndErrorHandler()
+```
+
+If the project bound `Doctrine\Common\Annotations\Reader` in a Ray.Di
+module solely to satisfy these constructors, remove that binding.
+
+## Step 3 — Update `FormInterface` implementations
+
+`FormInterface::input()` and `FormInterface::error()` now declare types:
+
+```php
+public function input(string $input): string;
+public function error(string $input): string;
+```
+
+Any project class implementing `FormInterface` (directly, or via
+`AbstractForm` override) must match these signatures. Update:
+
+```php
+public function input($input)
+public function error($input)
+```
+
+to:
+
+```php
+public function input(string $input): string
+public function error(string $input): string
+```
+
+## Step 4 — Update `ValidationException` callers
+
+`ValidationException::__construct(string $message = '', int $code = 0,
+?Throwable $e = null, ?FormValidationError $error = null)` — the third
+parameter is now `Throwable|null` (was `Exception|null`) and `$error` is
+typed `FormValidationError|null`.
+
+If the project passes a non-`Throwable` value, fix the call site.
+
+## Step 5 — Verify
+
+After all rewrites:
+
+1. Run the project's static analysis (`composer phpstan`, `composer psalm`,
+ `composer cs` — whichever exist).
+2. Run the project's test suite.
+3. `grep -rn "@FormValidation\|@InputValidation\|@VndError\|antiCsrf" src/ tests/ app/ 2>/dev/null`
+ should return nothing relevant.
+4. `grep -rn "Doctrine\\\\Common\\\\Annotations\\\\Reader" src/ app/ 2>/dev/null`
+ should not show usages tied to this package.
+
+Report to the user:
+
+- Files modified (count + paths).
+- Any annotation occurrence you could not rewrite automatically (e.g.,
+ uncommon formatting). List file:line so the user can finish manually.
+- Static analysis / test results.
+
+## Out of scope
+
+- Migrating other libraries' annotations (only `Ray\WebFormModule\Annotation\*`).
+- Renaming `SetAntiCsrfTrait` — the trait and `setAntiCsrf()` method are
+ unchanged in 1.0.
+- Changes to forms that use `AntiCsrf` without `#[CsrfProtection]` —
+ flag these to the user: in 1.0 they will silently stop enforcing CSRF.
+ The user must decide whether to add `#[CsrfProtection]` to the validated
+ controller method or accept the new behaviour.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b0a252d
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+version: 2
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ ignore:
+ - dependency-name: "*"
+ update-types: ["version-update:semver-minor", "version-update:semver-patch"]
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
new file mode 100644
index 0000000..9bd9501
--- /dev/null
+++ b/.github/workflows/coding-standards.yml
@@ -0,0 +1,45 @@
+name: Coding Standards
+
+on:
+ push:
+ paths-ignore:
+ - '**.md'
+ pull_request:
+ paths-ignore:
+ - '**.md'
+ workflow_dispatch:
+ inputs:
+ php_version:
+ default: '8.4'
+
+jobs:
+ coding-standards:
+ name: Coding Standards
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ inputs.php_version || '8.4' }}
+ tools: cs2pr
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --no-interaction --prefer-dist
+
+ - name: Run PHP_CodeSniffer
+ run: ./vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
new file mode 100644
index 0000000..71f9c1f
--- /dev/null
+++ b/.github/workflows/continuous-integration.yml
@@ -0,0 +1,57 @@
+name: Continuous Integration
+
+on:
+ push:
+ paths-ignore:
+ - '**.md'
+ pull_request:
+ paths-ignore:
+ - '**.md'
+ workflow_dispatch:
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ operating-system:
+ - ubuntu-latest
+ php-version:
+ - '8.0'
+ - '8.1'
+ - '8.2'
+ - '8.3'
+ - '8.4'
+ - '8.5'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ coverage: none
+ tools: none
+ ini-values: assert.exception=1, zend.assertions=1
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ ${{ runner.os }}-php${{ matrix.php-version }}-composer-
+ ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer update --no-interaction --no-progress --prefer-dist
+
+ - name: Run test suite
+ run: ./vendor/bin/phpunit
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..1c3a063
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,130 @@
+name: Static Analysis
+
+on:
+ push:
+ paths-ignore:
+ - '**.md'
+ pull_request:
+ paths-ignore:
+ - '**.md'
+ workflow_dispatch:
+ inputs:
+ php_version:
+ default: '8.4'
+
+jobs:
+ static-analysis-phpstan:
+ name: PHPStan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ inputs.php_version || '8.4' }}
+ tools: cs2pr
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run PHPStan
+ run: ./vendor/bin/phpstan analyse -c phpstan.neon --no-progress --no-interaction --error-format=checkstyle | cs2pr
+
+ static-analysis-psalm:
+ name: Psalm
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ inputs.php_version || '8.4' }}
+ tools: cs2pr
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run Psalm
+ run: ./vendor/bin/psalm --show-info=false --output-format=checkstyle --shepherd | cs2pr
+
+ static-analysis-phpmd:
+ name: PHPMD
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ inputs.php_version || '8.4' }}
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run PHP Mess Detector
+ run: ./vendor/bin/phpmd src text ./phpmd.xml
+
+ static-analysis-composer-require-checker:
+ name: ComposerRequireChecker
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ inputs.php_version || '8.4' }}
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Install dependencies
+ run: |
+ composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run composer-require-checker with config
+ run: ./vendor/bin/composer-require-checker check ./composer.json --config-file=./composer-require-checker.json
diff --git a/.gitignore b/.gitignore
index b47a5dd..93dec00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ composer.lock
tests/tmp/*
!tests/tmp/.placefolder
.phpunit.result.cache
+.phpcs-cache
diff --git a/.php_cs b/.php_cs
deleted file mode 100644
index 3518e7e..0000000
--- a/.php_cs
+++ /dev/null
@@ -1,132 +0,0 @@
-setRiskyAllowed(true)
- ->setRules(array(
- '@PSR2' => true,
- 'header_comment' => ['header' => $header, 'commentType' => 'PHPDoc', 'separate' => 'none'],
- 'array_syntax' => ['syntax' => 'short'],
- 'binary_operator_spaces' => ['align_equals' => false, 'align_double_arrow' => false],
- 'blank_line_after_opening_tag' => true,
- 'blank_line_after_namespace' => false,
- 'blank_line_before_return' => true,
- 'cast_spaces' => true,
-// 'class_keyword_remove' => true,
- 'combine_consecutive_unsets' => true,
- 'concat_space' => ['spacing' => 'one'],
- 'declare_equal_normalize' => true,
- 'declare_strict_types' => false,
- 'dir_constant' => true,
- 'ereg_to_preg' => true,
- 'function_typehint_space' => true,
- 'general_phpdoc_annotation_remove' => true,
- 'hash_to_slash_comment' => true,
- 'heredoc_to_nowdoc' => true,
- 'include' => true,
- 'indentation_type' => true,
- 'is_null' => ['use_yoda_style' => false],
- 'linebreak_after_opening_tag' => true,
- 'lowercase_cast' => true,
-// 'mb_str_functions' => true,
- 'method_separation' => true,
- 'modernize_types_casting' => true,
- 'native_function_casing' => true,
-// 'native_function_invocation' => true,
- 'new_with_braces' => false, //
- 'no_alias_functions' => true,
- 'no_blank_lines_after_class_opening' => true,
- 'no_blank_lines_after_phpdoc' => true,
- 'no_blank_lines_before_namespace' => true,
- 'no_empty_comment' => true,
- 'no_empty_phpdoc' => true,
- 'no_empty_statement' => true,
- 'no_extra_consecutive_blank_lines' => ['break', 'continue', 'curly_brace_block', 'extra', 'parenthesis_brace_block', 'return', 'square_brace_block', 'throw', 'use', 'useTrait'],
- 'no_leading_import_slash' => true,
- 'no_leading_namespace_whitespace' => true,
- 'no_mixed_echo_print' => ['use' => 'echo'],
- 'no_multiline_whitespace_around_double_arrow' => true,
- 'no_multiline_whitespace_before_semicolons' => true,
- 'no_php4_constructor' => false,
- 'no_short_bool_cast' => true,
- 'no_short_echo_tag' => false,
- 'no_singleline_whitespace_before_semicolons' => true,
- 'no_spaces_around_offset' => true,
- 'no_trailing_comma_in_list_call' => true,
- 'no_trailing_comma_in_singleline_array' => true,
- 'no_trailing_whitespace' => true,
- 'no_trailing_whitespace_in_comment' => true,
- 'no_unneeded_control_parentheses' => true,
- 'no_unreachable_default_argument_value' => true,
- 'no_unused_imports' => true,
- 'no_useless_else' => true,
- 'no_useless_return' => true,
- 'no_whitespace_before_comma_in_array' => true,
- 'no_whitespace_in_blank_line' => true,
- 'normalize_index_brace' => true,
- 'not_operator_with_space' => false,
- 'not_operator_with_successor_space' => true,
- 'object_operator_without_whitespace' => true,
- 'ordered_class_elements' => true,
- 'ordered_imports' => true,
- 'php_unit_construct' => true,
- 'php_unit_dedicate_assert' => true,
- 'php_unit_fqcn_annotation' => true,
- 'php_unit_strict' => true,
-// 'phpdoc_add_missing_param_annotation' => true,
- 'phpdoc_align' => true,
- 'phpdoc_annotation_without_dot' => true,
- 'phpdoc_indent' => true,
- 'phpdoc_inline_tag' => true,
- 'phpdoc_no_access' => true,
- 'phpdoc_no_alias_tag' => ['property-read' => 'property', 'property-write' => 'property', 'type' => 'var'],
- 'phpdoc_no_empty_return' => true,
- 'phpdoc_no_package' => true,
-// 'phpdoc_no_useless_inheritdoc' => true,
- 'phpdoc_order' => true,
- 'phpdoc_return_self_reference' => true,
- 'phpdoc_scalar' => true,
- 'phpdoc_separation' => true,
- 'phpdoc_single_line_var_spacing' => true,
-// 'phpdoc_summary' => true,
- 'phpdoc_to_comment' => true,
- 'phpdoc_trim' => true,
- 'phpdoc_types' => true,
- 'phpdoc_var_without_name' => true,
- 'pow_to_exponentiation' => true,
-// 'pre_increment' => true,
- 'protected_to_private' => true,
- 'psr0' => true,
- 'psr4' => true,
- 'random_api_migration' => true,
- 'return_type_declaration' => ['space_before' => 'one'],
- 'self_accessor' => true,
- 'short_scalar_cast' => true,
-// 'silenced_deprecation_error' => true,
-// 'simplified_null_return' => true,
-// 'single_blank_line_before_namespace' => true,
- 'single_quote' => true,
- 'space_after_semicolon' => true,
- 'standardize_not_equals' => true,
-// 'strict_comparison' => true,
- 'ternary_operator_spaces' => true,
- 'strict_param' => true,
-// 'ternary_to_null_coalescing' => true,
-// 'trailing_comma_in_multiline_array' => true,
- 'trim_array_spaces' => true,
- 'unary_operator_spaces' => true,
- 'whitespace_after_comma_in_array' => true
- ))
- ->setFinder(
- PhpCsFixer\Finder::create()
- ->exclude('tests/Fake')
- ->exclude('src-data')
- ->exclude('src-deprecated')
- ->in(__DIR__)
- )->setLineEnding("\n")
- ->setUsingCache(false);
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..dde77f0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,79 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0] - unreleased
+
+### Changed
+
+- **BC break**: Minimum PHP version raised to `8.0`.
+- **BC break**: Migrated from Doctrine Annotations to PHP 8 Attributes. All
+ validation metadata (`@FormValidation`, `@InputValidation`, `@VndError`) is
+ now expressed with `#[FormValidation]`, `#[InputValidation]`, `#[VndError]`.
+- **BC break**: CSRF protection for validation methods is now declared with
+ the separate `#[CsrfProtection]` attribute. The previous `antiCsrf=true`
+ boolean option on `@FormValidation` has been removed. CSRF checks are now
+ opt-in: methods without `#[CsrfProtection]` perform no CSRF verification
+ even if the form has an `AntiCsrf` object set.
+
+ Before:
+
+ ```php
+ /**
+ * @FormValidation(form="contactForm", antiCsrf=true)
+ */
+ public function createAction() {}
+ ```
+
+ After:
+
+ ```php
+ #[FormValidation(form: 'contactForm')]
+ #[CsrfProtection]
+ public function createAction() {}
+ ```
+- **BC break**: `AuraInputInterceptor`, `InputValidationInterceptor` and
+ `VndErrorHandler` no longer accept a `Doctrine\Common\Annotations\Reader`
+ in their constructors. Validation attributes are read directly via
+ `ReflectionMethod::getAttributes()`.
+- **BC break**: `FormInterface::input()` and `FormInterface::error()` now declare
+ parameter and return types (`string $input`, `: string` respectively).
+ Implementations must update their signatures.
+- **BC break**: `ValidationException::__construct()` now declares parameter
+ types (`string $message`, `int $code`, `Throwable|null $e`,
+ `FormValidationError|null $error`). The `$error` property is now typed as
+ `FormValidationError|null` via constructor property promotion.
+- Added property type declarations and return types across the codebase to
+ align with PHP 8 typing.
+- Bumped dependencies: `ray/di` `^2.16`, `ray/aop` `^2.14`,
+ `phpunit/phpunit` `^9.5`.
+
+### Fixed
+
+- `Exception\RuntimeException` now correctly extends `\RuntimeException`
+ instead of `\LogicException`.
+- `AntiCsrf::isValid()` uses strict comparison for the CSRF token.
+- Eliminated PHP 8.4 deprecation warnings for implicit nullable parameters in
+ `ValidationException::__construct()` and `VndErrorHandler::makeVndError()`.
+
+### Added
+
+- GitHub Actions workflows for tests and coding standards.
+- `CHANGELOG.md`.
+- `#[CsrfProtection]` attribute for composing CSRF checks with form/input
+ validation attributes.
+
+### Removed
+
+- `doctrine/annotations` dependency.
+- Travis CI configuration; replaced with GitHub Actions.
+
+## [0.6.0] - 2018-05-27
+
+See git history for changes prior to 1.0.0.
+
+[1.0.0]: https://github.com/ray-di/Ray.WebFormModule/compare/0.6.0...1.0.0
+[0.6.0]: https://github.com/ray-di/Ray.WebFormModule/releases/tag/0.6.0
diff --git a/README.JA.md b/README.JA.md
index edcdbb8..c8d4bd6 100644
--- a/README.JA.md
+++ b/README.JA.md
@@ -1,8 +1,8 @@
# Ray.WebFormModule
+[](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml)
+[](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml)
[](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x)
-[](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x)
-[](https://travis-ci.org/ray-di/Ray.WebFormModule)
Ray.WebFormModuleはアスペクト指向でフォームのバリデーションを行うモジュールです。
フォームライブラリには[Aura.Input](https://github.com/auraphp/Aura.Input)を使い、
@@ -124,19 +124,59 @@ class MyController
### CSRF Protections
-CSRF対策を行うためにはフォームにCSRFオブジェクトをセットします。
+CSRF対策は **opt-in** です。`SetAntiCsrfTrait` を使うフォームには `AntiCsrfInterface` が注入されますが、
+トークンの検証は `#[CsrfProtection]` 属性が付いたメソッドでのみ行われます。
+`#[CsrfProtection]` が無いメソッドでは、フォーム側に AntiCsrf がセットされていても CSRF チェックは実行されません。
```php
+use Ray\WebFormModule\AbstractAuraForm;
+use Ray\WebFormModule\Annotation\CsrfProtection;
+use Ray\WebFormModule\Annotation\FormValidation;
use Ray\WebFormModule\SetAntiCsrfTrait;
class MyForm extends AbstractAuraForm
{
use SetAntiCsrfTrait;
+}
+
+class MyController
+{
+ #[FormValidation(form: "contactForm")]
+ #[CsrfProtection]
+ public function createAction()
+ {
+ }
+}
```
セキュリティレベルを高めるためにはユーザーの認証を含んだカスタムCsrfクラスを作成してフォームクラスにセットします。
詳しくはAura.Inputの[Applying CSRF Protections](https://github.com/auraphp/Aura.Input#applying-csrf-protections)をご覧ください。
+## 0.x からのマイグレーション
+
+1.0 で Doctrine Annotations を廃止し、PHP 8 Attributes に完全移行しました。
+型宣言も強化されています。主な書き換え:
+
+| Before (0.x) | After (1.0) |
+|--------------------------------------------------------------------|---------------------------------------------------------------------------|
+| `@FormValidation(form="f", onFailure="badRequest")` | `#[FormValidation(form: 'f', onFailure: 'badRequest')]` |
+| `@FormValidation(form="f", antiCsrf=true)` | `#[FormValidation(form: 'f')]` + `#[CsrfProtection]` |
+| `@InputValidation(form="f")` | `#[InputValidation(form: 'f')]` |
+| `@VndError(message="...", logref="...")` | `#[VndError(message: '...', logref: '...')]` |
+| `new AuraInputInterceptor($injector, $reader)` | `new AuraInputInterceptor($injector)` (`Reader` 引数は不要) |
+| `public function input($input)` / `public function error($input)` | `public function input(string $input): string` / `error(string $input): string` |
+
+破壊的変更の完全なリストは [CHANGELOG.md](CHANGELOG.md) を参照してください。
+
+### Claude Code による自動マイグレーション
+
+リポジトリ同梱の Claude Code skill
+[`.claude/skills/migrate-to-1.0/SKILL.md`](.claude/skills/migrate-to-1.0/SKILL.md)
+が上記の書き換え (アノテーション → アトリビュート、`antiCsrf=true` の
+`#[CsrfProtection]` 分割、`Reader` 引数削除、`FormInterface` 署名更新) を
+AI アシスタントに案内します。利用側プロジェクトの `.claude/skills/` に
+ディレクトリをコピーして `/migrate-to-1.0` で起動してください。
+
## Validation Exception
`#[FormValidation]`の代わりに`#[InputValidation]`とアノテートするとバリデーションが失敗したときに`Ray\WebFormModule\Exception\ValidationException`が投げられるよになります。この場合はHTML表現は使われません。Web APIアプリケーションなどに便利です。
diff --git a/README.md b/README.md
index 36740e2..b880d99 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Ray.WebFormModule
+[](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml)
+[](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml)
[](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x)
-[](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x)
-[](https://travis-ci.org/ray-di/Ray.WebFormModule)
An aspect oriented web form module powered by [Aura.Input](https://github.com/auraphp/Aura.Input) and [Ray.Di](https://github.com/ray-di/Ray.Di).
@@ -12,7 +12,7 @@ An aspect oriented web form module powered by [Aura.Input](https://github.com/au
### Composer install
- $ composer require web-form-module
+ $ composer require ray/web-form-module
### Module install
@@ -136,17 +136,61 @@ or render input element basis.
echo $form->input('name'); //
echo $form->error('name'); // "Name must be alphabetic only." or blank.
```
-## CSRF Protections
+### CSRF Protections
+
+CSRF protection is **opt-in**. A form that uses `SetAntiCsrfTrait` is wired
+with an `AntiCsrfInterface`, but the token is only verified when the
+validated method is annotated with `#[CsrfProtection]`. Methods without
+`#[CsrfProtection]` perform no CSRF check even if the form supports it.
```php
+use Ray\WebFormModule\AbstractAuraForm;
+use Ray\WebFormModule\Annotation\CsrfProtection;
+use Ray\WebFormModule\Annotation\FormValidation;
use Ray\WebFormModule\SetAntiCsrfTrait;
-class MyController
+class MyForm extends AbstractAuraForm
{
use SetAntiCsrfTrait;
+}
+
+class MyController
+{
+ #[FormValidation(form: "contactForm")]
+ #[CsrfProtection]
+ public function createAction()
+ {
+ }
+}
```
You can provide your custom `AntiCsrf` class. See more detail at [Aura.Input](https://github.com/auraphp/Aura.Input#applying-csrf-protections)
+## Migration from 0.x
+
+Version 1.0 drops Doctrine Annotations in favour of native PHP 8 Attributes
+and tightens type declarations. The most common rewrites:
+
+| Before (0.x) | After (1.0) |
+|--------------------------------------------------------------------|---------------------------------------------------------------------------|
+| `@FormValidation(form="f", onFailure="badRequest")` | `#[FormValidation(form: 'f', onFailure: 'badRequest')]` |
+| `@FormValidation(form="f", antiCsrf=true)` | `#[FormValidation(form: 'f')]` + `#[CsrfProtection]` |
+| `@InputValidation(form="f")` | `#[InputValidation(form: 'f')]` |
+| `@VndError(message="...", logref="...")` | `#[VndError(message: '...', logref: '...')]` |
+| `new AuraInputInterceptor($injector, $reader)` | `new AuraInputInterceptor($injector)` (no `Reader` argument) |
+| `public function input($input)` / `public function error($input)` | `public function input(string $input): string` / `error(string $input): string` |
+
+See [CHANGELOG.md](CHANGELOG.md) for the full list of breaking changes.
+
+### Automated migration with Claude Code
+
+The repository ships a Claude Code skill at
+[`.claude/skills/migrate-to-1.0/SKILL.md`](.claude/skills/migrate-to-1.0/SKILL.md)
+that walks an AI assistant through the rewrites above (annotations →
+attributes, `antiCsrf=true` split into `#[CsrfProtection]`, `Reader`
+argument removal, `FormInterface` signature updates). Copy the directory
+into your consuming project's `.claude/skills/` and invoke it via
+`/migrate-to-1.0`.
+
## Validation Exception
When we install `Ray\WebFormModule\FormVndErrorModule` as following,
@@ -188,6 +232,6 @@ More detail for `vnd.error+json` can be added with the `#[VndError]` attribute.
This optional module is handy for API application.
-### Demo
+## Demo
$ php -S docs/demo/1.csrf/web.php
diff --git a/composer-require-checker.json b/composer-require-checker.json
new file mode 100644
index 0000000..2c1e5fd
--- /dev/null
+++ b/composer-require-checker.json
@@ -0,0 +1,13 @@
+{
+ "symbol-whitelist": [],
+ "php-core-extensions": [
+ "Core",
+ "date",
+ "json",
+ "pcre",
+ "Reflection",
+ "SPL",
+ "standard"
+ ],
+ "scan-files": []
+}
diff --git a/composer.json b/composer.json
index e36bd59..6f8b471 100644
--- a/composer.json
+++ b/composer.json
@@ -12,10 +12,17 @@
"aura/input": "^1.2",
"aura/filter": "^2.3|3.x-dev",
"aura/html": "^2.5",
+ "aura/session": "^2.1 || ^4.0",
"ray/aura-session-module": "^1.1"
},
"require-dev": {
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "doctrine/coding-standard": "^14.0",
+ "phpstan/phpstan": "^1.10",
+ "squizlabs/php_codesniffer": "^4.0",
+ "vimeo/psalm": "^5.0 || ^6.0",
+ "phpmd/phpmd": "^2.13",
+ "maglnet/composer-require-checker": "^4.0"
},
"license": "MIT",
"autoload":{
@@ -30,13 +37,20 @@
},
"scripts" :{
"test": ["@cs", "phpunit"],
+ "tests": ["@cs", "@sa", "@test"],
"coverage": ["php -dzend_extension=xdebug.so ./vendor/bin/phpunit --coverage-text --coverage-html=build/coverage"],
- "cs": ["php-cs-fixer fix -v --dry-run", "phpcs --standard=./phpcs.xml src"],
- "cs-fix": ["php-cs-fixer fix -v", "phpcbf src"]
+ "cs": "phpcs",
+ "cs-fix": "phpcbf",
+ "phpstan": "phpstan analyse -c phpstan.neon --no-progress",
+ "psalm": "psalm --show-info=false",
+ "phpmd": "phpmd src text ./phpmd.xml",
+ "crc": "composer-require-checker check ./composer.json --config-file=./composer-require-checker.json",
+ "sa": ["@phpstan", "@psalm"]
},
"config": {
"allow-plugins": {
- "aura/installer-default": true
+ "aura/installer-default": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true
}
}
}
diff --git a/docs/demo/1.csrf/ContactForm.php b/docs/demo/1.csrf/ContactForm.php
index ce91165..aea6dbd 100644
--- a/docs/demo/1.csrf/ContactForm.php
+++ b/docs/demo/1.csrf/ContactForm.php
@@ -1,9 +1,4 @@
response['code'] = 201;
diff --git a/docs/demo/1.csrf/MyModule.php b/docs/demo/1.csrf/MyModule.php
index 295da43..4e35dba 100644
--- a/docs/demo/1.csrf/MyModule.php
+++ b/docs/demo/1.csrf/MyModule.php
@@ -1,9 +1,4 @@
addPsr4('Ray\WebFormModule\\', __DIR__);
use Aura\Input\Exception\CsrfViolation;
diff --git a/docs/demo/1.csrf/web.php b/docs/demo/1.csrf/web.php
index 5bf307b..ef72c18 100644
--- a/docs/demo/1.csrf/web.php
+++ b/docs/demo/1.csrf/web.php
@@ -1,9 +1,5 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src
+ tests
+ */tmp/*
+ */Fake/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..00b655a
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,23 @@
+parameters:
+ level: max
+ reportUnmatchedIgnoredErrors: false
+ paths:
+ - src
+ ignoreErrors:
+ # PHPMD annotations (@SuppressWarnings(PHPMD.X)) are not PHPStan tags.
+ -
+ identifier: phpDoc.parseError
+ message: '#@SuppressWarnings#'
+ # Aura\Html\HelperLocator exposes helpers via __get/__call magic methods.
+ -
+ identifier: method.notFound
+ message: '#Aura\\Html\\HelperLocator::(input|form)#'
+ path: src/AbstractForm.php
+ # SetAntiCsrfTrait is part of the public API for consumers to compose.
+ -
+ identifier: trait.unused
+ path: src/SetAntiCsrfTrait.php
+ # AbstractForm narrows Fieldset::$filter (FilterInterface) to SubjectFilter for typed filter access.
+ -
+ identifier: property.phpDocType
+ message: '#AbstractForm::\$filter#'
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..18f8e1d
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AbstractForm.php b/src/AbstractForm.php
index 2274021..4a0431f 100644
--- a/src/AbstractForm.php
+++ b/src/AbstractForm.php
@@ -2,41 +2,44 @@
declare(strict_types=1);
-/**
- * This file is part of the Ray.WebFormModule package.
- *
- * @license http://opensource.org/licenses/MIT MIT
- */
-
namespace Ray\WebFormModule;
use ArrayIterator;
+use Aura\Filter\Failure\FailureCollection;
use Aura\Filter\FilterFactory;
use Aura\Filter\SubjectFilter;
+use Aura\Html\Exception\HelperNotFound;
use Aura\Html\HelperLocator;
use Aura\Html\HelperLocatorFactory;
use Aura\Input\AntiCsrfInterface;
+use Aura\Input\Builder;
use Aura\Input\BuilderInterface;
+use Aura\Input\Exception\NoSuchInput;
use Aura\Input\Fieldset;
-use Exception;
use Ray\Di\Di\Inject;
use Ray\Di\Di\PostConstruct;
use Ray\WebFormModule\Exception\CsrfViolationException;
use Ray\WebFormModule\Exception\LogicException;
+use Stringable;
+use Throwable;
+use function assert;
+use function is_string;
use function trigger_error;
+use const E_USER_ERROR;
+use const PHP_EOL;
+
+/** @SuppressWarnings(PHPMD.CouplingBetweenObjects) */
abstract class AbstractForm extends Fieldset implements FormInterface
{
/** @var SubjectFilter */
protected $filter;
- /** @var array>|null */
- protected ?array $errorMessages = null;
-
+ /** @var array>|null */
+ protected array|null $errorMessages = null;
protected HelperLocator $helper;
-
- protected ?AntiCsrfInterface $antiCsrf = null;
+ protected AntiCsrfInterface|null $antiCsrf = null;
public function __construct()
{
@@ -50,10 +53,8 @@ public function __clone()
/**
* Return form markup string
- *
- * @return string
*/
- public function __toString()
+ public function __toString(): string
{
try {
if (! $this instanceof ToStringInterface) {
@@ -61,24 +62,21 @@ public function __toString()
}
return $this->toString();
- } catch (Exception $e) {
+ } catch (Throwable $e) {
trigger_error($e->getMessage() . PHP_EOL . $e->getTraceAsString(), E_USER_ERROR);
}
- return '';
+ // Reachable when a custom error handler intercepts E_USER_ERROR without halting.
+ return ''; // @phpstan-ignore deadCode.unreachable
}
- /**
- * @param BuilderInterface $builder
- * @param FilterFactory $filterFactory
- * @param HelperLocatorFactory $helperFactory
- */
#[Inject]
public function setBaseDependencies(
BuilderInterface $builder,
FilterFactory $filterFactory,
- HelperLocatorFactory $helperFactory
+ HelperLocatorFactory $helperFactory,
): void {
+ assert($builder instanceof Builder);
$this->builder = $builder;
$this->filter = $filterFactory->newSubjectFilter();
$this->helper = $helperFactory->newInstance();
@@ -89,29 +87,47 @@ public function setAntiCsrf(AntiCsrfInterface $antiCsrf): void
$this->antiCsrf = $antiCsrf;
}
+ public function enableAntiCsrf(AntiCsrfInterface $antiCsrf): void
+ {
+ $this->antiCsrf = $antiCsrf;
+ if (isset($this->inputs[AntiCsrf::TOKEN_KEY])) {
+ return;
+ }
+
+ $this->antiCsrf->setField($this);
+ }
+
#[PostConstruct]
public function postConstruct(): void
{
$this->init();
if ($this->antiCsrf instanceof AntiCsrfInterface) {
- $this->antiCsrf->setField($this);
+ $this->enableAntiCsrf($this->antiCsrf);
}
}
- /** {@inheritdoc} */
- public function input($input)
+ /** {@inheritDoc} */
+ public function input(string $input): string
{
- return $this->helper->input($this->get($input));
+ $inputHtml = $this->helper->input($this->get($input));
+ assert(is_string($inputHtml) || $inputHtml instanceof Stringable);
+
+ return (string) $inputHtml;
}
- /** {@inheritdoc} */
+ /** {@inheritDoc} */
public function error(string $input): string
{
- if (! $this->errorMessages) {
+ if ($this->errorMessages === null) {
+ /** @var FailureCollection|null $failure */
$failure = $this->filter->getFailures();
- if ($failure) {
- $this->errorMessages = $failure->getMessages();
+ if ($failure === null) {
+ return '';
}
+
+ /** @var array> $messages */
+ $messages = $failure->getMessages();
+ $this->errorMessages = $messages;
}
if (isset($this->errorMessages[$input])) {
@@ -122,16 +138,19 @@ public function error(string $input): string
}
/**
- * @param array $attr attributes for the form tag
+ * @param array $attr attributes for the form tag
*
- * @throws \Aura\Input\Exception\NoSuchInput
- * @throws \Aura\Html\Exception\HelperNotFound
+ * @throws NoSuchInput
+ * @throws HelperNotFound
*/
public function form(array $attr = []): string
{
+ /** @var string $form */
$form = $this->helper->form($attr);
if (isset($this->inputs['__csrf_token'])) {
- $form .= $this->helper->input($this->get('__csrf_token'));
+ /** @var string $input */
+ $input = $this->helper->input($this->get('__csrf_token'));
+ $form .= $input;
}
return $form;
@@ -140,14 +159,14 @@ public function form(array $attr = []): string
/**
* Applies the filter to a subject.
*
- * @param array $data
+ * @param array $data
*
* @throws CsrfViolationException
*/
public function apply(array $data): bool
{
if ($this->antiCsrf && ! $this->antiCsrf->isValid($data)) {
- throw new CsrfViolationException;
+ throw new CsrfViolationException();
}
$this->fill($data);
@@ -158,14 +177,27 @@ public function apply(array $data): bool
/**
* Returns all failure messages for all fields.
*
- * @return list
+ * @return array>
*/
public function getFailureMessages(): array
{
- return $this->filter->getFailures()->getMessages();
+ /** @var FailureCollection|null $failure */
+ $failure = $this->filter->getFailures();
+ if ($failure === null) {
+ return [];
+ }
+
+ /** @var array> $messages */
+ $messages = $failure->getMessages();
+
+ return $messages;
}
- /** Returns all the fields collection */
+ /**
+ * Returns all the fields collection
+ *
+ * @return ArrayIterator
+ */
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->inputs);
diff --git a/src/Annotation/AbstractValidation.php b/src/Annotation/AbstractValidation.php
index b866cf3..8669808 100644
--- a/src/Annotation/AbstractValidation.php
+++ b/src/Annotation/AbstractValidation.php
@@ -2,12 +2,6 @@
declare(strict_types=1);
-/**
- * This file is part of the Ray.WebFormModule package.
- *
- * @license http://opensource.org/licenses/MIT MIT
- */
-
namespace Ray\WebFormModule\Annotation;
abstract class AbstractValidation
diff --git a/src/Annotation/CsrfProtection.php b/src/Annotation/CsrfProtection.php
new file mode 100644
index 0000000..c40a0a4
--- /dev/null
+++ b/src/Annotation/CsrfProtection.php
@@ -0,0 +1,12 @@
+session = $session;
$this->isCli = is_bool($isCli) ? $isCli : PHP_SAPI === 'cli';
}
@@ -40,7 +31,7 @@ public function setField(Fieldset $fieldset): void
->setAttribs(['value' => $this->getToken()]);
}
- /** @param array $data */
+ /** @param array $data */
public function isValid(array $data): bool
{
if ($this->isCli) {
diff --git a/src/AuraInputInterceptor.php b/src/AuraInputInterceptor.php
index 697693a..676ae74 100644
--- a/src/AuraInputInterceptor.php
+++ b/src/AuraInputInterceptor.php
@@ -2,17 +2,14 @@
declare(strict_types=1);
-/**
- * This file is part of the Ray.WebFormModule package.
- *
- * @license http://opensource.org/licenses/MIT MIT
- */
-
namespace Ray\WebFormModule;
+use Aura\Input\AntiCsrfInterface;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
+use Ray\Di\Di\Inject;
use Ray\WebFormModule\Annotation\AbstractValidation;
+use Ray\WebFormModule\Annotation\CsrfProtection;
use Ray\WebFormModule\Exception\InvalidArgumentException;
use Ray\WebFormModule\Exception\InvalidFormPropertyException;
use ReflectionAttribute;
@@ -22,17 +19,27 @@
use function array_shift;
use function property_exists;
+/** @SuppressWarnings(PHPMD.CouplingBetweenObjects) */
class AuraInputInterceptor implements MethodInterceptor
{
protected FailureHandlerInterface $failureHandler;
+ private AntiCsrfInterface|null $antiCsrf = null;
public function __construct(FailureHandlerInterface $handler)
{
$this->failureHandler = $handler;
}
+ #[Inject]
+ public function setAntiCsrf(AntiCsrfInterface $antiCsrf): void
+ {
+ $this->antiCsrf = $antiCsrf;
+ }
+
/**
- * {@inheritdoc}
+ * {@inheritDoc}
+ *
+ * @param MethodInvocation