Repository navigation
Add Symfony 7.4 support - #19
vinayak-iyer-paysera wants to merge 82 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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.
…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>
|
Thank you for the sixth round. I have answered both points in follow-up commits.
Two more fixes in 1e4205e, from my own review:
All 45 workflow jobs pass locally with 364 tests each, and so do 4 installs of FrameworkBundle 6.4 with Edited on 2026-10-14: the docblock count in point 2 said 641, from a scan a day older. A recount of the local |
mSprunskas
left a comment
There was a problem hiding this comment.
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.
…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>
|
Thank you for the seventh round. I have answered the four points in follow-up commits, in your order. Before answering,
All 45 workflow jobs pass locally with 407 tests each, and so do 4 installs of FrameworkBundle 6.4 with One question. The remaining difference in the lenient direction: a tag that resolves to no class is ignored like any |
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>
|
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 |
mSprunskas
left a comment
There was a problem hiding this comment.
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.
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>
|
Thank you for the eighth round. I have answered the four points in follow-up commits, in your order, each with a
All 45 workflow jobs pass locally with 328 tests each, and so do 4 installs of FrameworkBundle 6.4 with One question, about point 3. With Of the 40 internal applications I checked that require the bundle, 22 write its annotations in docblocks and 10 use I kept |
mSprunskas
left a comment
There was a problem hiding this comment.
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.
|
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. |
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
^6.0, and Symfony 7 requiresgetConfigTreeBuilder(): TreeBuilder.composer.json, so a PHP 8.3 install can mix 7.4 components into 6.4.Request::getPreferredLanguage(). Since then,de-DE(from 7.1) andde(from 7.3) loseto the listener's
'default'placeholder.framework.annotationsdisabled, and FrameworkBundle 6.4 withsymfony/routing7. There the loader has no way to readthe bundle's docblock options.
Changes
composer.json.^7.4onframework-bundle,http-kernel,security-bundle,validatorandyaml.^7.0onconfig,dependency-injection,http-foundation,property-access,routingandsecurity-core,which FrameworkBundle and SecurityBundle 6.4 accept at 7.x.
psr/log3.doctrine/annotations ^1.14.4 || ^2.0.2. Earlier releases never refuseopcache.save_comments=0, and read strippeddocblocks as empty.
^2.6.3, and DoctrineBundle^2.5, because 2.4 does not boot on Symfony 6.0.Signatures.
getConfigTreeBuilder(): TreeBuilder, andConfigurationis marked@final.load()gets only@return void. 11 parameters become?Type $x = null.LocaleListener. It picks the locale withRequest::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 languagedefault(default,i-default) now gives the configured locale that follows it. An item read as an empty language(
"",i-,-deon 3.4–7.0;"",0on 7.1) now keeps the default locale. The CHANGELOG lists both underChanged. Configured locales are compared as strings.
docblock_annotationsoption,falseby default. It applies where Symfony passes no annotation reader, fromSymfony 6.4.
falsereads only the attributes.truereads the docblocks of#[Route]actions and of their classes with Doctrine'sAnnotationReader, and failswhere that reader fails: on a tag that is neither imported nor ignored, and when OPcache is loaded with
save_comments=0.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 underResources/configare 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;.container.known_envsparameter thatXmlFileLoaderset is gone.The extension extends
Symfony\Component\DependencyInjection\Extension\Extension, not HttpKernel'sExtension, whichis internal since Symfony 7.1.
Tests and CI.
OPcache settings), mixed routes and the registered services.
7.*runs on PHP 8.2 to 8.4./attributed/, so both kinds of route are tested on 6.4.Docs.
CHANGELOG.md2.0.0. The README showstype: attributeroute imports for Symfony 7 in the setup section andAttribute\Routein 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 from1.8.2 lists 2 changes to the bundle's own API:
TreeBuilderreturn type, which breaks only a subclass that overridesgetConfigTreeBuilder()without it;addAnnotatedClassesToCompile()orgetAnnotatedClassesToCompile().Configurationis@final, so Symfony'sDebugClassLoaderreports any subclass.Where Symfony passes no annotation reader, the bundle ignores its docblock annotations unless
docblock_annotationsistrue. That includes@RequiredPermissions, which 1.x also ignored on 6.4 withframework.annotationsdisabled. Anapplication that moves there from a setup where Symfony's reader applied them (3.4 to 6.4 with
framework.annotationsenabled) 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
defaultor 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:
paysera/lib-normalization1.2.0 installspaysera/lib-object-wrapper0.3.0,whose
ArrayAccessmethods 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
routing7 (7.0, 7.1, 7.3), and withhttp-foundation7 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:
default,i-defaultandi-failwith 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: truegives the same result for 225. The other one is alowercase
@required, listed under Known gaps.The loader runs in a PHP process under four settings:
truewith docblocks kept: the permission applies;truewith 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 -loversrc/reports 0 deprecations (master has 11 implicitly nullable parameters). The changedfiles 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
false. The functional configuration setstrue, so that the@RequiredPermissionsroute keeps answering 401, 403 and 200. Unit data sets pin the default.docblock_annotationsistrue.@IgnoreAnnotationonly in the class docblock. A bundle annotation listed there is skipped unless ausestatement imports it or it is written with its full class name.trueon Symfony 7, a lowercase@requiredon a routed action fails the load. FrameworkBundle 6.4's readerignores it globally; it has been deprecated since 6.3.
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=0is not honoured, as on master.testAnItemOfOnlyASemicolonIsNotALanguageis skipped where Symfony's parser throws on;(http-foundation 4.4 only).PayseraApiExtensionTestcollectsE_USER_DEPRECATEDitself. Turning onconvertDeprecationsToExceptionsinsteadfails 167 of 320 tests on PHP 8.4, all from lib-normalization 1.3.1.
Decisions you may question
docblock_annotationsdefaults tofalse, as round 8 suggested. The reply asks whether 2.0.0 should shiptrueinstead. 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