Skip to content

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Sep 4, 2025

This PR addresses a bug where routes precedence is different than expected when matching actions and names compared to matching URLs.

Also, there is an inconsistency between action matching behaviour with cached and un-cached routes. This is fun because we didn't know about the issue until it was deployed (not in production) where the routes were cached.


Given the following two routes:

Route::get('base/{first}/{param?}', [MyController::class, 'show']);
Route::get('base/{second}', [MyController::class, 'show']);

Although both of the routes can respond to the following URL, the first route defined takes precedence:

/base/foo

Precedence is basically: first route to match handles the request.

When it comes to generating URLs for routes, we have some inconsistencies with this behaviour that is inconsistent between cached and uncached routes.

Artisan::call('route:clear');

url()->action([MyController::class, 'show'], ['laravel', 'framework']);
> "/base/laravel?framework"

Artisan::call('route:cache');

url()->action([MyController::class, 'show'], ['laravel', 'framework']);
> "/base/laravel/framework"

Notice when the routes were not cached, the second route was used and a query string is present. When the routes are cached, the first route is used and the second parameter becomes a path segment.

When the routes are cached, I feel the expected behaviour occurs. The CompiledRouteCollection will match against the first route with the same action.

$attributes = (new Collection($this->attributes))->first(function (array $attributes) use ($action) {
if (isset($attributes['action']['controller'])) {
return trim($attributes['action']['controller'], '\\') === $action;
}
return $attributes['action']['uses'] === $action;
});

I feel that the un-compiled RouteCollection class exhibits the wrong behaviour, and will match on the last route with the same action. The cause of this is the way the RouteCollection maintains a key -> value pair of action => route.

protected function addToActionList($action, $route)
{
$this->actionList[trim($action['controller'], '\\')] = $route;
}

This is called whenever we add a new route:

Route::get('...', [MyController::class, 'show']); 
// $this->actionList['MyControlle@show'] = $route;

Route::get('...', [MyController::class, 'show']);
// $this->actionList['MyControlle@show'] = $route;

We also override everything again when the action lookup is refreshed:

public function refreshActionLookups()
{
$this->actionList = [];
foreach ($this->allRoutes as $route) {
if (isset($route->getAction()['controller'])) {
$this->addToActionList($route->getAction(), $route);
}
}
}

The same precedence issue exists for named routes, where the last one is always matched. This doesn't have the difference between un-cached and cached routes, because you cannot serialize a routes if you have a route name conflict.

That being said, I still feel the precedence for un-cached routes should be addressed.

Copy link

github-actions bot commented Sep 4, 2025

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@timacdonald timacdonald force-pushed the route-action-precedence branch from 9d8d480 to c7b6235 Compare September 4, 2025 03:04
@timacdonald timacdonald marked this pull request as ready for review September 4, 2025 03:14
@timacdonald timacdonald marked this pull request as draft September 4, 2025 03:18
@timacdonald timacdonald marked this pull request as ready for review September 4, 2025 03:31
Comment on lines +1 to +18
<?php

namespace Illuminate\Tests\Integration\Routing\Fixtures;

use Illuminate\Foundation\Application;

if (! class_exists(AppCache::class)) {
class AppCache
{
public static $app;
}
}

if (isset($refresh)) {
return AppCache::$app = Application::configure(basePath: __DIR__)->create();
} else {
return AppCache::$app ??= Application::configure(basePath: __DIR__)->create();
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a little weird; I know.

When routes are cached, a new application instance is resolved.

This file allows us to capture the app instance created to add routes to it.

@taylorotwell taylorotwell merged commit d09f134 into laravel:12.x Sep 4, 2025
63 checks passed
@timacdonald timacdonald deleted the route-action-precedence branch September 4, 2025 23:13
tegos pushed a commit to tegos/laravel-framework that referenced this pull request Sep 28, 2025
…g actions and names (laravel#56920)

* Ensure cached and uncached routes share same precedence

* formatting

* Formatting

* formatting

* Fix tests
schu added a commit to openmultiplechoice/openmultiplechoice that referenced this pull request Oct 2, 2025
Due to a change in the Laravel Framework with regards to route names and
precedence (presumably laravel/framework#56920),
route precedence for the `users.edit` route changed and one of our
redirects and tests that relies on the old implicit route order fails.

```
return redirect()->route('users.edit', ...)
```

used to redirect to `users.edit` of our `UserManagementController` but
now redirects to our `Api\UserController`, i.e. the `Route::resource`
definition from `api.php` seems to be first now.

```
php artisan route:list -v | grep -A2 users.edit
  GET|HEAD        admin/users/{user}/edit .................................. users.edit › UserManagementController@edit
                  ⇂ web
                  ⇂ App\Http\Middleware\Authenticate:sanctum
--
  GET|HEAD        api/users/{user}/edit .......................................... users.edit › Api\UserController@edit
                  ⇂ api
                  ⇂ App\Http\Middleware\Authenticate:sanctum
```

As a fix, redirect to the controller action instead and don't rely on
the route name here:

https://laravel.com/docs/12.x/redirects#redirecting-controller-actions

I wonder if this is the most idiomatic approach though and what others
with non-API and API routes with identical names do.
schu added a commit to openmultiplechoice/openmultiplechoice that referenced this pull request Oct 2, 2025
Due to a change in the Laravel Framework with regards to route names and
precedence (presumably laravel/framework#56920),
route precedence for the `users.edit` route changed and one of our
redirects and tests that relies on the old implicit route order fails.

```
return redirect()->route('users.edit', ...)
```

used to redirect to `users.edit` of our `UserManagementController` but
now redirects to our `Api\UserController`, i.e. the `Route::resource`
definition from `api.php` seems to be first now.

```
php artisan route:list -v | grep -A2 users.edit
  GET|HEAD        admin/users/{user}/edit .................................. users.edit › UserManagementController@edit
                  ⇂ web
                  ⇂ App\Http\Middleware\Authenticate:sanctum
--
  GET|HEAD        api/users/{user}/edit .......................................... users.edit › Api\UserController@edit
                  ⇂ api
                  ⇂ App\Http\Middleware\Authenticate:sanctum
```

As a fix, redirect to the controller action instead and don't rely on
the route name here:

https://laravel.com/docs/12.x/redirects#redirecting-controller-actions

I wonder if this is the most idiomatic approach though and what others
with identical names for non-API and API routes do.
schu added a commit to openmultiplechoice/openmultiplechoice that referenced this pull request Oct 2, 2025
Due to a change in the Laravel Framework with regards to route names and
precedence (presumably laravel/framework#56920),
route precedence for the `users.edit` route changed and one of our
redirects and tests that relies on the old implicit route order fails.

```
return redirect()->route('users.edit', ...)
```

used to redirect to `users.edit` of our `UserManagementController` but
now redirects to our `Api\UserController`, i.e. the `Route::resource`
definition from `api.php` seems to be first now.

```
$ php artisan route:list -v | grep -A2 users.edit
  GET|HEAD        admin/users/{user}/edit .................................. users.edit › UserManagementController@edit
                  ⇂ web
                  ⇂ App\Http\Middleware\Authenticate:sanctum
--
  GET|HEAD        api/users/{user}/edit .......................................... users.edit › Api\UserController@edit
                  ⇂ api
                  ⇂ App\Http\Middleware\Authenticate:sanctum
```

As a fix, redirect to the controller action instead and don't rely on
the route name here:

https://laravel.com/docs/12.x/redirects#redirecting-controller-actions

As far as I can tell from the output below, the three fixed locations
are the only places where we have conflicting names in redirects.

```
$ php artisan route:list --json --sort name | jq -r .[].name | uniq -c | sort
[...]

$ git grep redirect app/Http/Controllers/
[...]
```

I wonder if this is the most idiomatic approach though and what others
with identical names for non-API and API routes do.
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