История коммитов

.
refactor(contacts): move the contact form to the new validator
The consent message now belongs to the rule that needs it. The previous API took an override for the whole form, so the same text replaced the message of the honeypot field too — a bot tripping the honeypot was told to accept the consent.
.
refactor(guestbook): move the forms to the new validator
The ruleset becomes typed rule objects injected through ValidatorInterface. NotEmpty disappears from the message and name fields: StringLength requires a value on its own now, which is the same verdict by a shorter route.

Flood and Ban keep the _form key they moved to, and the templates were already reading it.
.
feat(validator): add the rules for the new engine
Six wrappers (NotEmpty, StringLength, EmailAddress, InArray, Between, Identical) and six rules of the project (Flood, Ban, Captcha, ModelExists, ModelNotExists, MxRecord), each keeping the msgid it already had — the twenty catalogs need no new string.

NewValidatorCharacterizationTest runs the provider recorded from laminas against the new implementation: all 72 cases answer identically. Three places needed work to get there — Length measures the decimal notation of a number where the old engine rejected the type, Range refuses two bound messages at once, and Choice compares strictly, so InArray is a rule of ours that keeps the loose comparison a select posting "1" against integer ids depends on.

The legacy laminas rules move to Rules/Legacy so the new ones can take their names; the old validator keeps working.

MxRecord restores the DNS check registration, profile editing and the installer used to have, which Symfony has no equivalent for.
.
feat(validator): add the symfony/validator scaffolding
ValidatorInterface with typed rule objects instead of string keys, ValidationResult carrying the error shape every consumer already reads, RuleCompiler, and a gettext translator so the messages keep the msgids that twenty catalogs already translate.

Two behaviours of the previous engine are reproduced deliberately: the rules of a field are wrapped in Sequentially, so a field reports one message rather than one per rule, and a rule requiring a value is prefixed with NotBlank — Symfony's own validators return early on an empty value, which would have turned "this field has a rule" into "it is checked when it is not empty".

Rule factories are tagged, so a module adds a rule without touching the core. No rule ships yet and the legacy validator is untouched: both work side by side.
.
fix(security): send the CSRF token with the CKEditor uploads
The upload adapter of CKEditor sends its own XHR, so the axios defaults never reached it and every upload to /forum/upload_file, /guestbook/upload_file and the comment and admin editors was rejected with 403. It takes the token through its headers option.

Reading the meta tag moves into a csrf module shared by the editors and the axios defaults, so there is one place that knows where the token lives.
.
test(validator): record the current behaviour before replacing the engine
The validator had no tests at all, and the replacement changes exactly what nobody stated: whether an empty value is checked or skipped, whether a comparison is strict, whether a failing chain reports one message or all of them.

RuleBehaviourCases is engine-agnostic — a case names a rule and its options, not a laminas class — so the same provider becomes the acceptance criterion for the new implementation. The rules that need a session, a user or the database are covered separately, with the container swapped through the property di() reads.
.
chore: keep the planning docs local
Plans and analyses under .claude/ are working notes of one contributor, not shared tooling: they stop being versioned and stay on disk. The reviewers and commands under .claude/agents and .claude/commands are unaffected.
.
feat(security): enforce the CSRF check and drop the Csrf validation rule
The check is on: a request with an unsafe method and no valid token is answered 403 (JSON for XHR). The observation flag is gone with it.

The 46 validator calls whose whole ruleset was ['csrf_token' => ['Csrf']] are removed together with the isCsrfValid() wrappers and the now-unused $request parameters; the mixed rulesets lose their csrf_token entry. Flood and Ban move from csrf_token, which was only ever their carrier, to the reserved _form key, and the 12 errors.csrf_token blocks in the templates become errors._form.
.
feat(security): check the CSRF token in the pipeline, in observation mode
CsrfMiddleware runs after TrimStringsMiddleware and before the middlewares of the route, so a forged request never reaches the logic of a module. The token is read from the csrf_token field or the X-CSRF-Token header; a failure is answered 403, negotiated as JSON for XHR because the Vue components read response.data.message.

Ships with enforce = false in config/csrf.php: a failure is only logged and the request is served, so a form that still misses the token surfaces in the log instead of in support. The flag goes away when the check is turned on.
.
feat(router): add the CSRF exemption mechanics
Route::withoutCsrf() and RouteCollection::withoutCsrf() mark a route, a collection or a group as exempt; the flag travels as a route default and reaches the kernel as RouteMatchResult::$csrfExempt. config/csrf.php holds path patterns for the entry points whose routes are not ours to edit. Nothing reads the flag yet — the middleware follows.