Repository navigation
Add Symfony 7.4 support (2.0.0) - #21
Conversation
40f6396 to
574c864
Compare
e7e0c6e to
6297e5c
Compare
mSprunskas
left a comment
There was a problem hiding this comment.
High: a permission check can disappear without any error
1. Bundle attributes, including #[RequiredPermissions], are ignored on Symfony 5.4–6.3
- Where: src/DependencyInjection/PayseraApiExtension.php:242, src/Service/RoutingLoader/RoutingAnnotationLoader.php:38-60, README.md:660
- What I checked:
- When AttributeRouteControllerLoader doesn't exist (Symfony below 6.4), the extension registers RoutingAnnotationLoader.
- That loader's configureRoute() reads only $this->reader->getClassAnnotations() and getMethodAnnotations(). It never reads PHP attributes.
- Symfony's parent loader still reads #[Route] natively on PHP 8. So the route loads, but #[RequiredPermissions], #[Body] and the other bundle attributes are dropped.
- Is it new? No. 1.8.0 had the same class_exists switch in the old XML setup. But the new README says the attributes exist "since 1.8.0" and doesn't mention that they need Symfony 6.4. The 2.0.0 Security entry fixes the same
kind of silent drop, but only for docblocks.
- Fix: in RoutingAnnotationLoader::configureRoute(), check for RestAttributeInterface attributes on PHP 8. Either throw a ConfigurationException or apply them. Also state in the README and CHANGELOG that bundle attributes
need Symfony 6.4 or later.
2. Annotations used through a namespace alias are missed (RoutingAttributeLoader.php:259)
- use App\Annotation as Api; with /** @Api\AdminOnly */ is not detected, so the route loses its REST options and permissions.
3. Annotations from the controller's own namespace, used with no use, are missed (RoutingAttributeLoader.php:230)
- Doctrine resolves these through __NAMESPACE__, but the pre-scan doesn't.
The README lists cases 2 and 3 as known limitations. They still count as High because what silently disappears is a permission check.
Medium
4. The pre-scan reimplements part of Doctrine's import resolution with regexes (RoutingAttributeLoader.php:230)
- This is the root cause of findings 2, 3 and 5.
- Fix: use a DocParser with setImports(<imports> + ['__NAMESPACE__' => $ns]) and setIgnoreNotImportedAnnotations(true), then keep only the RestAnnotationInterface results.
5. is_a($import, …, true) can crash cache warmup (:260)
- It autoloads any imported class whose short name appears as an @Tag in the file.
- If that class's parent isn't installed (for example, an optional integration), cache warmup stops with a fatal "class not found" error that isn't wrapped.
Low
6. Inconsistent error wrapping (:135)
- The $this->reader path (Symfony 6.4 with annotations enabled) is not wrapped in the descriptive ConfigurationException.
- Errors thrown by annotation constructors aren't wrapped on either path.
7. Each file is read and tokenized twice (:269)
- Once by PhpParser, then again in getDocComments.
- Do it in one token_get_all pass, cached per file.
8. Duplicated logic (:174)
- filterRestAnnotations repeats the filtering in RoutingAnnotationLoader::configureRoute. Extract a shared helper so the two loaders don't drift apart.
9. The README XML example imports the same directory twice (README.md:178-183)
- The type="annotation" and type="attribute" lines are shown in one block, and nothing says they are alternatives.
- If a reader copies both: Symfony 6.4 loads the controllers twice, and Symfony 7 fails on type="annotation".
- Fix: put each line in its own block, as the YAML examples already do.
0bac26a to
78f4376
Compare
|
@mSprunskas thanks for the review. All nine points are addressed in 78f4376, and CI passes all 45 jobs. The "Review on this PR" table in the description has the per-finding details. In short:
1.x won't get a patch. The last 2.0.0 CHANGELOG Security entry lists the three 1.x setups that still drop permission checks silently, and points users to 2.0.0. Could you take another look? |
78f4376 to
701edbc
Compare
|
@mSprunskas a follow-up to my comment above, now pushed as 701edbc:
Tests: 345 pass locally on each of PHP 8.3 and 8.4 / Symfony 7.4, 8.3 / 6.4, 8.0 / 6.0 lowest, 7.4 / 5.4, and 7.1 / 3.4 lowest. CI passes all 45 jobs. |
mSprunskas
left a comment
There was a problem hiding this comment.
1. Low: the README's attribute examples don't work on Symfony 5.4 to 6.3
- README.md:155, :276 and :414 import Symfony\Component\Routing\Attribute\Route, which exists only from Symfony 6.4.
- README.md:667 says the attributes apply on every supported Symfony version, and composer.json allows ^5.4|^6.0.
- On Symfony 5.4 to 6.3 with PHP 8, Symfony skips the unknown attribute without an error, so the endpoint returns 404.
- Fix: add a note that before Symfony 6.4 the examples need Symfony\Component\Routing\Annotation\Route.
2. Low: an empty i- item in Accept-Language stops the locale from being picked on Symfony 3.4 to 7.0 (src/Listener/LocaleListener.php:47)
- Symfony reads the item i- as the language '', which is now the same as the placeholder. For Accept-Language: i-, de with locales: [de], the request keeps the default locale; 1.x picked de.
- It needs a malformed header, so it's an edge case. Still, it's a regression that CHANGELOG.md doesn't mention, and the test at tests/Unit/Listener/LocaleListenerTest.php:183 expects this result.
- Fix: use ',' as the placeholder. No header item can contain it, because Symfony splits items on commas. Then change that test case to expect de.
3. Note: array_push() with one argument warns on PHP 7.1 and 7.2 (src/Service/RoutingLoader/DocblockAnnotationReader.php:145)
- This is real, but your paste is right that nothing reaches it today. On PHP 7, Symfony either always passes a reader (3.4 and 4.x) or, with no reader, loads no routes, so getTraits() never runs. I'm lowering this from the
severity my review gave it.
- Fix: $traits = array_merge($traits, $this->getTraits($trait)); costs nothing, and it removes the warning for any future caller.
4. Note: the new Symfony 7 test fixtures use XML (tests/Functional/Fixtures/config/sf7_routing.yml:2,6 and the new explicit_routing.xml)
- The changelog says you removed the bundle's XML files because Symfony 7.4 deprecates XML, yet the new Symfony 7 test config imports XML route files. This affects tests only: the 7.4 jobs log deprecation notices that can
hide new ones.
- The fixture bundle's XmlFileLoader (PayseraFixtureTestExtension.php:30) was already there before this change, so only the new routing fixtures count against this change.
- Fix: write the Symfony 7 route fixtures in YAML or PHP.
701edbc to
169fb70
Compare
…ncies Every Symfony constraint gains the 7.x range: ^7.4, the supported Symfony 7 line, for framework-bundle, security-bundle, validator and http-kernel, and ^7.0 for the other components FrameworkBundle 6.4 already accepts at 7.x. The seven components the code uses directly are required explicitly, psr/log 3 is accepted, and doctrine/annotations starts at 1.14.4 / 2.0.2, the first releases whose reader refuses opcache.save_comments=0. The workflow adds PHP 8.4 and Symfony 7.*, and runs every line with lowest dependencies where that combination installs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
Symfony 7 declares getConfigTreeBuilder(): TreeBuilder on ConfigurationInterface, so Configuration must too; the class is marked @Final since the return type breaks an override without it. The eleven optional parameters typed as `Type $x = null` become `?Type $x = null`, which PHP 8.4 deprecates otherwise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
Symfony 7.4 deprecates the XML configuration format and 8.0 removes its loader. The extension now builds the same 24 definitions (23 in one container, which registers one of the two route loaders), the alias, the tags and the parameters with the Definition API, which exists unchanged from Symfony 3.4 to 7.4, and the six XML files are removed. A test dumps every definition and compares it with a snapshot of what the XML produced. The extension extends the DependencyInjection component's Extension rather than HttpKernel's, which is internal since Symfony 7.1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
169fb70 to
3089bf6
Compare
Symfony 7.1 rewrote Request::getPreferredLanguage() to match configured locales by prefix, so the language `de` matched the placeholder `default` first and the listener kept the default locale for de-DE (and for de from 7.3). No language starts with a comma, and no unquoted Accept-Language item contains one, as Symfony splits the header on commas, so a comma is the placeholder now. An empty one would match the item `i-`, which Symfony 3.4 to 7.0 reads as an empty language. Configured locales are compared as strings and empty ones are dropped, so a null entry neither breaks the typed Request::formatLocale() of Symfony 7.4 nor matches the item `i-`. The tests' HttpKernelHelper looked the constant up without `::`, so it always used MASTER_REQUEST, which Symfony 7 removed; it finds MAIN_REQUEST now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
…s none On Symfony 7, and on Symfony 5.4 with PHP 8 or Symfony 6 with framework.annotations disabled, the route loader has no annotation reader, and 1.x silently dropped the bundle's docblock annotations, @RequiredPermissions among them, or failed to load any route. DocblockAnnotationReader now reads them with Doctrine's AnnotationReader where Doctrine would find them: where a lenient Doctrine DocParser, given Doctrine's imports and namespace for the class or action docblock, finds a RestAnnotationInterface annotation or fails to parse it, or where the class, the declaring class or a trait in the action's file imports Paysera\Bundle\ApiBundle\Annotation, so a misspelt annotation there still fails. Where OPcache strips doc comments, Doctrine's reader refuses to run, so a file importing the annotation namespace fails loudly; an annotation used there without that import is not detected, a documented limit. RestRouteConfigurator, shared by both route loaders, wraps read errors in a ConfigurationException naming the action, applies the bundle's attributes on PHP 8 before Symfony 6.4 too, fails on an attribute named under Paysera\Bundle\ApiBundle\ that is none of the bundle's (a misspelt name or namespace), and refuses a route configured with both docblock annotations and attributes, where the attributes used to replace the docblock options silently, or be ignored before Symfony 6.4. The functional test application runs on Symfony 7 with attribute routes, XML entity mapping and a #[Route] action whose @RequiredPermissions must still answer 401, 403 and 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827
3089bf6 to
827116a
Compare
|
@mSprunskas thanks. All four points are addressed in 827116a. CI passes all 45 jobs.
Before pushing, I reviewed the whole PR against master, not only the change since your review, and checked every claim in the README, CHANGELOG, commit messages and description against the vendored Symfony versions. The fixes:
One known case is now documented rather than detected. Without a reader, an annotation behind a misspelt import ( The description has a row per point. |
mSprunskas
left a comment
There was a problem hiding this comment.
Low: a bundle attribute written without its use import is ignored, so the permission check is silently dropped
- Where: src/Service/RoutingLoader/RestRouteConfigurator.php:116-119 (the attribute lookup) and :134-147 (failOnUnknownBundleAttributes()).
- What happens: In namespace App\Controller; without use Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions;, PHP reads #[RequiredPermissions(permissions: ['ROLE_ADMIN'])] as App\Controller\RequiredPermissions. I checked
on PHP 8.3: getAttributes(RestAttributeInterface::class, IS_INSTANCEOF) returns 0 attributes, and getName() returns App\Controller\RequiredPermissions. That name doesn't start with Paysera\Bundle\ApiBundle\, so the new
check doesn't catch it either.
- Impact: The route loads without the permission check and nothing reports an error. 1.x behaves the same, so it's not a regression. But it falls outside what the 2.0.0 Security section and the README promise: a misspelt
name is caught, a missing import is not. The docs list this gap for docblock annotations (CHANGELOG.md:52, README.md:688) but not for attributes (CHANGELOG.md:26, README.md:673-675).
- Fix (recommended): In failOnUnknownBundleAttributes(), also fail when the attribute's class doesn't exist and its short name matches one of the bundle's attributes: Body, BodyContentType, PathAttribute, Query,
RequiredPermissions, ResponseNormalization, Validation. Limiting it to classes that don't exist means an app's own App\Attribute\Body still works. If you'd rather not fix it, document the gap for attributes next to the
docblock one.
PHP resolves an attribute written without its use statement against the controller's namespace, so #[RequiredPermissions] in App\Controller names App\Controller\RequiredPermissions. getAttributes() skips that class, and the namespace check misses it, so the route loaded without its permission check. A route now fails to load when an attribute's class does not exist and its short name, compared case-insensitively, is one of the bundle's seven attributes. An existing class of that name is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The data sets now name the missing classes in App\Controller, as PHP resolves an attribute written without its use statement, so a check that skips the short name fails them, and an attribute of an uninstalled library under another name still loads. The message says the class cannot be loaded, which also holds for an interface, a trait or a package that is not installed. CHANGELOG and README name the uninstalled-package case and each name that is still not detected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The refusal no longer asks "did you mean" the bundle's attribute: for an attribute of a package that is not installed it now says to import the bundle's attribute only if that is the one meant. The docs describe the check as the message does (a name that is not a class that can be loaded, which covers interfaces and traits) and say which aliases go undetected. A class-level attribute of another library still loads, and the provider names the glob pattern when it finds no attribute. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
For a qualified name such as #[REST\RequiredPermissions] without its alias import, importing the bundle's attribute changed nothing, so the message now says what the name must resolve to. The docs state exactly what goes undetected: a class that does not exist, is not named under the bundle's namespace and whose own name is none of the seven, however it is written or imported. The class-level attribute of another library gets its own fixture, so each data set that must still load tests one input. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@mSprunskas thanks. Fixed as you recommended, in 5835363, 1383b20, c37e156 and f2e782c. CI passes all 45 jobs.
One question. Your rule also refuses an attribute of a package that is not installed where the routes load, when its |
mSprunskas
left a comment
There was a problem hiding this comment.
1. High: on Symfony 7, a docblock tag that resolves to the bundle's attribute class is skipped, and the route loads without its permission check
src/Service/RoutingLoader/DocblockAnnotationReader.php:192-216, together with :173-186
- mayHaveRestAnnotations() pre-parses with setIgnoreNotImportedAnnotations(true). With that flag, Doctrine returns nothing for a tag whose class exists but has no @Annotation, and it doesn't throw.
Attribute\RequiredPermissions has no @Annotation.
- importsAnnotationNamespace() matches only ...\Annotation. So use ...\Attribute\RequiredPermissions; together with @RequiredPermissions(...) returns false on both checks.
- The result: no error, and no permission check on the route. On Symfony 6.4 with Symfony's own reader, the same code fails with a semantical error, so only Symfony 7 is silent. A developer who changes the use statement but
doesn't convert the docblock is exactly the person who hits this.
- The CHANGELOG "Not detected" list doesn't cover this case, and no fixture tests it.
- Fix: in mayHaveRestAnnotations(), return true when a tag resolves through the imports to a class under Paysera\Bundle\ApiBundle\. AnnotationReader then reads the docblock and loading fails. I'd use that namespace rule
rather than the pasted report's short-name rule: matching short names would bring back the false positives in #3. Add a fixture for both use ...\Attribute\RequiredPermissions and use ...\Attribute as Rest.
2. Medium: a DocParser failure on any docblock is treated as "uses REST annotations"
src/Service/RoutingLoader/DocblockAnnotationReader.php:205
- The catch returns true, so the full AnnotationReader reads the docblock and throws.
- Example: a controller that uses only attributes but has @OA\Get docblocks from a dev-only swagger-php stops the router from building in prod. 1.x loaded it fine.
- Interaction with #1: don't simply skip the docblock on failure, because that turns some mistakes into silent bypasses. On failure, return true only if a tag in the docblock resolves to the bundle's namespace (the same rule
as #1). Otherwise skip it.
3. Medium: failOnUnknownBundleAttributes flags third-party attributes
src/Service/RoutingLoader/RestRouteConfigurator.php:164
- The check throws for any attribute whose class can't be loaded if its short name is one of the seven bundle names, whatever its namespace. #[OA\Query] from a dev-only package therefore stops all routes from loading in
prod.
- Fix: flag a name only when it resolves into the controller's own namespace, which is what a missing use statement produces.
4. Medium: importing the annotation namespace sends every action to an unconfigured AnnotationReader
src/Service/RoutingLoader/DocblockAnnotationReader.php:74
- One unused use ...\Annotation\X; means every action docblock goes through a plain reader. Any tag it can't resolve then breaks loading.
- Fix: parse with unresolvable annotations ignored, but only for annotations outside the bundle's namespace, so #1 stays caught. Keep only RestAnnotationInterface results.
This reverts 5835363, 1383b20, c37e156 and f2e782c. The check refused any attribute whose class cannot be loaded and whose name, without its namespace, is one of the bundle's seven, whatever namespace it is in. An attribute of a package that is not installed where the routes load, such as swagger-php's #[OA\Query], stopped every route from loading. The next commit replaces it with a check limited to the namespace the attribute is written in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PHP resolves an attribute written without its use statement in the namespace of the file it is written in, so #[RequiredPermissions] in App\Controller names App\Controller\RequiredPermissions, which getAttributes() skips, and the route loaded without its permission check. A route now fails to load when an attribute's class cannot be loaded, its name without its namespace is one of the bundle's seven attributes, and the name lies in the namespace of the class it is written in (for an action, of its declaring class or a trait in its file) or below it; in the global namespace, only a name without a namespace part. An attribute imported from a package that is not installed, such as swagger-php's #[OA\Query], is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@mSprunskas thanks. I reverted round 3's short-name check and replaced it with your namespace rule from #3, in two
1, 2 and 4 are about the docblock pre-scan, which this push does not change. I will answer them separately. |
Where Symfony injects no annotation reader into the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with framework.annotations disabled), the bundle no longer reads the docblock annotations itself: DocblockAnnotationReader, its lenient DocParser pre-scan and its own AnnotationReader go. Instead, DocblockAnnotationGuard reads the source of the routed class, of the class declaring the action, of the action and of every trait they use, in one token_get_all() pass, and stops route loading with a ConfigurationException naming the action, the file and the finding when a file imports from Paysera\Bundle\ApiBundle\Annotation or has a docblock tag named like one of the bundle's annotations, in any letter case, split across lines too. Reading the files means opcache.save_comments=0 cannot hide an annotation; a file that cannot be read or matched fails the load as well. Where Symfony injects a reader, Doctrine reads the annotations as before. Not detected: an application's own annotation class implementing RestAnnotationInterface. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Doctrine strips any number of leading backslashes from a tag, and joins a name split at a backslash across docblock lines only where a name follows, with blanks as its lexer's u mode sees them. The guard allowed one backslash and joined any backslash with the blanks after it, so a tag written with two backslashes, a tag after a line of prose ending with a backslash, and a name split at a no-break space were applied by Doctrine and passed the guard. A docblock with invalid UTF-8 is matched byte by byte. The message for a tag names the remedy for another library's annotation of the same name. ext-tokenizer is required directly instead of through doctrine/annotations. Not detected, now documented with the remedy: an alias of a bundle annotation class made with class_alias(); an application's own annotation class, for which an attribute implementing RestAttributeInterface is applied on every path. Tests: the Symfony 7 docblock fixture moves to Controller/Docblock, its kernel config imports sf7_common.yml, generated files are removed after the run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Doctrine lexes a docblock with invalid UTF-8 from its first tag, in u mode, so a name split across lines at a no-break space after a line of Latin-1 prose was applied by Doctrine and passed the guard, whose byte-mode join only knew ASCII blanks. The byte-mode join now treats every non-ASCII byte before a name as a blank, and so also refuses such a split in a docblock Doctrine reads nothing from. The remedy for another library's annotation of the same name was appended to every tag finding, the bundle's own fully qualified name included; it is now appended only when the tag is not written with the bundle's full name. The README and CHANGELOG list the refusals beyond Doctrine with what to do about them, the tag in a docblock Doctrine reads for no route and the tag in a docblock with invalid UTF-8 included; the CHANGELOG lists ext-tokenizer among the direct requirements. Tests: the invalid UTF-8 shapes are generated with byte escapes instead of a Latin-1 fixture file, which .editorconfig declares UTF-8; every refused row of the guard's test asserts the whole message, remedy included or not; the remedy test uses another library's tag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@mSprunskas #1, #2 and #4 of your round of 2026-10-22, in three follow-up commits on
The four questions I had left open about the pre-scan (R2.1/R9.2, R5.4, R4.2/R5.2a/R7.1, |
Doctrine lexes identifiers with the i and u flags, under which a-z also matches the long s (U+017F) and the Kelvin sign (U+212A). A tag written through an alias of a parent namespace spelt with one of them, such as @Apiſ\Annotation\RequiredPermissions after use Paysera\Bundle\ApiBundle as Apiſ, was applied by Doctrine and passed the guard, whose name segments allowed ASCII only. Segments now take any non-ASCII byte, so a tag path with other non-ASCII letters is refused where Doctrine reads nothing; the README names that. In the other direction the guard refused bundle names Doctrine never reads: a tag right after a backtick, a parenthesis, a brace or a quote in prose, and a name followed by a dash that no digit follows. A tag now starts only after a blank, an asterisk, a double quote or a non-ASCII byte, and a name followed by a dash is not one unless a digit follows the dash, which Doctrine reads as a number. The double quote stays because Doctrine reads "x"@tag as a tag. A source file that reflection cannot name is mapped to null where the files are collected, instead of in a later closure; the whitespace collapse in expandImports() goes, since the import parser already writes every blank as one space. Tests: a row per alias shape (long s, Kelvin sign, an umlaut that Doctrine does not accept), the prose and dashed shapes as passing rows, the dash-and-digit shape as a refused row, the quoted-string shape, a comma-separated use statement, the two byte-mode join shapes that no row pinned, and the no-break-space split generated with an escape instead of a fixture holding the raw byte. The three application-annotation fixtures no test referenced become passing rows. The unreadable-source tests assert the whole message, and a built-in class drives the branch for a class without a source file. The loader test's data provider returns the message parts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@mSprunskas one more follow-up commit,
|
mSprunskas
left a comment
There was a problem hiding this comment.
1. Low: one LocaleListener test case will fail after the next Symfony 7.4 patch release
- Where: tests/Unit/Listener/LocaleListenerTest.php:135, the case 'a tag that a configured locale starts with, from Symfony 7.1 matched to that locale'.
- Confirmed: On 2026-10-25 Symfony merged commit f753a76fa2 ("[HttpFoundation] Match whole subtags in Request::getPreferredLanguage()") into the 7.4 branch. In that version, getPreferredLanguage() matches with $locale ===
$combination || str_starts_with($locale, $combination.'_'). For the header d with locales [',', 'de'], de no longer matches, so the method returns $locales[0], which is ,. The listener then leaves the locale unchanged, but
the test expects de. The latest release, v7.4.21 (2026-10-08), still has the old matching, so CI passes today. The CI matrix includes symfony: '7.*' jobs, so those jobs will fail once v7.4.22 is published.
- Not affected: The production code works with both versions, because the , placeholder works either way. I traced the en, en-GB and lt cases against the new code and they give the same results.
- Fix: Remove the case, or accept both de and unchanged on Symfony 7.1 and later. The expected value should not depend only on whether formatLocale() exists.
2. Note: doctrine/annotations is abandoned but stays a hard requirement
- Where: composer.json:38
- Confirmed: Packagist lists 2.0.2 as the last version and marks the package abandoned: true. Symfony 7.4's AttributeClassLoader no longer has a reader, so on Symfony 7 the bundle never reads docblock annotations. The only
reference in src/ is the nullable ?Reader type at src/Service/RoutingLoader/RestRouteConfigurator.php:7. A nullable type of a class that is not installed does not cause an error.
- Impact: Composer shows an "abandoned" warning on every Symfony 7 install. There is no runtime effect.
- Fix: Nothing is needed for 2.0.0. Later, you could move the package to suggest, or keep it and say why in the CHANGELOG.
Summary
Supersedes #19 with a fresh branch from master: the same goal (install and run on Symfony 7.4, keep 3.4 to 6.4 working,
ship as 2.0.0), six commits with each review round answered in follow-up commits, and the route loader built the way the
reviews asked for: Symfony's own annotation reader where it passes one, a refusal where it passes none, no
re-implementation of Doctrine, and no configuration option.
Why
^6.0. Symfony 7 requiresgetConfigTreeBuilder(): TreeBuilder, 7.4 deprecatesXML configuration and 7.1 makes HttpKernel's
Extensioninternal.framework.annotationsdisabled, Symfony passes noannotation reader to the route loader. 1.x then dropped the bundle's docblock annotations,
@RequiredPermissionsamong them, without an error on 6.4 and 7, and failed to load any route before 6.4.
Request::getPreferredLanguage(); since thende-DE(anddefrom 7.3) lost to the listener's'default'placeholder.Changes
composer.json:^7.4on framework-bundle, security-bundle, validator and http-kernel;^7.0on the componentsFrameworkBundle 6.4 accepts at 7.x, which are now required explicitly;
psr/log3;doctrine/annotations ^1.14.4 || ^2.0.2(the first releases whose reader refusesopcache.save_comments=0).Signatures:
Configuration::getConfigTreeBuilder(): TreeBuilder(class@final),PayseraApiExtensionextendsthe DependencyInjection component's
Extension, 11 optional parameters declared?Type $x = nullfor PHP 8.4.Services registered with the
DefinitionAPI in the extension; the six XML files are removed.PayseraApiExtensionTestcompares every definition, alias, tag and parameter with a snapshot of what the XML produced (updated only for the new loader services).LocaleListener:,as the placeholder (NO_LOCALE_MATCHED), which no unquotedAccept-Languageitem cancontain; configured locales are compared as strings and empty ones are dropped, so a
nullentry neither breaksSymfony 7.4's typed
Request::formatLocale()nor matches the itemi-.RestRouteConfigurator(new,@internal), shared by both route loaders. It reads the docblock annotations withthe reader the loader holds, and the bundle's attributes on PHP 8, and wraps any error in a
ConfigurationExceptionnamingthe action. It also filters the
RestAnnotationInterfaceresults, refuses a route with both, and sets the options.RoutingAnnotationLoader(Symfony < 6.4) therefore applies attributes now too. An attribute named underPaysera\Bundle\ApiBundle\that is none of the bundle's (a misspelt name or namespace, in any case) fails insteadof being skipped, and so does one of the bundle's attributes written without its
usestatement: a name whose classcannot be loaded, whose short name is one of the seven (compared case-insensitively), and which lies in the namespace
of the class or trait it is written in, or below it. An attribute imported from a package that is not installed, and a
class of that name that can be loaded, are left alone. Docblock annotations are read with the reader Symfony
passes; where it passes none, the guard below refuses them.
Guard (
DocblockAnnotationGuard, new,@internal): where Symfony injects no reader (Symfony 7; 6.4 withframework.annotationsdisabled; 5.4 on PHP 8), the bundle does not read docblock annotations and refuses them instead.It reads the source of the routed class's file, the declaring class's file, the action's file and every trait file these
classes use, in one
token_get_all()pass per file, and stops route loading with aConfigurationExceptionnaming theaction, the file and the finding when (a) a
usestatement imports anything fromPaysera\Bundle\ApiBundle\Annotation(plain, aliased, group use, namespace alias), or (b) a doc comment holds a tag named like one of the seven annotations
at tag position, in any letter case, a name split across lines joined as Doctrine's lexer does (any number of leading
backslashes, a no-break space between the parts, invalid UTF-8 matched byte by byte). A file that cannot be read, a
class without a source file and a
pregfailure fail the load too. Nothing is resolved or loaded: the guard looks forthe bundle's own names only.
It refuses more than Doctrine would have read, each loud and named in the message: an unused import of the annotation
namespace, a tag of another library named like one of the seven, a trait with docblock annotations next to the action,
a bundle tag in a doc comment Doctrine does not read (a property, another method), a bundle tag at the very start of a
line or right after a double quote or a non-ASCII character, which Doctrine reads in some of these places and skips in
others, a tag path with non-ASCII characters beyond the two Doctrine accepts inside a name, a bundle tag in a docblock
with invalid UTF-8 that Doctrine reads nothing from. Not detected, documented in the CHANGELOG and README: the
application's own annotation class implementing
RestAnnotationInterface, which nothing in the controller's filereveals (the remedy is an attribute implementing
RestAttributeInterface, applied on every path; none of our 40applications defines such an annotation), and an alias of a bundle class made with
class_alias(). Reading the filesmakes
opcache.save_comments=0irrelevant; the subprocess test pins that attribute-only routes load and the annotatedcontrollers are refused with the comments stripped. The scan over the 42,044 PHP files of our 40 applications flags
exactly the 209 files that import the annotation namespace, nothing else, and 0 in the 8 applications that run without
a reader today.
Tests: the functional application runs on Symfony 7 (attribute routes, XML entity mapping, and a
#[Route]action whose@RequiredPermissionsstops route loading with the guard's message; on 6.4 with a reader thesame action answers 401, 403 and 200); unit tests for both loaders, the configurator and the guard with 66
fixture files: group use, aliases, namespace aliases, the own namespace, fully qualified and split names, lowercase
imports and tags, an unused import, nested and renamed traits, parent-only import, the application's own
annotation, tag-shaped text in strings, docblock references to the attribute classes and another library's tag,
unloadable tagged classes, a class without a source file, an unreadable file, a
pregfailure, invalid and misspeltattributes, attributes written without
their
usestatement (each of the seven names, in lowercase, qualified without its alias import, on the class, in atrait of another namespace, in the global namespace) next to attributes of other libraries and of packages that are not
installed, native
#[Route]before 6.4, and a subprocess with
opcache.save_comments=0. The functional suite also pins the 400 answers for amissing Content-Type and a body that is not JSON, and the duplicate-
Bodytest now checks its message, so both keepfailing for the right reason on Symfony 7. The Symfony 7 route fixtures are YAML. CI adds PHP 8.4, Symfony 7.* and
lowest-dependency jobs.
Docs: README shows which route
type:to import per Symfony version, which#[Route]class to import(
Attribute\Routefrom 6.4,Annotation\Routeon 5.2–6.3), and that docblock annotations are refused without areader.
CHANGELOG 2.0.0.
Backward compatibility
A major release.
roave/backward-compatibility-checkfrom 1.8.2 to this branch reports two changes in the bundle's ownAPI: the
TreeBuilderreturn type and the extension's parent (its other lines are Symfony'sBundleclass between the6.4 and 7.4 installs). Behaviour changes, listed under Security and Changed in the CHANGELOG: reader-less setups now
refuse the bundle's docblock annotations, and an import of its annotation namespace, instead of dropping
them; the bundle's
attributes are applied before Symfony 6.4 too (1.x ignored them there); mixed routes are refused on PHP 8 (before,
attributes silently replaced the docblock options from 6.4 and were ignored below it); read errors are wrapped in a
ConfigurationException; an attribute named underPaysera\Bundle\ApiBundle\that is none of the bundle's failsroute loading, and so does one of the bundle's attributes written without its
usestatement (one of the seven names,in the namespace it is written in, whose class cannot be loaded); the locale placeholder (a header listing
defaultori-defaultbefore a configured locale now selects that locale).1.x is not patched. The permission-check fixes are not backported. 1.x still drops them without an error in four
setups: Symfony 6.4 with
framework.annotationsdisabled; the bundle's attributes on PHP 8 before Symfony 6.4; routeswith both docblock annotations and attributes; and a bundle attribute written without its
usestatement. The last CHANGELOG Security entry says so and points to 2.0.0.Review on this PR (2026-10-20)
RoutingAnnotationLoaderapplies them throughRestRouteConfiguratoron PHP 8, with the mixed check;RoutingAnnotationLoaderTest(native#[Route], runs on PHP 8 / Symfony 5.2–6.3); README and CHANGELOG say 1.8 needs 6.4@Api\AdminOnlymissed0639f49: nothing in the controller's file reveals the application's own annotation class. Documented in the CHANGELOG and README with the remedy, an attribute implementingRestAttributeInterface, which is applied on every path; none of our 40 applications defines such an annotation. Passing data sets "through an alias of a parent namespace" and "through a lowercase alias" pin itusemissed0639f49; passing data set "in the namespace of the controller"0639f49nothing is resolved: where Symfony passes no reader the guard refuses on the bundle's own names in the source text, imports and tags, a text check rather than a parser, measured against Doctrine'sAnnotationReaderover 416 shapes on five lexer versions (0 shapes Doctrine applies that the guard passes, the documented own-annotation andclass_alias()cases excepted). The lenientDocParserpre-scan is goneRoutingAttributeLoader(Symfony ≥ 6.4) andRoutingAnnotationLoaderon Symfony 5.4 with PHP 8 or 6.0–6.3, so always on PHP ≥ 8.0, where a missing parent is a catchableError(it is from PHP 7.3). It parses only the class and action docblocks Doctrine reads, so a broken class tagged in an unrelated docblock is never loaded (data set, with an@noteon the route too). Tagged on the action →ConfigurationExceptionnaming it. On PHP 7, Symfony passes its reader (or, with annotations disabled, registers no route loader to decorate), which the bundle uses as 1.x didRestRouteConfiguratorwraps every read: the injected reader, the own reader, the Symfony < 6.4 reader, annotation constructors, and attribute instantiation;RestRouteConfiguratorTestPhpParserreads the use statements, once per classRestRouteConfiguratorholds filtering, attributes, mixed check and options for both loaders (a service: no traits in production code)Also newly detected: a fully qualified name split across docblock lines, and the application's annotations fully
qualified without an import (data sets for both). Since
0639f49the pre-scan and the own reader these resolutionsdescribe are gone: the reader-less path refuses instead (2026-10-23, #1, #2 and #4 below).
Review on this PR (2026-10-21)
#[Route]examples need Symfony 6.4Attribute\Routefrom 6.4,Annotation\Routeon 5.2–6.3, a docblock@Routebefore 5.2. Our fixture comment and this description said "6.2": the comment is removed, and this description says 6.4i-, dekeeps the default locale on 3.4–7.0,as the placeholder; the test case expectsdeon every version. Against master's listener on Symfony 3.4, 5.4, 6.0 and 6.4, 22 header × locale cases give the same locale exceptdefault, de(the intended fix) and a quoted","item: a quoted item can contain a comma (HeaderUtils::unquote()), so",", destill keeps the default locale. No placeholder can avoid that from 7.1, where any quoted string is an item; only a deliberately quoted value reaches it. CHANGELOG says "no unquoted item"array_push()with an empty spread warns on PHP 7.1/7.2array_merge();DocblockAnnotationGuardTestruns the guard on PHP 7, and fails withArgumentCountErroron 7.1 against the old lineexplicit_routing.yml, andsf7_routing.ymlimports the attribute directory directly. Booting the Symfony 7.4 test kernel from a clean cache: the routing XML deprecation is gone (it fires twice with the old fixtures), and so isproperty_info.with_constructor_extractor, from our ownsf7_common.yml. Left, all older than this PR: the test bundle'sservices.xml, threeAttributed*service ids without\Attribute\(dc7601c), andintlmissing in my imageBefore pushing, I reviewed the whole PR against master instead of only the change since your review: every lens of
our review checklist plus an audit of each claim in the README, CHANGELOG, commit messages and this description
against the code and the vendored Symfony 3.4, 5.4, 6.0, 6.4 and 7.4. Fixed from it:
framework.annotationsdisabled (on PHP 7 Symfony registers no route loader there); Doctrine resolves an action's docblock from the declaring
class and the trait declaring it;
type: attributefrom 6.1 also loads docblock routes up to 6.4 (Symfony 7 reads only#[Route]) and 6.4deprecates
type: annotation; a missing parent stops PHP on 7.1 and 7.2 (checked on 7.1 to 8.0); and stale lines herestripos), and so is anullconfigured locale (strval, whichSymfony 7.4's typed
formatLocale()needs, then dropped so it cannot matchi-); comments that explained code are gone; the placeholder is a namedconstant
0639f49: an annotation behind a misspelt import (use …\Annotations as REST;) is refused by its tag name,like any tag named like one of the seven; before, the lenient
DocParsertreated an imported alias whose class does notexist like a missing import, so it was documented as not detected
Review on this PR (2026-10-22)
usestatement is ignored5835363. Its rule by short name alone also refused attributes of packages that are not installed, so on 2026-10-23 it is reverted and replaced by your namespace rule (below)Review on this PR (2026-10-23)
d3c1a6e) and replaced by your namespace rule (320bd5c): refused only where the class cannot be loaded, the short name is one of the seven, and the name lies in the namespace of the class or trait it is written in, or below it (in the global namespace, a name with no namespace part; a qualified name there is read as complete, so#[Rest\RequiredPermissions]without its alias import is listed as not detected). That covers#[Rest\RequiredPermissions]without its alias import and an action of a trait from another namespace.#[OA\Query]of an uninstalled package loads, in a namespace and in the global namespace, and so does a class of that name that can be loaded. Data sets for each of the seven names and each shape, written inApp\fixtures| 1 | a docblock tag resolving to the bundle's Attribute class is skipped | ✅ |
0639f49: the reader-less path no longer reads docblocks; the guard refuses a tag named like one of the seven whatever it would resolve to (data sets with the Attribute class imported, aliased and fully qualified) || 2 | a
DocParserfailure is treated as "uses REST annotations" | ✅ |0639f49: no parser; a file that cannot be read, a class without a source file and apregfailure stop route loading with aConfigurationException(three tests) || 4 | importing the annotation namespace sends every action to an unconfigured
AnnotationReader| ✅ |0639f49: no own reader; the import is itself refused, with the file named (useplain, aliased, group use, namespace alias; an unused import too) |The three fixes are one removal:
DocblockAnnotationReader(ownAnnotationReader+ lenientDocParserpre-scan) isreplaced by
DocblockAnnotationGuard(Changes, above);7708e60matches tags as Doctrine's lexer does (two leadingbackslashes, a backslash ending a line of prose before the tag, a no-break space inside a split name: shapes a
differential against Doctrine's
AnnotationReaderfound before the push) and declaresext-tokenizer;2667bc9joins a namesplit at a no-break space after a line of invalid UTF-8, which Doctrine lexes from its first tag (the one escape a second
pass over 27 more shapes found), extends the invalid-UTF-8 over-refusal (a bundle tag in a docblock Doctrine reads nothing from) to such split
names, and appends the alias remedy only when the tag is not written with the bundle's full name;
2877c09lets a name segmentcarry non-ASCII bytes, because Doctrine's lexer runs with the
ianduflags, under whicha-zalso matches the long s and the Kelvin sign (aparent-namespace alias spelt with one of them was applied by Doctrine and passed the guard: the one escape a review pass over
416 shapes on five lexer versions found), and stops refusing bundle names after prose punctuation or followed by a dash that no digit
follows, which Doctrine never reads. Refusing where no reader exists is what the first review on #19
and R9.1 proposed; 2.0.0 being a major made it possible. "Import or name" rather than R9.1's "import and name": R7.1's
unimported
@Rest\RequiredPermissionsand a fully qualified tag have no import to confirm.In our 40 applications on this bundle, every use of the seven attribute names resolves to the bundle's class, so the
attribute check refuses nothing there, and the guard flags exactly the 209 files that import the annotation namespace.
Review points from #19
All 59 points of the nine reviews on #19 (no inline comments there), checked against this branch.
✅ addressed · 📄 trade-off you offered, now documented · ❓ please confirm (a choice you did not offer).
Please confirm
bundle attribute on a parent controller is not read for its children's routes. Docblock annotations on a parent are
not read either, because Doctrine reads only the class's own docblock, and neither are Symfony's own route
attributes. So
#[RequiredPermissions]on an abstract base controller protects nothing. Should 2.0.0 read theparents' class attributes, or document that they are not inherited?
The four questions about the pre-scan (R2.1/R9.2, R5.4, R4.2/R5.2a/R7.1,
opcache.save_comments=0) went with it.All 59 points
DocblockAnnotationGuard; unit "annotation on the action"; functionaltestDocblockOptionsOnAttributeRouteAreRefusedWithoutReaderon Symfony 7, 401/403/200 with a reader on 6.4usestatement from tokens: plain, aliased, group use, namespace alias; "annotation written through an alias of a parent namespace" by rule (b)load(): voidPayseraApiExtension::load(); 2.0.0Annotation\RouteAttribute\Route, except theNativeRoute*ones ofRoutingAnnotationLoaderTest: Symfony < 6.4 has onlyAnnotation\Route/attributed/…paths@notebreaks 6.4 route loadingsave_comments=0fails every routeConfiguration@finalci.yml: 8.4,7.*, PHP 8.0 × 6 lowest^7.4blocks 6.4 apps on 7.0–7.3 components^7.0on the components; framework-bundle, security-bundle and validator stay^7.4, as you suggestedtype: annotationtype: attribute+ Symfony 7 notede-DEsave_comments=0readLanguages()copygetPreferredLanguage()again__NAMESPACE__insteadofbypassDefinitions;PayseraApiExtensionTestsnapshotgetPreferredLanguage()ConfigurationException(test witheval())ExtensionExtensionscandirlist, redundant checkusestatements and scans doc comments for the bundle's seven names, from the annotation classes themselves; it resolves and loads nothing, so it re-implements no Doctrine semanticssave_commentscheckRestAnnotationInterfaceannotations droppedstrval; "a locale configured as a number", three rows with anullconfiguredsave_comments=0ConfigurationException; "import or name" instead of "import and name" (R7.1, fully qualified tags)ci.ymlruns them; the lowest-dependency lines exclude only combinations that do not installsetUpFor(),tearDownnull guardTest plan
and the Symfony 7 functional permission test answers 200 instead of 401 and 403
are gone with the pre-scan; the configurator's: no attributes, no error wrapping)
php -lon PHP 7.1 and on PHP 8.4 withE_ALL: no errors, no deprecations insrc/array_push()on PHP 7.1,strposinstead ofstripos, nostrval, no empty-locale filter,class_existsinstead ofis_a, the oldAttribute\prefixattributes passes its 2,195 tests with the same per-test results as at
827116a; a Symfony 7.4 test applicationrefuses a route with
#[RequiredPermissions]and nousestatement, which loads without the check at827116ausestatement load without the check; under thereverted short-name rule, both
#[OA\Query]data sets are refusedclass_exists; a globalnamespace that matches every name; the exact namespace instead of a prefix; the declaring class only; the routed
class only; class attributes not checked;
strcmpinstead ofstrcasecmpon Symfony 7.4, 10 must-load shapes; each of the seven annotations through a generated file; an eval'd class, an unreadable file and a
pregfailure refused; theopcache.save_comments=0subprocess refuses the annotated controllers; the Symfony7.4 test application refuses its half-migrated controller; an internal application on Symfony 6.4 with 12
attribute controllers passes its 2,195 tests with the same per-test results as at
320bd5c; the guard over the42,044 PHP files of our 40 applications flags exactly the 209 annotation files
recursion; neither the declaring class nor the action's file; case-sensitive import; case-sensitive tag; split
names not joined; one leading backslash only; names joined without a name following, or with ASCII blanks only;
no fallback on invalid UTF-8;
(required after the name; pass on an unreadable file; pass on apregfailure;group use not expanded; alias not stripped; guard not called; non-ASCII bytes not joined; alias remedy on every tag; one leading backslash stripped before the name check; lookbehind
without the double quote; lookbehind as before; segment class ASCII only; lookahead without the dash; source file
names not mapped to null; comma-separated imports not split
2877c09, 45 of 45 jobs, 432 tests in each job log🤖 Generated with Claude Code