Skip to content

Add Symfony 7.4 support (2.0.0) - #21

Merged
mSprunskas merged 16 commits into
paysera:masterfrom
vinayak-iyer-paysera:symfony-7-support
Oct 26, 2026
Merged

mSprunskas merged 16 commits into
paysera:masterfrom
vinayak-iyer-paysera:symfony-7-support

Conversation

@vinayak-iyer-paysera

@vinayak-iyer-paysera vinayak-iyer-paysera commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Every Symfony constraint stops at ^6.0. Symfony 7 requires getConfigTreeBuilder(): TreeBuilder, 7.4 deprecates
    XML configuration and 7.1 makes HttpKernel's Extension internal.
  • On Symfony 7, and on Symfony 5.4 with PHP 8 or Symfony 6 with framework.annotations disabled, Symfony passes no
    annotation reader to the route loader. 1.x then dropped the bundle's docblock annotations, @RequiredPermissions
    among them, without an error on 6.4 and 7, and failed to load any route before 6.4.
  • Symfony 7.1 rewrote Request::getPreferredLanguage(); since then de-DE (and de from 7.3) lost to the listener's
    'default' placeholder.

Changes

  • composer.json: ^7.4 on framework-bundle, security-bundle, validator and http-kernel; ^7.0 on the components
    FrameworkBundle 6.4 accepts at 7.x, which are now required explicitly; psr/log 3;
    doctrine/annotations ^1.14.4 || ^2.0.2 (the first releases whose reader refuses opcache.save_comments=0).

  • Signatures: Configuration::getConfigTreeBuilder(): TreeBuilder (class @final), PayseraApiExtension extends
    the DependencyInjection component's Extension, 11 optional parameters declared ?Type $x = null for PHP 8.4.

  • Services registered with the Definition API in the extension; the six XML files are removed.
    PayseraApiExtensionTest compares 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 unquoted Accept-Language item can
    contain; configured locales are compared as strings and empty ones are dropped, so a null entry neither breaks
    Symfony 7.4's typed Request::formatLocale() nor matches the item i-.

  • RestRouteConfigurator (new, @internal), shared by both route loaders. It reads the docblock annotations with
    the reader the loader holds, and the bundle's attributes on PHP 8, and wraps any error in a ConfigurationException naming
    the action. It also filters the RestAnnotationInterface results, refuses a route with both, and sets the options.
    RoutingAnnotationLoader (Symfony < 6.4) therefore applies attributes now too. An attribute named under
    Paysera\Bundle\ApiBundle\ that is none of the bundle's (a misspelt name or namespace, in any case) fails instead
    of being skipped, and so does one of the bundle's attributes written without its use statement: a name whose class
    cannot 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 with
    framework.annotations disabled; 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 a ConfigurationException naming the
    action, the file and the finding when (a) a use statement imports anything from Paysera\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 preg failure fail the load too. Nothing is resolved or loaded: the guard looks for
    the 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 file
    reveals (the remedy is an attribute implementing RestAttributeInterface, applied on every path; none of our 40
    applications defines such an annotation), and an alias of a bundle class made with class_alias(). Reading the files
    makes opcache.save_comments=0 irrelevant; the subprocess test pins that attribute-only routes load and the annotated
    controllers 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 @RequiredPermissions stops route loading with the guard's message; on 6.4 with a reader the
    same 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 preg failure, invalid and misspelt
    attributes, attributes written without
    their use statement (each of the seven names, in lowercase, qualified without its alias import, on the class, in a
    trait 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 a
    missing Content-Type and a body that is not JSON, and the duplicate-Body test now checks its message, so both keep
    failing 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\Route from 6.4, Annotation\Route on 5.2–6.3), and that docblock annotations are refused without a
    reader.
    CHANGELOG 2.0.0.

Backward compatibility

A major release. roave/backward-compatibility-check from 1.8.2 to this branch reports two changes in the bundle's own
API: the TreeBuilder return type and the extension's parent (its other lines are Symfony's Bundle class between the
6.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 under Paysera\Bundle\ApiBundle\ that is none of the bundle's fails
route loading, and so does one of the bundle's attributes written without its use statement (one of the seven names,
in the namespace it is written in, whose class cannot be loaded); the locale placeholder (a header listing default or i-default before 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.annotations disabled; the bundle's attributes on PHP 8 before Symfony 6.4; routes
with both docblock annotations and attributes; and a bundle attribute written without its use statement. The last CHANGELOG Security entry says so and points to 2.0.0.

Review on this PR (2026-10-20)

# Finding Status Resolution
1 bundle attributes ignored on Symfony 5.4–6.3 ✅ RoutingAnnotationLoader applies them through RestRouteConfigurator on 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
2 namespace alias @Api\AdminOnly missed 📄 not detected without a reader since 0639f49: nothing in the controller's file reveals the application's own annotation class. Documented in the CHANGELOG and README with the remedy, an attribute implementing RestAttributeInterface, 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 it
3 own namespace without use missed 📄 as #2, not detected without a reader since 0639f49; passing data set "in the namespace of the controller"
4 regexes re-implement Doctrine's resolution 📄 since 0639f49 nothing 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's AnnotationReader over 416 shapes on five lexer versions (0 shapes Doctrine applies that the guard passes, the documented own-annotation and class_alias() cases excepted). The lenient DocParser pre-scan is gone
5 autoload crash during warmup not wrapped ✅ the pre-scan runs wherever Symfony passes no reader: RoutingAttributeLoader (Symfony ≥ 6.4) and RoutingAnnotationLoader on Symfony 5.4 with PHP 8 or 6.0–6.3, so always on PHP ≥ 8.0, where a missing parent is a catchable Error (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 @note on the route too). Tagged on the action → ConfigurationException naming 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 did
6 inconsistent error wrapping ✅ RestRouteConfigurator wraps every read: the injected reader, the own reader, the Symfony < 6.4 reader, annotation constructors, and attribute instantiation; RestRouteConfiguratorTest
7 each file read and tokenized twice ✅ the pre-scan takes docblocks from reflection, so the bundle reads no file itself; only PhpParser reads the use statements, once per class
8 duplicated filtering ✅ RestRouteConfigurator holds filtering, attributes, mixed check and options for both loaders (a service: no traits in production code)
9 README XML example imports twice ✅ one YAML and one XML block per Symfony range, and a note to import once

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 0639f49 the pre-scan and the own reader these resolutions
describe are gone: the reader-less path refuses instead (2026-10-23, #1, #2 and #4 below).

Review on this PR (2026-10-21)

# Finding Status Resolution
1 README #[Route] examples need Symfony 6.4 ✅ a note before the attribute example: Attribute\Route from 6.4, Annotation\Route on 5.2–6.3, a docblock @Route before 5.2. Our fixture comment and this description said "6.2": the comment is removed, and this description says 6.4
2 i-, de keeps the default locale on 3.4–7.0 ✅ , as the placeholder; the test case expects de on 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 except default, de (the intended fix) and a quoted "," item: a quoted item can contain a comma (HeaderUtils::unquote()), so ",", de still 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"
3 array_push() with an empty spread warns on PHP 7.1/7.2 ✅ array_merge(); DocblockAnnotationGuardTest runs the guard on PHP 7, and fails with ArgumentCountError on 7.1 against the old line
4 Symfony 7 route fixtures in XML ✅ explicit_routing.yml, and sf7_routing.yml imports 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 is property_info.with_constructor_extractor, from our own sf7_common.yml. Left, all older than this PR: the test bundle's services.xml, three Attributed* service ids without \Attribute\ (dc7601c), and intl missing in my image

Before 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:

  • claims: the reader-less case is Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with framework.annotations
    disabled (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: attribute from 6.1 also loads docblock routes up to 6.4 (Symfony 7 reads only #[Route]) and 6.4
    deprecates
    type: annotation; a missing parent stops PHP on 7.1 and 7.2 (checked on 7.1 to 8.0); and stale lines here
  • a lowercase misspelt attribute namespace is pinned (stripos), and so is a null configured locale (strval, which
    Symfony 7.4's typed formatLocale() needs, then dropped so it cannot match i-); comments that explained code are gone; the placeholder is a named
    constant
  • since 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 DocParser treated an imported alias whose class does not
    exist like a missing import, so it was documented as not detected

Review on this PR (2026-10-22)

# Finding Status Resolution
1 a bundle attribute written without its use statement is ignored ✅ refused since 5835363. 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)

# Finding Status Resolution
3 third-party attributes refused ✅ the short-name check reverted (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 in App\ 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 DocParser failure is treated as "uses REST annotations" | ✅ | 0639f49: no parser; a file that cannot be read, a class without a source file and a preg failure stop route loading with a ConfigurationException (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 (use plain, aliased, group use, namespace alias; an unused import too) |

The three fixes are one removal: DocblockAnnotationReader (own AnnotationReader + lenient DocParser pre-scan) is
replaced by DocblockAnnotationGuard (Changes, above); 7708e60 matches tags as Doctrine's lexer does (two leading
backslashes, a backslash ending a line of prose before the tag, a no-break space inside a split name: shapes a
differential against Doctrine's AnnotationReader found before the push) and declares ext-tokenizer; 2667bc9 joins a name
split 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; 2877c09 lets a name segment
carry non-ASCII bytes, because Doctrine's lexer runs with the i and u flags, under which a-z also matches the long s and the Kelvin sign (a
parent-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\RequiredPermissions and 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

  • Attributes on a parent class (found while answering the 2026-10-22 review, unchanged since 1.8.0): a class-level
    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 the
    parents' 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
Pt Concern Where
R1.1 6.4 without a reader drops docblock options ✅ refused by DocblockAnnotationGuard; unit "annotation on the action"; functional testDocblockOptionsOnAttributeRouteAreRefusedWithoutReader on Symfony 7, 401/403/200 with a reader on 6.4
R1.2 class-alias import missed ✅ rule (a) parses every use statement from tokens: plain, aliased, group use, namespace alias; "annotation written through an alias of a parent namespace" by rule (b)
R1.3 load(): void ✅ no return type on PayseraApiExtension::load(); 2.0.0
R1.4 other libraries' short names match 📄 a tag of another library named like one of the seven is refused, loud, with the tag and the file in the message; 0 such files in our 40 applications
R1.5 fixtures on deprecated Annotation\Route ✅ all attribute fixtures use Attribute\Route, except the NativeRoute* ones of RoutingAnnotationLoaderTest: Symfony < 6.4 has only Annotation\Route
R1.6, R2.6 attribute routes shadowed on 6.4 ✅ /attributed/… paths
R2.1 @note breaks 6.4 route loading ✅ no reader and no parser on that path: only the bundle's names and imports are refused ("attribute next to a tag that is not an annotation")
R2.2 save_comments=0 fails every route ✅ the guard reads the files, not the stripped doc comments; attribute-only routes load (subprocess test)
R2.3 new reader per method ✅ no reader; one token pass per file, cached for the load
R2.4, R3.D5, R4.4, R5.3 breaking changes in a minor ✅ 2.0.0; Configuration @final
R2.5 no PHP 8.4 / Symfony 6 lowest job ✅ ci.yml: 8.4, 7.*, PHP 8.0 × 6 lowest
R3.1 ^7.4 blocks 6.4 apps on 7.0–7.3 components ✅ ^7.0 on the components; framework-bundle, security-bundle and validator stay ^7.4, as you suggested
R3.2 setup shows only type: annotation ✅ XML and YAML type: attribute + Symfony 7 note
R3.3 wrong locale example ✅ Fixed entry names de-DE
R3.D1 traits of traits skipped ✅ every trait of the routed and the declaring class, recursively; "action of a trait used by another trait", "own action next to a nested trait"
R3.D2 case-sensitive namespace match ✅ lowercased comparison
R3.D3 unused import + save_comments=0 📄 an unused import of the annotation namespace is refused, named in the message; CHANGELOG/README say so
R3.D4 whole-file text search 📄 the guard reads tokens, so strings and comments are never matched ("tag-shaped text in a string and a comment"); it does scan every doc comment of the files, by design, so a bundle tag on a property or another method is refused too
R3.D6, R5.7, R9.4 repeated parsing ✅ per-class cache
R3.D7, R7.4 readLanguages() copy ✅ getPreferredLanguage() again
R3.D8 no __NAMESPACE__ ✅ rule (b) matches the bundle's names in any namespace
R4.1 trait alias / insteadof bypass ✅ the action's file and every trait file are scanned; "trait action renamed with as"
R4.2 FQ name split across lines ✅ joined as Doctrine's lexer does; data set "split across docblock lines", mutant "split names not joined"
R4.3 every docblock of the file scanned 📄 deliberate now: the guard refuses, it does not apply, so over-inclusion is loud and named (see R3.D4)
R4.5 XML config deprecated on 7.4 ✅ services as Definitions; PayseraApiExtensionTest snapshot
R4.6 locale parsing differs ✅ Symfony's own getPreferredLanguage()
R4.7 eval'd class ✅ a class without a source file fails the load with a ConfigurationException (test with eval())
R4.8 internal HttpKernel Extension ✅ DI Extension
R4.9, R4.10, R4.11, R8.4 hand-written parser, scandir list, redundant check 📄 the guard parses use statements 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 semantics
R5.1 no save_comments check ✅ not needed on the reader-less path (files are read); Doctrine ≥ 1.14.4 / 2.0.2 refuses it on the reader path
R5.2a, R7.1 annotation with no import ignored ✅ refused by its tag name (rule b); "tag through an alias that is not imported, in the parent declaring the action"
R5.2b, R7.2 app's own RestAnnotationInterface annotations dropped 📄 not detected where no reader exists: nothing in the controller's file reveals the class; documented; none of our 40 applications defines one
R5.4 FQ tag of another library ✅ loads: no reader on that path, only the bundle's names count
R5.5 numeric locales ✅ strval; "a locale configured as a number", three rows with a null configured
R5.6 trait imports apply to the class ✅ every trait file is scanned for the class's routes
R6.1 attribute-only + save_comments=0 ✅ attribute-only routes load with the comments stripped (subprocess test)
R6.2 class references in arguments ✅ reader path only: Doctrine's reader resolves them
R7.3 misleading message ✅ the message names the action, the file and the import or tag found
R8.1 attribute replaces docblock options ✅ mixed route refused
R8.2 bare language → region locale ✅ "a bare language, from Symfony 7.1 matched to a configured locale with a region"
R8.3, R9.5, R9.8 option defaults and wiring ✅ no option
R9.1 default drops docblock permissions ✅ your remedy: refused with a ConfigurationException; "import or name" instead of "import and name" (R7.1, fully qualified tags)
R9.2 one unknown tag stops all routes ✅ no reader on that path: no tag of another kind can stop route loading
R9.3 mixed check breaks the router 📄 your "accept it as a documented break … or make the error message clearer": both
R9.6 new CI combinations ✅ ci.yml runs them; the lowest-dependency lines exclude only combinations that do not install
R9.7 paged-query test boots before skipping ✅ skip before setUpFor(), tearDown null guard

Test plan

  • Locally in Docker at the pushed commit: all 45 jobs of the workflow, 432 tests each
  • Against master's loader, the unit data sets for docblock reading, the unresolvable tag and the mixed route fail,
    and the Symfony 7 functional permission test answers 200 instead of 401 and 403
  • Review on this PR (2026-10-20): each fix pinned by a mutation that made its test fail (the pre-scan mutations
    are gone with the pre-scan; the configurator's: no attributes, no error wrapping)
  • php -l on PHP 7.1 and on PHP 8.4 with E_ALL: no errors, no deprecations in src/
  • Locale parity probe against master's listener (above), and a deprecation probe booting the Symfony 7.4 kernel
  • Mutations for this round fail their tests: array_push() on PHP 7.1, strpos instead of stripos, no strval, no empty-locale filter,
    class_exists instead of is_a, the old Attribute\ prefix
  • Review on this PR (2026-10-23): an internal application on Symfony 6.4 with 12 controllers that use the bundle's
    attributes passes its 2,195 tests with the same per-test results as at 827116a; a Symfony 7.4 test application
    refuses a route with #[RequiredPermissions] and no use statement, which loads without the check at 827116a
  • Before the fix, the 12 data sets of an attribute without its use statement load without the check; under the
    reverted short-name rule, both #[OA\Query] data sets are refused
  • Mutations for 2026-10-23 fail their tests: no namespace step; no short-name step; no class_exists; a global
    namespace that matches every name; the exact namespace instead of a prefix; the declaring class only; the routed
    class only; class attributes not checked; strcmp instead of strcasecmp
  • Review on this PR (2026-10-23, Remove keys with null-values from ErrorNormalizer #1, Support for PHP 7.4 #2, Make RestRequestListener to try to resolve RestRequestOptions on kernel.request also #4): 37 refusal shapes in the guard's test, 20 through Symfony's loader
    on 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
    preg failure refused; the opcache.save_comments=0 subprocess refuses the annotated controllers; the Symfony
    7.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 the
    42,044 PHP files of our 40 applications flags exactly the 209 annotation files
  • Mutations of the guard fail their tests (27 of 27, on PHP 8.3 and 7.1): no import rule; no tag rule; the class file only; no trait
    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 a preg failure;
    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
  • CI on this pull request: run 40298478020 at 2877c09, 45 of 45 jobs, 432 tests in each job log

🤖 Generated with Claude Code

@vinayak-iyer-paysera
vinayak-iyer-paysera force-pushed the symfony-7-support branch 3 times, most recently from 40f6396 to 574c864 Compare October 20, 2026 09:59
@vinayak-iyer-paysera vinayak-iyer-paysera mentioned this pull request Oct 20, 2026
7 of 8 tasks
@vinayak-iyer-paysera
vinayak-iyer-paysera force-pushed the symfony-7-support branch 3 times, most recently from e7e0c6e to 6297e5c Compare October 20, 2026 10:52

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  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. 

@vinayak-iyer-paysera
vinayak-iyer-paysera force-pushed the symfony-7-support branch 6 times, most recently from 0bac26a to 78f4376 Compare October 20, 2026 15:27
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@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. Attributes on Symfony 5.4–6.3: RoutingAnnotationLoader now applies the bundle's attributes on PHP 8, with the mixed docblock/attribute check. README and CHANGELOG say 1.8 needs 6.4 for them.

  2. Regex pre-scan (findings 2, 3 and 4): it's gone. Doctrine's DocParser, run leniently with the scope's imports and __NAMESPACE__, now resolves the tags. It reads exactly the class and action docblocks Doctrine reads. That covers:

    • namespace aliases,
    • the controller's own namespace,
    • fully qualified names, including ones split across lines.

    I kept the PhpParser check for files that import Paysera\Bundle\ApiBundle\Annotation. A misspelt @Rest\RequiredPermision there still fails loudly; the lenient parser alone would skip it.

    One exception to "no own resolution": with opcache.save_comments=0, Doctrine's reader refuses to run and its parser can't tell annotation classes. There, tags are read from the file on disk and resolved with Doctrine's three rules (resolveTag()), and a route that uses one fails loudly. A subprocess test with OPcache stripping comments pins this.

  3. Autoload failures (finding 5): is_a() is gone. An unloadable class tagged on the route raises a ConfigurationException naming the action. A broken tag on an unrelated method no longer matters.

  4. Error wrapping (finding 6): every read is wrapped in one place, RestRouteConfigurator. That covers both readers, annotation constructors and attribute instantiation.

  5. Double file reads (finding 7): the pre-scan uses reflection docblocks, so normally the bundle reads no file itself. Only PhpParser does, once per class. Files are read, once each, only where OPcache strips comments.

  6. Duplicated logic (finding 8): both loaders share RestRouteConfigurator. It's a service rather than a trait, since traits are out for production code.

  7. README imports (finding 9): one YAML block and one XML block per Symfony range, with a note to import the controllers once.

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?

@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@mSprunskas a follow-up to my comment above, now pushed as 701edbc:

  1. The opcache.save_comments=0 exception is gone. I removed resolveTag() and the on-disk reading, so the bundle has no own name resolution left anywhere. With comments stripped, only the import check still applies: a file importing Paysera\Bundle\ApiBundle\Annotation fails loudly, because Doctrine's reader refuses to run there. An annotation used without that import goes undetected; README and CHANGELOG list this as a limit. This also settles finding 7: the bundle never reads a file itself now. PhpParser does, once per class.
  2. Symfony < 6.4 with framework.annotations disabled uses the same path. The reader-less logic now lives in one DocblockAnnotationReader service, shared by both loaders through RestRouteConfigurator, so the old loader reads docblocks the way the 6.4+ loader does when Symfony passes no reader.
  3. Bundle attributes are checked by namespace. An attribute named under Paysera\Bundle\ApiBundle\ that isn't one of the bundle's attributes (#[Rest\RequiredPermission], Attributes\…, or a misspelt name through use …\Annotation as REST) fails to load instead of being skipped.

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 mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. 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.

vinayak-iyer-paysera and others added 3 commits October 21, 2026 12:44
…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
vinayak-iyer-paysera and others added 3 commits October 21, 2026 12:54
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
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@mSprunskas thanks. All four points are addressed in 827116a. CI passes all 45 jobs.

  1. README #[Route] examples: a note before the attribute example now says Attribute\Route exists from 6.4. On 5.2–6.3 you import Annotation\Route, and before 5.2 you use a docblock @Route. The reference section points to that note. We had also written "6.2" in a fixture comment and in the description: the comment is gone, and the description says 6.4.
  2. i-, de: the placeholder is , now, and the test case expects de on every version. I ran master's listener against the branch's on Symfony 3.4, 5.4, 6.0 and 6.4 with 22 header and locale combinations. Every result matches, with two exceptions:
    • default, de: the intended fix.
    • A quoted item: Symfony unquotes ",", so an item can contain a comma, and ",", de still keeps the default locale. No placeholder can avoid this from 7.1, where any quoted string becomes an item. Only a deliberately quoted value reaches it, while '' was reached by a plain i-. The CHANGELOG says "no unquoted item".
  3. array_push(): replaced with array_merge(). A new DocblockAnnotationReaderTest runs getTraits() on PHP 7. With the old line it fails on 7.1 with ArgumentCountError.
  4. XML route fixtures: explicit_routing.yml replaces the XML file, and sf7_routing.yml imports the attribute directory directly. I booted the 7.4 test kernel from a clean cache and collected its deprecations:
    • Gone: the routing XML deprecation (it fired twice with the old fixtures) and property_info.with_constructor_extractor, which came from our own sf7_common.yml.
    • Left, all older than this PR: services.xml, three Attributed* service ids without \Attribute\, and intl missing in my image.

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:

  • The reader-less case is now worded as Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with annotations disabled. On PHP 7, Symfony registers no route loader there.
  • The type: annotation / type: attribute advice now matches 6.1 and 6.4.
  • A missing parent stops PHP only on 7.1 and 7.2; I checked 7.1 to 8.0.
  • LocaleListener drops empty configured locales after the string cast. A null entry, which the config accepts, would otherwise break Symfony 7.4's typed formatLocale(), or match i- on 3.4–6.4.

One known case is now documented rather than detected. Without a reader, an annotation behind a misspelt import (use …\Annotations as REST;) goes unnoticed, like any annotation Doctrine cannot resolve outside a file importing the annotation namespace. The lenient DocParser treats an imported alias whose class does not exist exactly like a missing import. Catching it would take our own "this bundle name does not exist" rule, so the README and CHANGELOG name it instead.

The description has a row per point.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  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>
vinayak-iyer-paysera and others added 3 commits October 22, 2026 15:19
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>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@mSprunskas thanks. Fixed as you recommended, in 5835363, 1383b20, c37e156 and f2e782c. CI passes all 45 jobs.

  • The check: failOnUnknownBundleAttributes() now also refuses an attribute whose short name is one of the seven
    bundle attributes but which is not a class that can be loaded. The name is compared case-insensitively, as PHP
    compares class names. For your example the message reads:
    App\Controller\AdminController::show() uses App\Controller\RequiredPermissions, which is not a class that can be loaded; if the bundle's attribute is meant, the name must resolve to Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions.
    A class of that name that can be loaded is left alone. The check covers class and method attributes, through both
    route loaders.
  • Tests: a data set for each of the seven names, built from src/Attribute/, so an attribute missing from the list
    fails the test, plus a lowercase name, the global namespace and the class level. Three cases must still load: another
    library's Validation on the class and on the action, and a missing class with another name. Each part of the new
    condition has a mutation that makes a test fail: the short-name step, the case-insensitive compare, class_exists(),
    the null guard, and reading class attributes.
  • Your exact case, a controller in App\Controller with no use: at 827116a it loads without the permission
    check, and now it is refused. I ran it through the configurator on PHP 8.3, through a Symfony 7.4 application's
    router, and through both loaders on PHP 8.0 with Symfony 5.4 and 6.0.
  • What else it touches: in our own applications, 28 controller files in 10 of them use these names, and all of them
    import the bundle's classes, so none of them stops loading. One of them, on Symfony 6.4 with 12 attribute controllers,
    passes its 2,195 tests with the same per-test results before and after.
  • Docs: the CHANGELOG (Changed, a Security line, the 1.x list) and the README reference describe the check and what
    it does not detect.

One question. Your rule also refuses an attribute of a package that is not installed where the routes load, when its
name is one of the seven. swagger-php 5.8 and 6.0 ship #[OA\Query], so an application that installs swagger-php for
development only would stop loading its routes in production. A misspelt name without its use statement
(#[RequiredPermission]) is still not detected. The alternative is a rule by namespace: refuse any attribute that cannot
be loaded from the namespace of the class it is written in. That would catch the misspelt name and leave swagger-php
alone. But it would miss an action declared in a trait from another namespace, and it would refuse other libraries'
attributes too, such as a forgotten use for Symfony's #[Cache]. I kept your rule and documented both edges. Is that
the trade-off you want?

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. 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.

vinayak-iyer-paysera and others added 2 commits October 22, 2026 19:27
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>
@vinayak-iyer-paysera

vinayak-iyer-paysera commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

@mSprunskas thanks. I reverted round 3's short-name check and replaced it with your namespace rule from #3, in two
commits on top of f2e782c: d3c1a6e (the revert) and 320bd5c. CI passes all 45 jobs.

  • The check (your round-3 point and PathAttribute: mapping of primitives #3): failOnUnknownBundleAttributes() now refuses an attribute only when all
    three hold: its class cannot be loaded, its short name is one of the seven (compared case-insensitively), 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. That is what a missing use statement produces. It also covers #[Rest\RequiredPermissions] without its
    alias import and an action of a trait from another namespace. In the global namespace, only a name with no namespace
    part counts: there PHP reads #[Rest\RequiredPermissions] without its alias import as a complete name, the same as
    another package's attribute written in full, so the CHANGELOG lists that case as not detected. #[OA\Query] imported
    from an uninstalled package now loads, and so does a class of that name that can be loaded. This also answers my
    question from the last round.
  • Tests: each of the seven names, in lowercase, qualified without the alias import, on the class, in a trait of
    another namespace and in the global namespace. These are written as App\ fixtures, because a missing use in the
    fixtures' own namespace is caught by the namespace check first. Six cases must still load: another library's
    Validation on the class and on the action, the application's own Body, a missing class with another name, and
    #[OA\Query] of an uninstalled package, in a namespace and in the global namespace. Each part of the condition has a
    mutation that makes a test fail (9 of 9).
  • What else it touches: in our 40 applications on this bundle, every use of the seven attribute names resolves to
    the bundle's class, so nothing newly fails to load. An internal application on Symfony 6.4 with 12 attribute
    controllers passes its 2,195 tests with the same per-test results as at 827116a.

1, 2 and 4 are about the docblock pre-scan, which this push does not change. I will answer them separately.

vinayak-iyer-paysera and others added 3 commits October 23, 2026 22:53
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>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@mSprunskas #1, #2 and #4 of your round of 2026-10-22, in three follow-up commits on 320bd5c: 0639f49, 7708e60 and 2667bc9.
CI passes all 45 jobs, 413 tests each.

  • What changed: where Symfony passes no annotation reader to the route loader (Symfony 7; 6.4 with
    framework.annotations disabled; 5.4 on PHP 8), the bundle no longer reads docblock annotations itself.
    DocblockAnnotationReader, its own AnnotationReader and the lenient DocParser pre-scan are gone, and with them
    the three points, which are all about that code. In their place DocblockAnnotationGuard refuses: 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, and stops route loading with a ConfigurationException naming the action, the file and
    the finding when a file imports anything from Paysera\Bundle\ApiBundle\Annotation (plain, aliased, grouped or as a
    namespace alias) or has a docblock tag named like one of the seven annotations at tag position, in any letter case,
    split across lines as Doctrine's lexer joins them. Where Symfony passes a reader, Doctrine reads as before. Refusing
    is what you proposed in your first round on Add Symfony 7.4 support #19 and in R9.1; 2.0.0 being a major made it possible. I took "import or
    name" rather than "import and name": your R7.1 (an unimported @Rest\RequiredPermissions) and a fully qualified tag
    have no import to confirm.
  • Remove keys with null-values from ErrorNormalizer #1 (a tag resolving to the Attribute class is skipped): a tag named like one of the seven is refused whatever it
    would resolve to. Support for PHP 7.4 #2 (a parser failure read as "uses REST annotations"): there is no parser; a file that cannot
    be read or matched fails the load. Make RestRequestListener to try to resolve RestRequestOptions on kernel.request also #4 (importing the annotation namespace sends every action to a strict reader):
    the import is itself refused, with the file named; nothing is read strictly anywhere.
  • Refused beyond what 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 (the message says to import its class
    under another alias), a trait with docblock annotations next to the action, a bundle tag in a docblock Doctrine would
    not read (a property, another method), a bundle tag in a docblock with invalid UTF-8 that Doctrine reads nothing from.
    Not detected, in the CHANGELOG and README: your own annotation class
    implementing RestAnnotationInterface, which nothing in the controller's file reveals — the remedy is an attribute
    implementing RestAttributeInterface, which is applied on every path; none of our 40 applications defines such an
    annotation — and an alias of a bundle class made with class_alias(). I kept class resolution out on purpose:
    locating classes to look inside them is the road the earlier rounds went down. Reading the files makes
    opcache.save_comments=0 irrelevant: the subprocess test now proves the annotated controllers are refused with the
    comments stripped.
  • Checks: 413 tests on PHP 7.1/Symfony 3.4, 8.0/5.4, 8.3/7.4.0 lowest and 8.3/7.4.21; 27 refusal shapes in the
    guard's own test and 20 through Symfony's loader (imports plain, aliased, grouped, lowercase, unused; tags bare,
    aliased, fully qualified with one or two backslashes, lowercase, without arguments, split across lines at a backslash
    or a no-break space, after a line of prose ending with a backslash, in a docblock with invalid UTF-8, in traits,
    nested traits, renamed trait actions and parents), 10 must-load shapes, each of the seven annotations through a
    generated file, an eval'd class, an unreadable file and a preg failure; 21 mutants of the guard each fail a test. A
    differential against Doctrine's AnnotationReader over 31 shapes found the backslash and no-break-space escapes
    before the push; they are fixed in 7708e60. A second pass over 27 more shapes found one escape left: a name split at
    a no-break space after a line of invalid UTF-8, which Doctrine lexes from its first tag; 2667bc9 fixes it, extends the
    invalid-UTF-8 over-refusal to such split names, and appends the alias remedy only when the tag is not written with
    the bundle's full name. Over the 42,044 PHP files of our 40 applications the guard flags exactly
    the 209 files that import the annotation namespace, nothing else, and 0 in the 8 applications that run without a
    reader today; its import parser agrees with Doctrine's PhpParser on all 41,361 files that declare a class. The
    Symfony 7.4 test application refuses its half-migrated controller with the message; 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 four questions I had left open about the pre-scan (R2.1/R9.2, R5.4, R4.2/R5.2a/R7.1, save_comments=0) go with
it; the description's "Please confirm" keeps only the parent-class attributes.

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>
@vinayak-iyer-paysera

Copy link
Copy Markdown
Contributor Author

@mSprunskas one more follow-up commit, 2877c09, after a review pass I ran on the two commits above. CI passes all 45 jobs,
432 tests each.

  • Fixed: a tag written through an alias of a parent namespace spelt with a long s (U+017F) or a Kelvin sign (U+212A),
    use Paysera\Bundle\ApiBundle as Apiſ; with @Apiſ\Annotation\RequiredPermissions(…), was applied by Doctrine and
    passed the guard: Doctrine's lexer matches identifiers with the i and u flags, under which a-z also matches those two
    code points, and the guard's name segments allowed ASCII only. Segments now take any non-ASCII byte. A differential against
    AnnotationReader over 416 shapes on doctrine/lexer 1.0.x, 1.2.3, 2.0.0, 2.1.1 and 3.0.3 shows 0 shapes Doctrine
    applies that the guard passes, before and after this commit the same 19 documented ones excepted (an application's own
    annotation class, class_alias()); none of our 40 applications contains either character.
  • Loosened to what Doctrine reads: a bundle name right after a backtick, a parenthesis, a brace or a single quote in
    prose, and a name followed by a dash that no digit follows, are no longer refused; Doctrine reads nothing from them. A tag still starts after a
    double quote, because Doctrine reads "x"@Tag as a tag. The README and CHANGELOG list what stays refused beyond
    Doctrine: a 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, and a tag path with non-ASCII characters beyond the two Doctrine accepts.
  • Tests: a row per alias shape, the prose and dashed shapes as passing rows, the "x"@Tag shape, a comma-separated
    use statement, two byte-mode join shapes no row had pinned, the no-break-space split generated with an escape instead
    of a fixture holding the raw byte, the three application-annotation fixtures as "not detected" rows, whole-message
    assertions on the unreadable-source tests, and a built-in class for the class-without-a-source-file branch;
    28 mutants of the guard each fail a test on PHP 8.3 and 7.1. The loader test's provider returns the message
    parts instead of calling a static helper.

@mSprunskas mSprunskas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. 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.

@mSprunskas
mSprunskas merged commit d1914a4 into paysera:master Oct 26, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants