Skip to content

Add Symfony 7.4 support - #19

Closed
vinayak-iyer-paysera wants to merge 82 commits into
paysera:masterfrom
vinayak-iyer-paysera:EE-283-symfony-7-support
Closed

vinayak-iyer-paysera wants to merge 82 commits into
paysera:masterfrom
vinayak-iyer-paysera:EE-283-symfony-7-support

Conversation

@vinayak-iyer-paysera

@vinayak-iyer-paysera vinayak-iyer-paysera commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This change makes the bundle install and run on Symfony 7.4, keeps 3.4 to 6.4 working as they do today, and ships as
2.0.0. It needed paysera/lib-dependency-injection 1.5.0, lib-pagination 1.6.0 and lib-normalization-bundle 1.4.0, all
of which are now released.

Why

  • Every Symfony constraint stops at ^6.0, and Symfony 7 requires getConfigTreeBuilder(): TreeBuilder.
  • Seven components the code uses are missing from composer.json, so a PHP 8.3 install can mix 7.4 components into 6.4.
  • Symfony 7.1 and 7.3 rewrote Request::getPreferredLanguage(). Since then, de-DE (from 7.1) and de (from 7.3) lose
    to the listener's 'default' placeholder.
  • In three setups Symfony passes no annotation reader to the route loader: Symfony 7, Symfony 6.4 with
    framework.annotations disabled, and FrameworkBundle 6.4 with symfony/routing 7. There the loader has no way to read
    the bundle's docblock options.

Changes

  • composer.json.

    • ^7.4 on framework-bundle, http-kernel, security-bundle, validator and yaml.
    • ^7.0 on config, dependency-injection, http-foundation, property-access, routing and security-core,
      which FrameworkBundle and SecurityBundle 6.4 accept at 7.x.
    • The seven missing components are now required.
    • psr/log 3.
    • doctrine/annotations ^1.14.4 || ^2.0.2. Earlier releases never refuse opcache.save_comments=0, and read stripped
      docblocks as empty.
    • In require-dev: ORM ^2.6.3, and DoctrineBundle ^2.5, because 2.4 does not boot on Symfony 6.0.
  • Signatures. getConfigTreeBuilder(): TreeBuilder, and Configuration is marked @final. load() gets only
    @return void. 11 parameters become ?Type $x = null.

  • LocaleListener. It picks the locale with Request::getPreferredLanguage(), as 1.x did. The placeholder is now
    '' instead of 'default', so no language tag can match it. As a result, an item that Symfony reads as the language
    default (default, i-default) now gives the configured locale that follows it. An item read as an empty language
    ("", i-, -de on 3.4–7.0; "", 0 on 7.1) now keeps the default locale. The CHANGELOG lists both under
    Changed. Configured locales are compared as strings.

  • docblock_annotations option, false by default. It applies where Symfony passes no annotation reader, from
    Symfony 6.4.

    • false reads only the attributes.
    • true reads the docblocks of #[Route] actions and of their classes with Doctrine's AnnotationReader, and fails
      where that reader fails: on a tag that is neither imported nor ignored, and when OPcache is loaded with
      save_comments=0.
    • Where Symfony passes its reader, the bundle uses it as in 1.x, whatever the option says.
  • Mixed routes. From Symfony 6.4, wherever the bundle reads docblock annotations, a route configured with both
    docblock annotations and attributes that implement the bundle's interfaces, on the action or on its class, fails to
    load with a ConfigurationException. In 1.x, the attributes silently replaced the docblock options.

  • Services. PayseraApiExtension::load() registers them, and the six XML files under Resources/config are removed.
    Definitions, aliases and parameters match the XML ones on Symfony 3.4, 4.4, 5.4, 6.0, 6.4 and 7.4, with two
    exceptions:

    • ErrorBuilder's class name lost its leading backslash;
    • on 7.4.0, the internal .container.known_envs parameter that XmlFileLoader set is gone.

    The extension extends Symfony\Component\DependencyInjection\Extension\Extension, not HttpKernel's Extension, which
    is internal since Symfony 7.1.

  • Tests and CI.

    • New tests cover the locale rule, the docblock reading (unit tests, plus the loader run in a PHP process under
      OPcache settings), mixed routes and the registered services.
    • The test application and the workflow run on every Symfony line. PHP 8.4 is added, and 7.* runs on PHP 8.2 to 8.4.
    • Every job also runs with lowest dependencies where they install and run: 45 jobs, 16 of them lowest.
    • The attribute fixture routes live under /attributed/, so both kinds of route are tested on 6.4.
  • Docs. CHANGELOG.md 2.0.0. The README shows type: attribute route imports for Symfony 7 in the setup section and
    Attribute\Route in the attribute examples. The option has its own README section.

Backward compatibility

This is a major release: PHP 7.1+ and Symfony 3.4+ stay supported, and composer constraints need ^2.0. A check from
1.8.2 lists 2 changes to the bundle's own API:

  • the TreeBuilder return type, which breaks only a subclass that overrides getConfigTreeBuilder() without it;
  • the extension's new parent, which breaks only a subclass that calls addAnnotatedClassesToCompile() or
    getAnnotatedClassesToCompile().

Configuration is @final, so Symfony's DebugClassLoader reports any subclass.

Where Symfony passes no annotation reader, the bundle ignores its docblock annotations unless docblock_annotations is
true. That includes @RequiredPermissions, which 1.x also ignored on 6.4 with framework.annotations disabled. An
application that moves there from a setup where Symfony's reader applied them (3.4 to 6.4 with framework.annotations
enabled) must set the option to keep them; the CHANGELOG says so under Security. From 6.4, a route with both docblock
annotations and attributes fails to load wherever docblocks are read. The locale placeholder changes the result for
items that Symfony reads as the language default or as an empty language, listed under Changed.

Exclusions in the workflow

Every excluded combination either cannot be installed or fails inside a dependency's oldest release:

  • Symfony 5 on PHP 7.1, 6 on PHP 7.x, and 7 on PHP 8.1 and older: Composer cannot install them (PHP version).
  • Symfony 3 on PHP 8.x: Composer cannot install it (doctrine/orm).
  • Symfony 4 lowest on PHP 8.x: Symfony 4's old routing annotation loader misreads the fixture class names.
  • Symfony 5 and 6 lowest on PHP 8.1+: paysera/lib-normalization 1.2.0 installs paysera/lib-object-wrapper 0.3.0,
    whose ArrayAccess methods break on PHP 8.1 (paged queries answer 500). Symfony 6 lowest runs on PHP 8.0.

Test plan

  • All 45 workflow jobs run locally with the workflow's own installs, and the suite passes on each with 328 tests. It
    also passes on 4 installs of FrameworkBundle 6.4 with routing 7 (7.0, 7.1, 7.3), and with http-foundation
    7 on three of them.

  • Round 8: the new data sets fail against the commit before them and pass after. The exceptions pin unchanged
    behaviour, and those fail with the mutant they guard:

    • the reader: 4 on Symfony 7.4, and the same 4 on 6.4;
    • the default: 4 on 7.4 and 1 on 6.4;
    • mixed routes: 2 on 7.4 and 4 on 6.4;
    • the locale: 5 on 7.4, the line where the behaviour changes;
    • the follow-ups: the method's message, 2 on 7.4 and 4 on 6.4. The data sets for default, i-default and i- fail
      with 1.x's placeholder on 3.4, 6.4 and 7.4.

    The tests catch a mutant of each new behaviour: the mixed-route throw, the default, the injected-reader branch, the
    option gate, both annotation filters, the message and the placeholder.

  • Compared with FrameworkBundle 6.4's own reader over 226 routed actions in 224 controllers, on doctrine/annotations
    2.0.2 and 1.14.4, the bundle with docblock_annotations: true gives the same result for 225. The other one is a
    lowercase @required, listed under Known gaps.

  • The loader runs in a PHP process under four settings:

    • attributes only with docblocks stripped, default configuration: the routes load;
    • true with docblocks kept: the permission applies;
    • true with docblocks stripped: the load fails, also with OPcache off for the command line.

    On doctrine/annotations 1.14.0, the stripped case reads empty docblocks, which is why the floor is 1.14.4.

  • On PHP 8.4, php -l over src/ reports 0 deprecations (master has 11 implicitly nullable parameters). The changed
    files also lint on PHP 7.1.

  • A test application on Symfony 7.4 and another on 4.4 install the bundle from this branch: 27 tests each, none
    failing (2 skipped on 4.4).

  • A Symfony 6.4 production application without an annotation reader: 2,195 tests, the same result for each.

  • CI on this pull request after these commits.

Known gaps

  • No booted-kernel test runs Symfony 7 with the default false. The functional configuration sets true, so that the
    @RequiredPermissions route keeps answering 401, 403 and 200. Unit data sets pin the default.
  • doctrine/annotations is abandoned upstream. 2.0.0 uses it only where Symfony passes its reader, or where
    docblock_annotations is true.
  • Doctrine reads @IgnoreAnnotation only in the class docblock. A bundle annotation listed there is skipped unless a
    use statement imports it or it is written with its full class name.
  • With true on Symfony 7, a lowercase @required on a routed action fails the load. FrameworkBundle 6.4's reader
    ignores it globally; it has been deprecated since 6.3.
  • On Symfony 7.1+, getPreferredLanguage() returns Symfony's formatting of a configured locale (es_419 → es,
    en-us → en_US), as 1.x did with http-foundation 7.1+. q=0 is not honoured, as on master.
  • Class-level options of a parent controller never reach its inherited routes, as on master.
  • testAnItemOfOnlyASemicolonIsNotALanguage is skipped where Symfony's parser throws on ; (http-foundation 4.4 only).
  • PayseraApiExtensionTest collects E_USER_DEPRECATED itself. Turning on convertDeprecationsToExceptions instead
    fails 167 of 320 tests on PHP 8.4, all from lib-normalization 1.3.1.

Decisions you may question

  • docblock_annotations defaults to false, as round 8 suggested. The reply asks whether 2.0.0 should ship true
    instead. Pinned by testLoadWithoutAnnotationReaderReadsOnlyAttributesByDefault, data sets "docblock annotation" and
    "annotation on the class and attribute on the action: only the attribute is read".

🤖 Generated with Claude Code

vinayak-iyer-paysera and others added 30 commits September 23, 2026 20:43
…le uses

framework-bundle, security-bundle and validator (and yaml in require-dev) now allow ^7.4.

The code also uses config, dependency-injection, http-foundation, http-kernel, routing,
security-core and property-access directly, but composer.json did not name them. They arrived
through other packages with no limit, so on PHP 8.3 an install next to the 6.4 bundles took
routing, http-foundation and security-core 7.4, and 144 of the 259 tests failed. They are now
required at the same constraint as the bundles.

psr/log also allows ^3.0. The doctrine/orm dev floor moves to 2.6.3: with the lowest
dependencies on PHP 7.4, 2.6.0 failed 140 tests with "continue targeting switch is
equivalent to break".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Symfony axis gains 7.*, excluded on PHP 7.1 to 8.1 because Symfony 7 needs PHP 8.2, so
PHP 8.2 and 8.3 each get one Symfony 7 job with the highest dependencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Symfony 7 reads no Doctrine annotations and dropped two settings the test application used,
so on 7.4 the suite could not boot. Each line now gets what it accepts:

- The fixture entities are mapped in Doctrine XML (one mapping for every Doctrine version)
  instead of @Orm docblocks.
- The routes defined in XML move to explicit_routing.xml. Symfony 7 imports that file and the
  attribute controllers only (sf7_routing.yml); the two docblock-routed controllers that other
  tests rely on get attribute twins with the same paths. The docblock half of
  FunctionalAnnotationsTest is skipped on Symfony 7.
- The Symfony 7 security settings omit enable_authenticator_manager (removed in 7.0).
- The fixture bundle's Configuration declares its TreeBuilder return type.
- HttpKernelHelper looked for the constant "…HttpKernelInterfaceMAIN_REQUEST" (no "::"), so it
  always fell back to MASTER_REQUEST, which Symfony 7 removed.
- EntityValidatorTest no longer marks three cases as asserting nothing: they expect the
  validator to be called once. With PHPUnit 7.5 and Mockery 1.3 those cases were reported as
  risky because Mockery counted its expectations as assertions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Configuration::getConfigTreeBuilder() now returns TreeBuilder: Symfony 7's
ConfigurationInterface declares that type, and without it the bundle cannot be loaded on
Symfony 7 (fatal "must be compatible"). PayseraApiExtension::load() now returns void, which
Symfony 8 requires; Symfony 7.4 only reports the missing type as a deprecation.

A subclass that overrides either method without the type stops compiling. No such subclass
exists in the applications that use the bundle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The locale listener asked Request::getPreferredLanguage() to choose between a placeholder
"default" and the configured locales, and treated "default" as no match. Symfony 7.1 rewrote
that method to match by prefix, so "default" now wins whenever a request asks for German
("default" starts with "de"), and it also ranks the languages differently (en-US now matches
"en" before a de-CH;q=0.9 matches "de").

The listener now matches the header itself with the rule Symfony 3.4 to 7.0 apply: the first
header language, in the client's order of preference, that is a configured locale, where a
regional variant also offers its primary language unless the header lists it. The results are
unchanged below Symfony 7.1. The existing eight test cases stay as they were; five cases are
added, three of which fail on Symfony 7.4 with the old listener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Symfony 7 gives the route loader no Doctrine annotation reader, so on Symfony 7 a controller
routed with #[Route] but configured with the bundle's docblock annotations (@Body, @query,
@PathAttribute, @ResponseNormalization, @RequiredPermissions, @Validation, @BodyContentType)
was loaded with none of those options, without any error: an endpoint protected by
@RequiredPermissions would have answered everyone.

Where the route loader has no annotation reader (Symfony 7 and later), loading such a route
now fails with a ConfigurationException that names the controller method, the annotations it
uses and the attributes to use instead (the #[...] attributes exist since 1.8.0). The
annotations are found without an annotation reader: DocblockAnnotationFinder resolves each
docblock tag through the use imports of the file that declares it. Symfony 4.4 to 6.4 are
unchanged: the reader still applies the annotations there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Eleven parameters in nine files were written "Type $x = null", which PHP 8.4 reports as a
deprecated implicit nullable type. They are now "?Type $x = null": the same type on every
PHP version the bundle supports (7.1 and later), so no caller or subclass is affected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The attribute's first parameter is the options array, so a positional list made the test
fixture fail with a TypeError on Symfony 7.4. The bundle's own fixtures and the one
application using the attribute pass it as permissions: [...].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tests only. The suite executed 910 of the 1,027 executable lines in src/ (88.6 %); the largest
gap was ErrorBuilder, which builds every error response and had no test (30 of 84 lines).

- ErrorBuilderTest: the error code, status and message for each kind of exception the builder
  maps, with the codes the bundle configures, and the API exception's details.
- The guards that were never reached: reading an option before it is set, an unknown path
  attribute resolver type, validation without the validator service, a path attribute or query
  whose type cannot be guessed, explicit arguments winning over the signature.
- RestResponseListener on a request that is not a REST request and on a controller returning
  nothing; RestRequestHelper for a controller with no identifier.
- Two request-body errors in the functional suite: a body without Content-Type where the
  endpoint restricts content types, and a JSON body that does not decode.
- RoutingAttributeLoader on Symfony 6.4 when the application disabled annotations: the
  docblock options stay ignored there, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…test

QueryResolverOptions starts with validation options set, so the guard is only reached once they
are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only internal classes have no file, and they carry no docblocks, so readImports() never met
one; the guard was a line no test could reach. The file name check stays, inside the expression.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…der found them

The Symfony 7 guard missed five forms that Doctrine's reader applies on Symfony 4.4 to 6.4, so
on Symfony 7 such a route still loaded with its options silently dropped: a group import
(use ...\Annotation\{Body, RequiredPermissions}), a comma-separated import, an import on the
namespace line, a full class name without the leading backslash, and an action that comes from
a trait whose file imports the annotation. The finder now reads the use statements in all
these forms, merges the imports of the file that declares the method with the declaring
class's (as Doctrine does for traits), and resolves a name through an import, then the
namespace, then as a fully qualified name.

It also counts an @ only where Doctrine's lexer counts a top-level annotation (at the start of
the docblock text, after whitespace or *), so {@inheritdoc}-style inline tags, e-mail
addresses and annotations nested in another annotation's arguments are no longer reported.
Each file's imports are read once per route loading.

The error message no longer claims the endpoint would run without options when an attribute
is also present, writes the attribute class with its leading backslash, and names the
attribute interface for an application's own annotation class instead of an attribute that
does not exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The locale listener still took the header's languages from Request::getLanguages(). Symfony 7.1
rewrote that method too (it lowercases and reformats the tags), so on 7.1 and later a client
sending "DE" got German where every earlier line kept the default, and "EN,de" got English
instead of German. On http-foundation releases that build that list from the header's array keys
(3.4, 4.4.0 to 4.4.45, 5.4.0 to 5.4.12, 6.0.0 to 6.0.12, 6.1.0 to 6.1.4) a numeric tag came back as
an int and the listener's strpos() threw a TypeError under strict types, where the old listener
returned a locale.

The listener now reads the header through AcceptHeader, whose order is the same on every line,
and writes the tags the way getLanguages() did up to 7.0 ("de-CH" becomes "de_CH", a tag without
a region keeps its case). Five cases are added: "DE" and "EN,de" fail on Symfony 7.4 with the
previous listener, "1" and "de, 1" throw on http-foundation 4.4.0 with it, and "i-cherokee" runs
the i- branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…supports

The test application picked its Symfony 7 configuration by the http-kernel major, while the
bundle decides by what symfony/routing supports. Composer can install routing 7.4 next to
framework-bundle 6.4 (framework-bundle 6.4 allows it), and there the application kept importing
its controllers with the "annotation" route type that routing 7 no longer loads: 144 tests failed,
on master as well. It now asks routing whether its attribute loader still has an annotation reader,
the question the bundle's own loader asks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…test setup

framework-bundle 6.4 installs next to validator 7.4, and it still configures the Email
validator with the "loose" mode validator 7 removed, so every request validating an e-mail
failed with "The defaultMode parameter value is not valid". The Symfony 7 test configuration now
names html5, validator 7's own default: the five validation tests pass on that mix, and nothing
changes on a pure Symfony 7 install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ach their guards

- ErrorBuilderTest builds paysera_api.error_builder from the bundle's own services.xml instead of
  repeating its eight configureError() calls, so a change to the configured error codes shows up.
- The duplicate-annotation test passed on an earlier exception (its first annotation could not
  guess a type) and never reached the guard it is named after; both annotations now name their
  type, and the test expects the guard's message.
- Element types on the new data providers, an imported RuntimeException, and the eight
  implicitly nullable test parameters PHP 8.4 reports written ?Type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… found them

The Symfony 7 check still missed annotations that Doctrine's reader applies on Symfony 4.4 to
6.4, so those routes loaded on Symfony 7 with their options dropped, and it reported text that
Doctrine never read as an annotation.

- Imports: the use statements are read with the PHP tokenizer, as Doctrine's TokenParser does,
  from the class's file up to the class and from the class's namespace declaration on. Comments
  inside use statements, several statements on one line and braced or repeated namespace blocks
  now count as Doctrine counts them; a commented-out import, a trait used in the class body and
  another namespace's import no longer do. A method from a trait also gets the imports of the
  trait's file, as in Doctrine's getMethodImports().
- Docblocks: reading starts at the first "@" after a space, a tab or "*"; after that an
  annotation starts at an "@" after whitespace (a no-break space too) or "*"; a quoted string is
  text; a name followed by "-" is not an annotation; the arguments of an annotation are skipped,
  so an annotation nested in them is not reported as the method's own.

On 19 sample controllers the check now reports what Doctrine's AnnotationReader reports, with
doctrine/lexer 2 and 3; with lexer 1 the no-break space case differs, because lexer 1 does not
read it as whitespace. On 207 controller files of Paysera applications (688 docblocks) the
imports and the annotations found are the same as Doctrine's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nguage item

http-foundation 3.4 gives null for an item that is only ";" and false for a lone quote, and the
listener passed that to strpos() under strict types: "Accept-Language: '" ended in a TypeError
where 1.8.2 kept the locale. The value is read as a string again, as it was before the listener
read the header itself. Two test cases cover a malformed item alone and next to a language.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ErrorBuilderTest covered six of the eight codes in services.xml. It now also checks
invalid_parameters (with its configured message), invalid_state and not_acceptable, so a change
to any configured status or message fails a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… across Symfony lines

The comments said the listener picks the same locale on every Symfony line. It applies the same
rule and tag format on every line; for some malformed headers the releases' own parsing of
Accept-Language still differs (for example "de;q=, en"), as it did before this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s reader

Six rules of the check ran in the suite without a test that fails when they break, or did not
run at all. Each new case gives the result Doctrine's AnnotationReader gives (checked with
doctrine/lexer 2 and 3), and each fails when its rule is taken out:

- a `use function` or `use const` import with an annotation's short name does not replace the
  class import (the statement ends at `function` / `const`);
- a method a class declares over its trait's method resolves through the class file's imports
  only, not the trait file's;
- the parentheses after a class that is not an annotation are read, so an annotation in them
  counts, as in Doctrine;
- reading starts at the first "@" after a space, a tab or "*": an annotation at the start of a
  line before it is skipped, and one after a tab starts the reading;
- a class declared in evaluated code, such as a test double, has no file and gives no imports.

The no-break space fixture moves into WhitespaceBeforeAnnotationController with the tab case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…keeps a name

The quoted-string pattern was written in two regular expressions; it is now one constant. The
class comment said a name followed by "-" is never an annotation, but a "-" that starts a
number keeps it, in Doctrine and in the code. A comment line in the locale listener is rewrapped
to the line length. No behaviour changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On http-foundation 3.4 such an item gives null, which the listener must read as no language; the
existing lone-quote cases cover only false. http-foundation 4.4 fails on an empty item itself,
as with 1.8.2, so the test is skipped where Symfony's own parser throws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n it

On http-foundation 4.4 the parser reads a value that is not there before it throws its
TypeError, and PHP 7.4 reports that as a notice, PHP 8 as a warning. PHPUnit turns either into
an error first, so the test errored on Symfony 4.4 with PHP 7.4 and later instead of being
skipped. It now skips on any failure of Symfony's own parser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he rest of its rules

Two inputs made the Symfony 7 check miss an annotation that Doctrine's reader applies on
Symfony 4.4 to 6.4:

- `@Target( @RequiredPermissions(...) )` where `Target` is an application's annotation class
  found through the namespace: Doctrine ignores tag names such as Target and Required unless
  they are imported or written in full, and then reads what the parentheses hold as top-level
  annotations. The check now skips an annotation's arguments only when its name was imported or
  written in full, where Doctrine always reads them.
- an "@" right after a quote (`see "the notes"@RequiredPermissions(...)`): Doctrine's lexer
  measures a string without its quotes, so the "@" is not glued to it.

New cases pin rules no test protected before, each checked against Doctrine's reader and each
failing when its rule is taken out: an "@" right after a star, a one-letter alias, aliases in a
comma list, an import written with a leading backslash, an alias two namespace levels up, an
import on the class's own line, a docblock comment inside an import, arguments after a space,
a parenthesis inside a quoted argument, an annotation without arguments before another, a ":"
or a negative number after a name, a class docblock resolved in its own namespace and file,
two classes in one file with their own namespace blocks, and a trait whose import wins over the
class's for the trait's method.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ErrorBuilder falls back to a hard-coded 500 for other exceptions, so no test read the status
configured for internal_server_error. A data set with that code now does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The guard skipped on any failure of Symfony's parser, so a later release that broke on ";" would
have skipped the test instead of failing it. Only http-foundation 4.4 fails on an empty item
(3.4, 5.4, 6.4 and 7.4 parse it), so the test now skips on Symfony 4 and runs everywhere else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lves them

Four more inputs where the Symfony 7 check and Doctrine's reader (Symfony 4.4 to 6.4) differed:

- a name written on both sides of a "\" with whitespace, a line break or "*" between them
  (`@REST\ RequiredPermissions`): Doctrine joins the parts, so the check now does too;
- a name with two leading backslashes: Doctrine strips them all;
- a bundle annotation written in another case after an alias (`@REST\requiredPermissions`):
  Doctrine found it once an earlier route had loaded the class, but on Symfony 7 nothing may have
  loaded it, and the autoloader looks for a file in the written case. The check now loads the
  bundle's own annotation classes before resolving names;
- a name with a namespace that is not imported (`@Paysera\...\Query(... @Validation ...)`):
  Doctrine never ignores such a name, so its arguments are skipped again, as for imported names.

The first three made the check miss an annotation Doctrine applies; the fourth made it report a
nested one. New cases also pin that reading does not start at an "@" glued to a quote, and that
a class docblock resolves in the route class's own namespace when the method is inherited. Each
case gives Doctrine's result and fails when its rule is taken out. The class comment now says
which arguments are skipped and in which direction the check can still differ from Doctrine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitHub's setup-php configures the JIT, which PHP turns off with a startup warning when an extension overrides
zend_execute_ex(). Once the test enables OPcache for the command line, that warning came before the script's result in
the merged output, so three data sets failed although their results were right. The test now compares the last line
and shows the whole output when it fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Attribute-only apps with OPcache save_comments=0 can no longer load routes

  src/Service/RoutingLoader/RoutingAttributeLoader.php:105 (checkDocblocksAreKept())

  - Before: with no reader, docblocks were never read, so opcache.save_comments=0 didn't matter. Routes using #[Route] and #[Body] loaded fine.
  - Now: the first route throws a ConfigurationException, so router and cache warmup fail, even when the app has no docblock annotations at all.
  - Status: listed as Breaking in the CHANGELOG, so it's a deliberate choice, but it is still a regression. The check can't be made lazy, because stripped comments look the same as missing ones. Ways out: log a warning instead
    of throwing, add a config switch to turn off docblock reading, or keep it and accept the break.

  2. Class references in annotation arguments fail for same-namespace classes and namespace aliases

  RoutingAttributeLoader.php:212-228 (getResolvableImports()). This is the issue you pasted, confirmed against Doctrine's DocParser::Constant() source.

  - Before: docblock annotations were ignored in this setup, so their arguments were never evaluated.
  - Now: these docblocks are parsed, but __NAMESPACE__ is only set for the bundle's own namespace and namespace-alias imports are dropped:
    - Roles::ADMIN for a same-namespace class, or Sec\Roles::ADMIN with use App\Security as Sec, fails the route load with Doctrine's "Couldn't find constant" error.
    - UserDto::class for a same-namespace class silently becomes "UserDto". Route loading then fails with the misleading "Denormalizer UserDto does not exist".
  - Docs: neither case is listed under Changed in the CHANGELOG.
  - Fix: for each Identifier:: with no import, add an import for a matching same-namespace class or namespace-alias class, as long as it exists and isn't an annotation. Don't set __NAMESPACE__ in general. One catch: the alias
    import also lets @Sec\SomeAnnotation tags resolve, so skip it when the docblock has an @Sec\ tag. Alternatively, document it as Breaking.

vinayak-iyer-paysera and others added 2 commits October 14, 2026 13:08
…ibutes

Since 8b364ff the route load fails where OPcache strips docblocks, also in an application that uses no docblock
annotations. With docblock_annotations: false, the attribute loader reads none of the bundle's docblock annotations, on
@route routes too, so it reads no docblock and does not check OPcache. The default, true, keeps the annotations
applied for the applications that use them. The extension adds the setter call only for false, so the default service
definitions do not change. Before Symfony 6.4 the bundle registers its annotation loader instead and the option has no
effect; the README and CHANGELOG say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Where no annotation reader is available, a class name or constant in an annotation argument resolved only through an
imported class or interface. Roles::ADMIN for a class in the controller's namespace, Sec\Roles::ADMIN through a
namespace alias and self::ROLE failed the route load with "Couldn't find constant", and UserDto::class for a class in
the controller's namespace stayed "UserDto". For each Name:: or Name\Sub:: reference whose first segment is not
imported, the loader now imports that name when the class exists and is not an annotation: relative to the controller's
namespace, or under the imported namespace alias. It skips an alias that the same docblock also writes as a tag, so
another library's annotation reached through that alias stays ignored. self resolves to the declaring class.

The second DocParser pass, which finds a tag written as a bundle annotation's short name that does not resolve to it,
now maps only the names the docblock writes as a tag (@query, not @QueryParam). It mapped every short name the
controller did not import, so since 8b364ff a constant of an imported class that shares an annotation's short name,
such as Validation::ROLE, failed the route load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vinayak-iyer-paysera

vinayak-iyer-paysera commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for the sixth round. I have answered both points in follow-up commits.

  1. Done in c749ac4, as a switch: a docblock_annotations option, true by default. The bundle is public and its
    consumers are unknown, so the default keeps the docblock annotations applied for the applications that use them, and
    the route load still fails when OPcache strips docblocks. An application that uses only the attributes sets
    docblock_annotations: false: the loader then reads none of the bundle's docblock annotations and does not check
    OPcache, so save_comments=0 no longer matters to the bundle. The option takes effect from Symfony 6.4, where the
    bundle decorates the attribute loader. There it also covers @Route routes, and where framework.annotations is
    enabled, Symfony's own annotation reader still needs save_comments. Before 6.4 the option has no effect. The README
    and CHANGELOG say so. The extension adds the setter call only for false, so the default service definitions do
    not change. New tests cover the configuration, the extension and the loader with the option off, with and without an
    annotation reader. With docblocks stripped in a PHP process and the option off, the routes load.
  2. Fixed in 1e4205e, as you suggested. For each Name:: or Name\Sub:: reference in the docblock whose first segment is
    not imported, the loader adds an import when the class exists and is not an annotation: relative to the
    controller's namespace, or under an imported namespace alias. It skips an alias that the same docblock also writes
    as a tag (@Sec\…, in any letter case), so another library's annotation reached through that alias stays ignored.
    __NAMESPACE__ is still set only inside the bundle's annotation namespace. New data sets cover a same-namespace
    ::class, a relative Support\Roles::ADMIN, an aliased Security\Roles::ADMIN and an alias followed by several
    namespace segments. Two more cover an alias used both for a tag Doctrine rejects and for a ::class reference, once
    with the tag in another letter case: the tag stays ignored and the reference stays as written. None of the 644
    docblocks with the bundle's annotations in the local applications uses either form; all 7 class references there
    are imported classes.

Two more fixes in 1e4205e, from my own review:

  • self::ROLE now resolves, as with Doctrine's reader.
  • The second DocParser pass from round 5, which finds a tag written as an annotation's short name that does not
    resolve to it, now maps only the names the docblock writes as a tag (@Query, not @QueryParam). It used to remap
    every short name the controller did not import. So a constant of an imported class that shares an annotation's
    short name, such as Validation::ROLE, failed the route load. With point 2, the new forms would have failed the
    same way whenever their first segment was such a name (Query\Roles::ADMIN).

All 45 workflow jobs pass locally with 364 tests each, and so do 4 installs of FrameworkBundle 6.4 with routing and
security-core at 7.0, 7.1 and 7.3. A Symfony 6.4 production application without an annotation reader gives the same
result for each of its 2,195 tests.

Edited on 2026-10-14: the docblock count in point 2 said 641, from a scan a day older. A recount of the local
applications gives 644 docblocks in 209 files; all 7 class references are still imported classes, so the conclusion is
unchanged.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Medium: a tag through an unimported namespace alias is silently ignored, including @Rest\RequiredPermissions
  src/Service/RoutingLoader/RoutingAttributeLoader.php:202, :214, :288
  - The parser runs with setIgnoreNotImportedAnnotations(true). Doctrine's DocParser then drops @Rest\X when rest isn't imported, without an error.
  - The check after parsing only looks at short names (@RequiredPermissions) and at aliases that the class or one of its traits imports. An alias that nothing imports is never checked.
  - So @Rest\RequiredPermissions({"ROLE_ADMIN"}) in a controller without use … as Rest removes the permission check on Symfony 7. On Symfony 6.4 with the reader, the same code fails to load.
  - The CHANGELOG ("still ignored") and README document this. But it is a gap in the same control that the release's Security section says is fixed.
  - Fix: in parseTags(), find tags matching @<alias>\<bundle annotation short name> where <alias> isn't in $useStatements, and throw ConfigurationException for them, as is already done for short names.

  2. Low–Medium: the app's own RestAnnotationInterface annotations are skipped unless written fully qualified
  RoutingAttributeLoader.php:236, :259, :278
  - getResolvableImports() only keeps a use statement that points into the bundle's annotation namespace or to a class that isn't an annotation. It only sets __NAMESPACE__ for the bundle's own namespace.
  - So use App\Annotation\AuditAccess; with @AuditAccess gives no annotation. The same happens for an annotation class in the controller's own namespace. Doctrine's reader reads both.
  - My earlier "namespace-resolved" finding is the same issue. getUnresolvedAnnotationImports treats <controller ns>\RequiredPermissions as resolved, but the parser never resolves it, so the app's annotation is dropped.
  - RestAnnotationInterface is a public extension point. A custom security or audit annotation would stop working silently when an app moves from 6.4 with the reader to Symfony 7. This is documented in the README, but it fails
    silently.
  - Fix: also import use statements, and resolve controller-namespace names, whose class implements RestAnnotationInterface. Or throw when a tag resolves to such a class but isn't read.
  
  3. Low: the error message is wrong when the use statement names another class
  RoutingAttributeLoader.php:321
  - With use App\Entity\Query; and @Query(...), the error says the tag "does not resolve to a class". It actually resolves to a class that isn't an annotation.
  - The route load still fails as intended (the reader path fails too), so only the wording is wrong. Say "resolves to <class>, not to <bundle annotation>" when the alias is in $useStatements.
  
  4. Cleanup: readLanguages() copies Request::getLanguages()
  src/Listener/LocaleListener.php:74
  - It repeats the same AcceptHeader parsing, the i- handling and the casing. getLanguages() is the same in every supported Symfony version; only getPreferredLanguage() changed. Calling $request->getLanguages() removes the
    copy.

vinayak-iyer-paysera and others added 3 commits October 14, 2026 21:59
…n's own annotations

Where no annotation reader is available, a tag written through a namespace alias that nothing imports, or that is
imported for another namespace, was ignored: @rest\RequiredPermissions without use … as Rest left the route without
its permission check on Symfony 7, where Doctrine's reader fails to load it. A tag written through a namespace alias,
ending in one of the bundle's seven annotation names and resolving to no class, now fails the route load with a
ConfigurationException; another library's tag through an unimported alias stays ignored.

An application's own annotation implementing RestAnnotationInterface was read only when written fully qualified. A
tag now imports it as Doctrine's reader resolves it: through a use statement, in the controller's namespace or relative
to it, or through a namespace alias. Only classes implementing that interface are imported, so other libraries'
annotations stay ignored.

When a use statement resolves one of the bundle's short names to another class, the refusal now names that class
instead of saying the tag does not resolve.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…letter case

The listener copied the Symfony 3.4 to 7.0 version of getLanguages() so that every line would pick the same locale.
It now calls getLanguages() and compares its tags with the configured locales ignoring letter case, and sets the
locale as configured, so a tag in another letter case matches on every line (DE gives a configured de).
getLanguages() still differs between lines: 3.4.34 iterates the header's keys, so a numeric tag comes back as an int;
5.4.50 upper-cases every subtag (zh_HANT_TW); 7.4.20 formats the tag (zh_Hant_TW) and removes duplicates. Comparing
without case covers the first two. From 7.1 getLanguages() drops a numeric region or a variant and keeps an x- tag
and a short i- tag whole, so such a tag can pick another locale than on older lines (es-419 no longer matches es_419,
i-ami no longer matches ami); the CHANGELOG says so and a data set pins the result on each line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…review found

A comparison of the reader-less reading with Doctrine's AnnotationReader over 176 routed methods, and a review of the
round-7 answer, found more cases on the paths that answer touches:
- a constant or ::class of an annotation class in the arguments did not resolve (AppPermission::ROLE);
- a tag resolving into Paysera\Bundle\ApiBundle\Annotation to a class that does not exist was ignored;
- a tag split across lines, written with a space after the backslash or with a doubled backslash got past the
  refusals, while an @ in prose such as `@Api\Body` failed the route load. Tags are now read as Doctrine's lexer reads
  them: from the first tag on, outside double-quoted strings, an @ after whitespace, * or a value's =, comma or {, and
  the name joined across a backslash and a line break. A doubled backslash in a tag of the bundle, in an application's
  own annotation or in a fully qualified tag of the bundle is refused before parsing, where PHP stopped with "Cannot
  redeclare class";
- a class name in the arguments did not resolve when the docblock wrote a tag of the same name inside a quoted string;
- a tag spelling its class in another letter case, in any part of its name, was refused, dropped or applied depending
  on which class PHP had loaded; it is now refused on every load order;
- two tags with one first name resolving in different namespaces dropped one of them; the route load now fails;
- a miscased import was refused with a message naming the same class twice, and an unresolved nested annotation
  failed with Doctrine's syntax error or named the wrong import; the messages now name the cause.

Annotation classes resolve as references unless the docblock also writes them as a tag, which keeps another library's
tag ignored. @IgnoreAnnotation does not exempt the bundle's annotations; a test and the README say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

Thank you for the seventh round. I have answered the four points in follow-up commits, in your order. Before answering,
I compared the reader-less reading with Doctrine's AnnotationReader over 184 routed methods, so the answer covers each
class of input rather than only the case you named; the differences that remain are listed in the description.

  1. Fixed in 92375fb. A tag written through a namespace alias, ending in one of the bundle's seven annotation names and
    resolving to no class, now fails the route load with a ConfigurationException: an alias that nothing imports
    (@Rest\RequiredPermissions without use … as Rest), and an alias imported for a namespace that has no such class.
    fcdc389 extends the same control to a tag that resolves into Paysera\Bundle\ApiBundle\Annotation to a class that does
    not exist, such as an import of a misspelled …\Annotation\RequiredPermission, also inside another annotation.
    Tags are read as Doctrine's lexer reads them, so a name split across lines or written with a space after the
    backslash is refused too, and an @ in prose such as `@Api\Body` is not a tag. Another library's tag through
    an unimported alias stays ignored unless its name is one of the seven (@Lib\Cache loads).
  2. Fixed in 92375fb, as you suggested first: an application's own annotation that implements RestAnnotationInterface is
    read as with Doctrine's reader, through a use statement, in the controller's namespace or relative to it, through a
    namespace alias, or fully qualified. Only classes implementing that interface are imported, so another library's
    annotation in the controller's namespace stays ignored. fcdc389 also resolves constants and ::class of annotation
    classes in arguments (AppPermission::ROLE), which failed with "Couldn't find constant" before; an annotation class
    the same docblock writes as a tag keeps its tag ignored, and an own annotation written with a doubled backslash is
    refused, where PHP stopped with "Cannot redeclare class".
  3. Fixed in 92375fb: when the use statement names another class, the message says the tag resolves to that class, not to
    the annotation. fcdc389 adds a message for an import in another letter case, which otherwise named the same class
    twice, and replaces Doctrine's "[Syntax Error]" for an unresolved nested annotation with the bundle's message.
  4. Changed in 47de41a: the listener calls $request->getLanguages() and compares its tags with the configured locales
    ignoring letter case, so DE now matches a configured de on every line. getLanguages() is not the same on every
    supported line, though: 3.4.34 iterates the header's keys (Request.php:1757), so a numeric tag comes back as an
    int; 5.4.50 upper-cases every subtag (:1727, zh_HANT_TW); 7.4.20 formats the tag and removes duplicates
    (:1741, :1743, zh_Hant_TW). Comparing without case covers the first two. The third remains by design: from 7.1
    getLanguages() drops a numeric region or a variant and keeps an x- tag and a short i- tag whole, so such a tag
    can pick another locale than on 6.4 (es-419 no longer matches a configured es_419, nor i-ami a configured
    ami). The CHANGELOG says so, and a data set pins the result on each line.

All 45 workflow jobs pass locally with 407 tests each, and so do 4 installs of FrameworkBundle 6.4 with routing and
security-core at 7.0, 7.1 and 7.3. A Symfony 6.4 production application without an annotation reader gives the same
result for each of its 2,195 tests. Over 184 routed methods the comparison with Doctrine's reader finds no case where
the bundle drops an annotation the reader applies; the remaining differences are tabled in the description.

One question. The remaining difference in the lenient direction: a tag that resolves to no class is ignored like any
unknown tag, so a forgotten import of an application's own RestAnnotationInterface annotation, one written in another
letter case before its class is loaded, or a bare misspelled @RequiredPermission, goes unnoticed where Doctrine's
reader fails. It follows from ignoring unknown tags, which your round-2 review asked for ("Do not let unrelated docblock
content stop route loading"). The alternative for 2.0.0 is Doctrine's strictness, where every unresolved tag fails the
route load: the reading would then match Doctrine's on 8 more of the 184 routed methods, and a Symfony 7 application
with a stray @note or @SomeCustomTag on a routed docblock would fail to load its routes. I kept the lenient reading;
if you prefer the strict one, I will make that change. The other decisions I kept, with their evidence and the test that
pins each, are in the description under "Decisions you may question".

@vinayak-iyer-paysera
vinayak-iyer-paysera marked this pull request as draft October 15, 2026 06:31
After the round-7 answer, further passes over the follow-up commit found docblock forms
where the reader-less path loaded a route without its @RequiredPermissions while Doctrine's
annotation reader fails, and forms it refused where the reader loads. They came from a
regex that approximated Doctrine's lexer.

DocblockTagReader now reads the tags with Doctrine's DocLexer, the way DocParser finds
them, and a route load fails only where Doctrine's annotation reader fails as well: a name
split by whitespace before a backslash (@rest \RequiredPermissions), a colon in a name
(@RequiredPermissions:admin) and a tag naming the bundle's annotation namespace itself
(@rest RequiredPermissions), for the bundle's annotations and an application's own. An own
annotation reached through class_alias(), and an import in another letter case once its
class is loaded, apply as with Doctrine's reader; a leading backslash on a use statement
is ignored as Doctrine ignores it; a nested tag of the bundle with a doubled backslash is
refused where PHP stopped with "Cannot redeclare class".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

Before your next look: after my round-7 reply I ran two more passes over its follow-up commit. They found docblock forms where, without an annotation reader, the bundle loaded a route without its @RequiredPermissions while Doctrine's reader fails: @Rest\ continued with \RequiredPermissions on the next line, a colon in a name (@RequiredPermissions:admin), and a space or a dot instead of the backslash (@Rest RequiredPermissions). They also found a few it refused where the reader loads: an own annotation through class_alias(), and an import in another letter case once its class is loaded. All of them came from the regex that approximated Doctrine's lexer. Instead of patching it again, 8574742 reads the tags with Doctrine's own DocLexer, as DocParser finds them, and refuses only where Doctrine's reader fails as well, apart from the decisions listed in the description. It also closes the "Cannot redeclare class" gap for a nested annotation with a doubled backslash. The comparison with Doctrine's reader now covers 224 controllers on annotations 1.14 and 2.0, and the description has the updated table. The lenient-vs-strict question from my last reply still stands.

@vinayak-iyer-paysera
vinayak-iyer-paysera marked this pull request as ready for review October 15, 2026 10:13

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 1. A REST attribute on a route silently drops its docblock annotations, including @RequiredPermissions (permission bypass)
  - Where: src/Service/RoutingLoader/RoutingAttributeLoader.php:123-126, :948, and src/Service/RestRequestHelper.php:129
  - Cause: configureRoute() runs loadAnnotations() and then loadAttributes(). Both call setOptionsForRoute(), and both write the same SERIALIZED_REST_OPTIONS_KEY default on the route. If a route has any REST attribute, the
    attribute options replace the docblock options entirely instead of merging with them.
  - Example: a half-migrated controller has @RequiredPermissions in its class docblock and #[Body] on one action. That action runs with no permission check and no error.
  - Why this change makes it worse: before, this only happened on Symfony 6.4 with a reader. The new docblock parser now enables it on Symfony 7 as well.
  - CHANGELOG: the Security entry says the docblock annotations "are now applied", which is not true for mixed routes.
  - Tests: no fixture has both docblock annotations and REST attributes on one route. AttributedDocblockOptionsController uses only the docblock.
  - Fix: merge both sets into one RestRequestOptions before writing it, or throw a ConfigurationException when a route has both. Then add a fixture with both on the same route.

  2. LocaleListener no longer matches a bare language to a configured locale with a region
  - Where: src/Listener/LocaleListener.php:47
  - Example: on Symfony 7.1+, with locales [en_US, lt_LT], a request with Accept-Language: en used to get en_US through prefix matching. It now silently gets the default locale.
  - CHANGELOG: this is not listed under Changed.
  
  3. The docblock_annotations: true default can break apps that don't use docblocks at all
  - Where: RoutingAttributeLoader.php:135
  - Cause: checkDocblocksAreKept() runs for every route whenever there is no annotation reader.
  - Example: a Symfony 7 app that uses only attributes and runs with opcache.save_comments=0 now fails to load its routes.
  - Fix: default the option to false, or only run the check when a docblock actually contains bundle tags.
  
  Design decision

  4. The custom docblock parser duplicates Doctrine's AnnotationReader
  - Where: RoutingAttributeLoader.php:211
  - Problem: about 800 lines reimplement how Doctrine resolves imports and ignored names, and add more than ten heuristic refuse* checks. doctrine/annotations is already a hard dependency.
  - Fix: when no reader is injected, use new AnnotationReader().
  - Payoff: this alone removes findings 5–8 and 11–12 and most of the roughly 150 fixture controllers.

vinayak-iyer-paysera and others added 9 commits October 16, 2026 19:20
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eader is injected

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eader

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ault

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lder, and correct the IgnoreAnnotation notes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and what IgnoreAnnotation skips

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eAnnotation skips an annotation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

Thank you for the eighth round. I have answered the four points in follow-up commits, in your order, each with a
remedy you offered.

  1. Fixed in 6d45dfe. From Symfony 6.4, wherever the bundle reads docblock annotations, a route configured with both
    docblock annotations and attributes fails to load with a ConfigurationException that names the method.
    • "Reads docblock annotations" means Symfony passes its reader (6.4 with framework.annotations), or
      docblock_annotations is true.
    • The check counts the bundle's classes and an application's own RestAnnotationInterface and
      RestAttributeInterface classes, on the action or on its class. Since 896cf5b the message says so.
    • Your example, a class @RequiredPermissions with #[Body] on the action, now fails there instead of running
      without the permission. On master with Symfony's 6.4 reader, the same controller loads with no permission.
    • Under the new default of point 3, Symfony 7 reads no docblocks, so that controller loads with #[Body] only. A
      data set pins this, and my question below is about it.
    • Before 6.4 the bundle's annotation loader reads no attributes, as in 1.x.
  2. Fixed in c128796. The listener calls Request::getPreferredLanguage() again, as 1.x did, so on 7.1+ en, en-GB
    and lt reach a configured en_US or lt_LT again.
    • The placeholder is now '' instead of 'default', so de-DE and de no longer lose to it (the Fixed entry).

    • The placeholder also changes the result for header items that Symfony reads as the language default, or as an
      empty language. A CHANGELOG line under Changed lists them since 896cf5b and 4d5ec4f:

      • default and i-default before a configured locale now give that locale on every line, where 1.x kept the
        default one;
      • "", i- and -de keep the default locale on 3.4–7.0, and so do "" and 0 on 7.1, where 1.x gave the
        configured one.

      I measured each shape on http-foundation 3.4, 4.4, 6.4, 7.0, 7.1, 7.3 and 7.4. Data sets pin default,
      i-default and i-, and they fail with 1.x's placeholder on 3.4, 6.4 and 7.4.

    • This also undoes two things round 7 had changed. The letter-case matching is gone: on 3.4–7.0, DE no longer
      matches a configured de, the same as on master. The copy of getLanguages() is gone too.

    • Against master, the listener now changes only the placeholder, a string cast and round 5's strval.

  3. Fixed in 9e61258. docblock_annotations defaults to false.
    • Where Symfony passes no reader, the bundle then reads only attributes. It runs no OPcache check and parses no
      docblock, so an attributes-only application with save_comments=0 loads its routes. A data set in a PHP subprocess
      with docblocks stripped pins this.
    • Where Symfony passes its reader, the bundle uses it as in 1.x, whatever the option says. Before 6.4 the option has
      no effect.
  4. Fixed in 4225ad0. With docblock_annotations: true and no injected reader, the loader builds one
    new AnnotationReader().
    • The docblock parser, DocblockTagReader and 149 fixture files are gone. The loader went from 953 lines to 161, and
      the pull request shrinks from 216 files to 69.
    • Reading now fails where Doctrine's reader fails: on an unimported tag that is not ignored, and when OPcache runs
      with save_comments=0. That answers my lenient-vs-strict question in favour of Doctrine's strictness, and retires
      round 5's per-SAPI OPcache rule.
    • 5fabfc3 requires doctrine/annotations 1.14.4 or 2.0.2. Earlier releases (1.14.0–1.14.3, 2.0.0, 2.0.1) compare
      save_comments with === 0 and never refuse, so a test with stripped docblocks fails on 1.14.0.
    • Compared with FrameworkBundle 6.4's own reader, 225 of 226 routed actions in 224 controllers give the same result.
      The one difference is a lowercase @required on an action. FrameworkBundle 6.4 ignores it globally (deprecated
      since 6.3, gone in 7.0); the plain reader fails on it.
    • @IgnoreAnnotation: Doctrine reads it only in the class docblock. An annotation of the bundle listed there is
      skipped only when no use statement imports it and it is not written with its full class name. An imported one
      still applies. The notes say so since 4d5ec4f, and data sets pin the unimported and the imported case.

All 45 workflow jobs pass locally with 328 tests each, and so do 4 installs of FrameworkBundle 6.4 with routing 7
(7.0, 7.1, 7.3). A Symfony 6.4 production application without an annotation reader gives the same result for each of
its 2,195 tests. The data sets of the four answers fail against the commit before each. The data sets that pin
unchanged behaviour fail with the mutant they guard. The tests catch a mutant of each new behaviour: the mixed-route throw, the default, the injected-reader branch, the option gate, both annotation
filters, the message and the placeholder.

One question, about point 3. With false, an application that moves to Symfony 7 from a setup where Symfony's
reader applied the annotations (3.4 to 6.4 with framework.annotations enabled) loses its docblock
@RequiredPermissions without an error, unless it sets docblock_annotations: true. The CHANGELOG says so under
Security.

Of the 40 internal applications I checked that require the bundle, 22 write its annotations in docblocks and 10 use
attributes. None mixes the two, and none runs Symfony 7 yet.

I kept false, as you suggested. With true, an upgrading application would keep its docblock permissions, and an
attributes-only one under save_comments=0 would fail to load its routes instead (your point 3). If you prefer true,
I will change the default.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

     Location: src/DependencyInjection/Configuration.php:26, src/Service/RoutingLoader/RoutingAttributeLoader.php:105
     On Symfony 7, or on Symfony 6.4 with framework.annotations disabled, docblock_annotations is false by default. Then getRestAnnotations() returns an empty array without any error, so @RequiredPermissions is dropped and the
     endpoint is open to any authenticated user.
     Fix before release: check the docblock text for the bundle's annotation names, and also confirm the controller imports Paysera\Bundle\ApiBundle\Annotation, so that short names like @Body from other libraries do not match.
     If both are found, throw ConfigurationException and tell the user to set docblock_annotations: true or to use attributes.
  2. Medium. One unknown docblock tag stops all routes from loading
     Location: src/Service/RoutingLoader/RoutingAttributeLoader.php:137
     With the option on, the bare AnnotationReader reads the docblocks of every #[Route] controller, vendor ones included. A tag that is not imported (for example @note) throws AnnotationException, and route loading fails for
     the whole app.
     Fix: only parse controllers that use the bundle's annotation namespace, or catch the exception and rethrow it with context.
  3. Low to medium. The mixed-configuration check breaks the whole router
     Location: src/Service/RoutingLoader/RoutingAttributeLoader.php:79
     One controller that mixes a class-level docblock annotation with a method attribute throws during route loading, so every route in the app returns 500, not just that controller's routes.
     Fix: accept it as a documented break, or make the error message clearer.
  4. Low. The class docblock is parsed again for every action
     Location: src/Service/RoutingLoader/RoutingAttributeLoader.php:111
     The reader has no cache, so a controller with 20 actions has its class docblock parsed 20 times.
     Fix: cache the result per class name, or wrap the reader in PsrCachedReader.
  5. Low. docblock_annotations: true sometimes does nothing, with no warning
     Location: src/DependencyInjection/PayseraApiExtension.php:263
     On Symfony before 6.4 the value is dropped. When Symfony passes its own reader, that reader is used instead.
     Fix: document this, or log a warning.
  6. Low. New untested CI combinations
     Location: .github/workflows/ci.yml:47
     Removing the exclusion adds PHP 8.0 runs against Symfony 5 and 6 (including the end-of-life 6.0.x). These may fail to install and turn CI red for reasons unrelated to this change.
     Fix: run CI once and add the exclusion back if needed.
  7. Low (tests). The paged query test starts the kernel and database before it decides to skip
     Location: tests/Functional/FunctionalPagedQueryTest.php:39, tests/Functional/FunctionalTestCase.php:57
     makeTest() boots the kernel and adds 100 entities, then may skip.
     Fix: add "if ($this->kernel === null) { return; }" at the top of tearDown(), then call skipUnlessRoutesAreLoaded() before setUpFor(). Moving the skip without the guard makes every skipped data set fail with "Call to a
     member function getContainer() on null".
  8. Nit. The docblock_annotations setting is stored as bool|null
     Location: src/Service/RoutingLoader/RoutingAttributeLoader.php:41
     The setter is only ever called with true.
     Fix: pass $config['docblock_annotations'] directly and type the property as bool = false.

@vinayak-iyer-paysera vinayak-iyer-paysera mentioned this pull request Oct 20, 2026
12 tasks done
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

Superseded by #21, which restarts the Symfony 7.4 support on a fresh branch and maps every review point from this pull request to its resolution in its description.

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.

2 participants