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

.
refactor(profile,registration): move the last forms to the new validator
The optional profile fields now say so: everything a visitor may leave blank carries allowEmpty, including the email, which the previous ruleset left optional only as a side effect of ModelNotExists finding nothing for an empty value.

The MX check of the address survives as the MxRecord rule, and the consent message again belongs to the rule that needs it rather than to every Identical of the form.
.
refactor(admin,consent): move the remaining admin forms to the new validator
The forum structure form uses ValidationResult::withError() for the cycle check, so a domain failure found outside the ruleset joins the result instead of unpacking it into an array.
.
refactor(forum): move the topic forms to the new validator
The meta fields become explicitly optional. They carried a StringLength with only a max, which the previous engine let an empty value through — under the new null policy a rule means the field is required unless it says otherwise, so allowEmpty keeps them optional instead of silently making them mandatory.
.
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.