Skip to content

Order command routes most-specific-first so literal paths win over placeholders#31

Merged
stixx merged 2 commits into
mainfrom
fix/concrete-route-precedence
Jun 15, 2026
Merged

Order command routes most-specific-first so literal paths win over placeholders#31
stixx merged 2 commits into
mainfrom
fix/concrete-route-precedence

Conversation

@stixx

@stixx stixx commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

The command-route directory loader registers routes in filename-sort order, and CommandRouteClassLoader builds every route with requirements: []. A placeholder segment such as {id} therefore compiles to [^/]++ and also matches a sibling literal segment — e.g. a /resource/{id} route can swallow /resource/current — with the winner decided purely by registration order rather than path specificity.

RouteSpecificitySorter reorders the scanned command routes most-specific-first (fewer placeholders, then longer static prefix, stable otherwise) and is applied in AttributeDirectoryLoaderDecorator before the routes are merged. Concrete paths are now matched before templated ones — the OpenAPI path-precedence rule — regardless of class/file name.

Changes

  • src/Routing/RouteSpecificitySorter.php — new, pure sorter over a RouteCollection.
  • src/Routing/Loader/AttributeDirectoryLoaderDecorator.php — apply the sorter to the scanned command routes (one line).
  • tests/Unit/Routing/RouteSpecificitySorterTest.php — ordering rules (placeholder count, static-prefix length, stability).
  • tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php — integration test asserting the decorator emits the literal route before the colliding placeholder. The fixture filenames deliberately scan placeholder-first, so this test fails without the sort.
  • tests/Mock/Routing/src/CollectionItemCommand.php, CollectionLiteralCommand.php — colliding fixtures (/api/items/{id} vs /api/items/featured).

Verification

  • composer cs-fixer — clean.
  • composer phpstan (level max) — no errors.
  • composer test — 201 tests, 596 assertions, green. Confirmed the new integration test fails without the fix.

Notes

  • Per-class $routes->import(Command::class, 'attribute') ordering is unchanged: the precedence sort applies to the directory scan the bundle controls; explicit per-class import order remains the application's responsibility.
  • The heuristic is placeholder-count then static-prefix length. It resolves the common literal-vs-placeholder collision at a given depth and never reorders non-overlapping routes in a way that affects matching.

Summary by CodeRabbit

  • Bug Fixes
    • Routes are now automatically ordered by specificity, ensuring literal paths are registered before templated ones. Routes with fewer placeholders are prioritized first, then ordered by static path length, with original registration order preserved for equally specific routes.

…aceholders

The command-route directory loader registered routes in filename-sort order,
and CommandRouteClassLoader builds every route with empty requirements, so a
placeholder segment such as {id} compiles to [^/]++ and also matches a sibling
literal segment (for example a /resource/{id} route swallowing /resource/current).
Resolution then depends on registration order rather than path specificity.

RouteSpecificitySorter reorders the scanned command routes most-specific-first
(fewer placeholders, then longer static prefix, stable otherwise) and is applied
in AttributeDirectoryLoaderDecorator before the routes are merged, so concrete
paths are matched before templated ones per the OpenAPI path-precedence rule.
Per-class $routes->import(...) ordering is unchanged.
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@stixx, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 50 minutes and 41 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8fa3b39b-b46b-4c02-903b-7f0d349a2bb5

📥 Commits

Reviewing files that changed from the base of the PR and between 9265e1b and 09f1787.

📒 Files selected for processing (2)
  • src/Routing/RouteSpecificitySorter.php
  • tests/Unit/Routing/RouteSpecificitySorterTest.php

Walkthrough

Adds RouteSpecificitySorter, a new class that reorders a Symfony RouteCollection by path specificity (fewer placeholders first, then longer static prefix, then original insertion order). AttributeDirectoryLoaderDecorator is updated to apply this sorter to command-loaded routes before merging them into the main collection.

Changes

Route Specificity Sorting

Layer / File(s) Summary
RouteSpecificitySorter implementation
src/Routing/RouteSpecificitySorter.php, tests/Unit/Routing/RouteSpecificitySorterTest.php
New final class RouteSpecificitySorter with sort(), placeholderCount(), and staticLength() methods. The sorter rebuilds a RouteCollection ordered by ascending placeholder count, descending static prefix length, and original insertion order as a tie-breaker. Four unit tests cover static-vs-placeholder collisions, cross-depth placeholder counts, static prefix tie-breaking, and stability for equally specific routes.
Decorator wiring and integration test
src/Routing/Loader/AttributeDirectoryLoaderDecorator.php, tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php
Adds use import for RouteSpecificitySorter and wraps the command RouteCollection with (new RouteSpecificitySorter())->sort($commands) before addCollection. A new decorator test filters items_ routes and asserts items_featured precedes items_item.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Hoppity-hop through the routing tree,
Specific paths first, as they ought to be!
No placeholder shall jump the queue ahead,
Static segments lead — that's how routes are read.
Sorted with care, the bundle hops free! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: ordering command routes by specificity to ensure literal paths match before placeholders.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/concrete-route-precedence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/Routing/RouteSpecificitySorter.php (1)

55-58: Consider preserving route priorities when rebuilding the sorted collection.

While the sorter rebuilds a RouteCollection without carrying forward route priorities via add(), this is currently not a practical concern since no routes in the bundle have explicit priorities set. However, for defensive consistency with the Symfony RouteCollection API contract, getPriority() and the three-parameter add($name, $route, $priority) form could be used to ensure priorities are preserved if routes with non-default priorities are encountered in the future.

Suggested patch
     public function sort(RouteCollection $routes): RouteCollection
     {
         $all = $routes->all();

         $positions = [];
+        $priorities = [];
         $position = 0;
-        foreach ($all as $name => $route) {
+        foreach ($all as $name => $route) {
             $positions[$name] = $position;
+            $priorities[$name] = $routes->getPriority($name);
             ++$position;
         }
@@
         $sorted = new RouteCollection();
         foreach ($names as $name) {
-            $sorted->add($name, $all[$name]);
+            $sorted->add($name, $all[$name], $priorities[$name]);
         }

         return $sorted;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Routing/RouteSpecificitySorter.php` around lines 55 - 58, The
RouteSpecificitySorter rebuilds a RouteCollection using the two-parameter add()
method, which does not preserve route priorities from the original routes. To
ensure defensive consistency with the Symfony RouteCollection API contract and
future-proof the code for routes with non-default priorities, modify the loop
that adds routes to the sorted collection to also retrieve and pass the route
priority. Use the getPriority() method on each route from the all array and
provide it as the third parameter to the add() method when adding routes to the
sorted collection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/Routing/RouteSpecificitySorter.php`:
- Around line 55-58: The RouteSpecificitySorter rebuilds a RouteCollection using
the two-parameter add() method, which does not preserve route priorities from
the original routes. To ensure defensive consistency with the Symfony
RouteCollection API contract and future-proof the code for routes with
non-default priorities, modify the loop that adds routes to the sorted
collection to also retrieve and pass the route priority. Use the getPriority()
method on each route from the all array and provide it as the third parameter to
the add() method when adding routes to the sorted collection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d727ec78-d636-48a0-960e-34d228b6894c

📥 Commits

Reviewing files that changed from the base of the PR and between 8cb5fa1 and 9265e1b.

⛔ Files ignored due to path filters (2)
  • tests/Mock/Routing/src/CollectionItemCommand.php is excluded by !tests/Mock/**
  • tests/Mock/Routing/src/CollectionLiteralCommand.php is excluded by !tests/Mock/**
📒 Files selected for processing (4)
  • src/Routing/Loader/AttributeDirectoryLoaderDecorator.php
  • src/Routing/RouteSpecificitySorter.php
  • tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php
  • tests/Unit/Routing/RouteSpecificitySorterTest.php

@stixx stixx added the bug Something isn't working label Jun 15, 2026
RouteCollection::add() carries a route's priority as its third argument; the
specificity rebuild now forwards RouteCollection::getPriority() so an explicit
non-default priority survives the sort. Behaviour is unchanged for the default
priority 0 (not stored, so the collection stays in specificity order); when a
priority is set it dominates, with specificity as the stable tiebreak.
@stixx stixx merged commit 225ed40 into main Jun 15, 2026
6 checks passed
@stixx stixx deleted the fix/concrete-route-precedence branch June 15, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant