Skip to content

fix: pluralize only the messages Symfony pluralizes - #22

Merged
66Ton99 merged 2 commits into
Svaroh:mainfrom
66Ton99:fix/plural-message-followup
Aug 25, 2026
Merged

66Ton99 merged 2 commits into
Svaroh:mainfrom
66Ton99:fix/plural-message-followup

Conversation

@66Ton99

@66Ton99 66Ton99 commented Aug 25, 2026 •

Copy link
Copy Markdown

Follow-up to 39493ab, which chose the plural form of a constraint message by the
locale. A review of it turned up one behaviour bug, one BC break, and a gap in
the tests. This fixes all three, plus what a review of this branch turned up.

The convention reached constraints Symfony does not pluralize

getPluralCount() fell back to a naming convention — a minMessage is chosen by
a numeric min — for anything absent from PLURAL_COUNT_OPTIONS. That fallback
is meant for a constraint of an application's own, but it also answered for
Symfony's, whose pluralized messages the table already covers in full:

Constraint Message Convention returned Symfony pluralizes it
Range(min: 5, max: 10) minMessage / maxMessage 5 / 10 no
Count(divisibleBy: 3) divisibleByMessage 3 no
Image(minRatio: 1.5) minRatioMessage 1 no
Image(minWidth: 100) minWidthMessage 100 no

Harmless while those translations carry no |, since a single-part message comes
back unchanged. One that does carry a | is cut at it:

uk, Range(min: 1), 'Значення має бути {{ limit }} або більше (див. А|Б).'
  → 'Значення має бути {{ limit }} або більше (див. А'
en, Range(min: 5), the same string
  → 'Б).'

Symfony's constraints are now answered from the table and from nothing else.
Across all of Symfony's own validators.*.xlf catalogues a | appears in
exactly 11 message ids, and the table has a row for each, so nothing of
Symfony's is lost by this. A constraint pluralized in a later Symfony release
needs a row before it is chosen from, and keeps every form until it gets one —
the behaviour the library had before the table existed, which is the safe side
of the trade.

A constraint that extends one of Symfony's is answered the same way. The
table is matched with instanceof, so AppCount extends Count already took
Count's rows; the guard beside it looked at the class itself, so the same
constraint then took the convention for every message the table does not name.
AppRange extends Range was still choosing a form for its minMessage by
Range::$min. Such a constraint inherits Symfony's options and is validated by
Symfony's validator, which calls setPlural() for the table's rows and nothing
else, so the whole ancestry is looked at now.

translateMessage() takes two arguments again

svaroh_js_form_validator.factory.class is the documented way to replace the
factory, and translateMessage($message, ?array $parameters = null) predates all
of this. A third parameter on it means PHP fatals on any subclass that overrides
the method with the signature it has always had — not a deprecation, a
Declaration ... must be compatible at class-declaration time. 39493ab is not in
any tag yet, so nothing shipped broken and only a dev-main checkout could have
hit it; this keeps the break from reaching a release.

The plural path moved to translatePluralMessage(), which decides the count and
then hands the message to translateMessage() with that count as the %count%
parameter — the call it was making directly is the call translateMessage()
makes. An override therefore sees every message: pluralized, not pluralized,
and the ones no form could be chosen for. Three tests cover it, and declaring
the test's LegacyFactory would itself be a fatal error if the signature ever
slipped again.

A message no form could be chosen for is reported

The catch (\InvalidArgumentException) was silent. Swallowing it is right — an
unfinished translation should not take a form down — but the only trace left was
a message still showing its | separators in the browser. The factory now logs
it through an optional PSR-3 logger, naming the catalogue entry to go and finish.
It is a warning, not a debug note: that message is what the form actually
shows, which is a defect in the rendering rather than a diagnostic detail.

psr/log joins require at ^3.0; it was already present transitively through
symfony/http-kernel. The range is deliberately narrow — the logger the tests
collect records with cannot be declared against psr/log 1, whose
LoggerInterface::log() leaves $message untyped, so narrowing it in a subclass
is a fatal error.

Tests for the table

Every row of PLURAL_COUNT_OPTIONS is load-bearing now that the convention no
longer stands behind it, and one row always was: filenameTooLongMessage is
chosen by filenameMaxLength, which no convention could derive. It had no test.
Neither did Choice, WordCount, or Count::exactMessage. They do now, along
with a constraint extending Range and one extending Count — the latter both
keeping the table's rows and losing the convention for the rest.

The helper the tests parse a constraint through also had to learn that a File
constraint is exported as a plain option list rather than as itself, which is why
a File test could not have been written against it before. It now shares one
validator between the factory and the form, as the container does.

The two tests that asserted on Symfony's own English wording bring a catalogue of
their own now, so a rewording upstream cannot fail them for a reason that has
nothing to do with choosing a form.

Verified locally on PHP 8.5

  • composer test: 95 tests, 268 assertions, green (81 / 250 before).
  • composer phpstan: no errors.
  • composer validate --strict: valid.
  • The test container compiles with the setLogger call, resolving to logger.
  • Removing the namespace guard fails the three original cases and nothing else;
    narrowing it back to the class itself fails exactly the two new subclass cases;
    reaching past translateMessage() again fails exactly the new override test.
    Each test locks its fix rather than the code.

Not changed

prepareMessage() in the browser still splits on |, because the fallback still
needs it. Its two-form rule is now reachable only through a translation with too
few forms, which 3_23.md says.

🤖 Generated with Claude Code

66Ton99 and others added 2 commits August 25, 2026 11:11
The naming convention that covers a constraint of an application's own -- a
"minMessage" is chosen by a numeric "min" -- was also applied to Symfony's own
constraints, which the table in the factory already covers in full. It matched
options that count nothing: Range::$min sits beside a minMessage Symfony never
pluralizes, and so do Image::$minRatio and Count::$divisibleBy. Nothing broke
while those translations held no "|", but one that did was cut at a separator
that was never a plural one, and a Ukrainian Range(min: 1) lost everything
after the first pipe. Answer Symfony's constraints from the table and from
nothing else. A constraint Symfony pluralizes in a later release needs a row
before it is chosen from, and keeps every form until it gets one, which is
where the library stood before the table existed.

translateMessage() takes its two arguments again. The class of the factory is a
documented extension point, "svaroh_js_form_validator.factory.class", and a
factory of an application's own that overrides the method with the signature it
has always had would have died on a fatal error. The plural forms move to
translatePluralMessage(), which falls back through translateMessage(), so an
override still sees every message that is not pluralized and every pluralized
one whose form could not be chosen.

A message no form could be chosen for is now reported to the application
logger, optional and at debug level. It reaches the browser carrying its "|"
separators either way, and nothing else said which entry of the catalogue was
unfinished.

The table gets the tests it was missing. Every row of it is load-bearing now
that the convention no longer stands behind it, and File's
filenameTooLongMessage, chosen by filenameMaxLength, is one the convention
could never have reproduced. The two tests that read Symfony's own English
wording bring a catalogue of their own instead, so a rewording upstream cannot
fail them for a reason that has nothing to do with choosing a form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard that keeps the naming convention off Symfony's own constraints
looked at the class itself, while the table beside it is matched with
"instanceof". A constraint of an application's own that extends one of
Symfony's fell between the two: it took the table's rows, correctly, and
then took the convention for every message the table does not name. An
"AppRange extends Range" was still choosing a form for its minMessage by
Range::$min, and an "AppCount extends Count" for its divisibleByMessage
by Count::$divisibleBy, which is the same cut at a separator that was
never a plural one. Look at the whole ancestry instead: such a
constraint inherits Symfony's options and is validated by Symfony's
validator, which calls setPlural() for the table's rows and nothing
else.

Hand the count to translateMessage() as the "%count%" parameter it has
always been, rather than reaching past it to the translator. The method
is a documented extension point, and routing the plural path through it
costs nothing -- the call it made was the call translateMessage() makes
-- while an override now sees every message rather than only the ones
that carry no plural forms.

Report a message no form could be chosen for as a warning rather than a
debug note. That message is what the form actually shows, separators and
all, so it is a defect in the rendering and not a diagnostic detail.

Require psr/log ^3.0. The wider range promised more than is tested: the
logger the tests collect records with cannot be declared against psr/log
1, whose LoggerInterface::log() leaves $message untyped, so narrowing it
in a subclass is a fatal error.

Share one validator between the factory and the form in the tests, as
the container does, and give a pluralized message its own test for an
overridden translateMessage().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@66Ton99
66Ton99 merged commit 94e9259 into Svaroh:main Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant