framework icon indicating copy to clipboard operation
framework copied to clipboard

Resource Controller registration causes non-resource routes to be ignored

Open bluesheep100 opened this issue 3 months ago • 11 comments

Laravel Version

12.38.1

PHP Version

8.3.27

Database Driver & Version

SQLite 3.37.2

Description

Routes using a resources prefix (e.g. /users for the User model) placed after a Route::resource() call seem to be ignored, but still shown in the list when running php artisan route:list.

Steps To Reproduce

Discovered on v12.36.1, verified on v12.38.1

Tested on a brand new starter kit free application:

  • laravel new routetester
  • No starter kit
  • Pest
  • SQLite database

Test case setup:

  • Create a resource controller: php artisan make:controller ExampleController -r
  • Add return to_route('welcome'); to the index() method

Then add this method to the controller:

public function test()
{
    return to_route('welcome');
}

And the following route definitions to routes/web.php:

Route::resource('examples', ExampleController::class);
Route::get('/examples/test', [ExampleController::class, 'test'])->name('examples.test');

Run php artisan serve and visit localhost:8000/examples, which should display the welcome page.

Visiting localhost:8000/examples/test however, gives a blank white page, with 200 OK as the status code. If the order of the route definitions are switched, the welcome page is displayed. If using a DELETE request instead of GET, this issue seems to end in a 404 Not Found.

bluesheep100 avatar Nov 13 '25 13:11 bluesheep100

Just to confirm, you're saying this has been an issue since at least 12.36.1, right?

cosmastech avatar Nov 13 '25 13:11 cosmastech

You are declaring two similar routes.

GET /examples/{example} (created by the resource method) GET /examples/test

Declare /examples/test before the Route::resource() and it will be fixed. This happens in every router I have worked with.

JesterIruka avatar Nov 13 '25 21:11 JesterIruka

Just to confirm, you're saying this has been an issue since at least 12.36.1, right?

Yes. I originally discovered this at work, in a 12.36.1 application, but since it also uses Vue, Inertia, and many other packages, I created a new application as bare as possible to confirm it, in case it was a controller override, or some package interfering with the route definitions.

I was a little rushed so I forgot to mention that the project I discovered this in also contains numerous instances of this pattern where the issue does NOT occur, and I don't currently have any idea why, which is why this confused me so much.

bluesheep100 avatar Nov 14 '25 00:11 bluesheep100

You are declaring two similar routes.

GET /examples/{example} (created by the resource method) GET /examples/test

Declare /examples/test before the Route::resource() and it will be fixed. This happens in every router I have worked with.

I do know that switching the order fixes the issue, as I wrote above. However I do think this is a decent idea as to why the issue occurs. I checked the stack trace of the 404 error and it happens In Route:resolveRoute(), (or something similar) which I'd presume means it's attempting to perform implicit model binding or something, despite there being no model to match to. Maybe /examples/test and /example/{example} just conflict. I can probably live with that, but arguably it could use a dedicated error for clarity.

bluesheep100 avatar Nov 14 '25 00:11 bluesheep100

I do know that switching the order fixes the issue, as I wrote above. However I do think this is a decent idea as to why the issue occurs.

Not sure if there is a way to “fix it,” since it's not broken. The framework is following the declaration order that you defined. If it is declared first, the priority works, otherwise, it doesn't.

Ideally, you should use Route::whereNumber to match {example} only as \d+ (an incrementing primary key), which will not match /examples/test and will allow it to match the next route.

Also, this has been an “issue” at least since Laravel 6, which is the first version I used.

JesterIruka avatar Nov 14 '25 05:11 JesterIruka

I was a little rushed so I forgot to mention that the project I discovered this in also contains numerous instances of this pattern where the issue does NOT occur, and I don't currently have any idea why, which is why this confused me so much.

Perhaps you are using the Route::whereNumber or Route::pattern to only match a specific regex? As I explained above, this will solve the problem, since /examples/test does not match /examples/[0-9]+

JesterIruka avatar Nov 14 '25 05:11 JesterIruka

I also agree that this is not technically a problem, however I also think it can be a source of a lot of frustration (I've seen at least one senior dev debugging a "missing" route and not noticing that this is what happened)

I think we could be more conceptually consistent if we throw an error when we come upon an unreachable route, the same way we throw an error if there are two different routes with the same name.

ellnix avatar Nov 16 '25 09:11 ellnix

Interesting how (overhead) features are complicating devs lifes when they were intended to ease them... Developer convenience... Laravel's features should be used ONLY after the developer understands what they do behind the scenes.

marius-ciclistu avatar Nov 16 '25 12:11 marius-ciclistu

Interesting how (overhead) features are complicating devs lifes when they were intended to ease them... Developer convenience... Laravel's features should be used ONLY after the developer understands what they do behind the scenes.

I do understand how routing works generally, but if I had to read the entire source of the framework before defining routes, I'd get nowhere. Clearly what I missed is that the router doesn't simply skip over conflicts but instead creates a second definition with overlapping match criteria.

It'd be nice if this situation had a clear exception message, and/or a better way of avoiding it than having to remember which order to place the route definitions in, rather than a developer (me) having to spend an hour debugging to figure out why a route that is clearly defined returns a 404.

bluesheep100 avatar Dec 01 '25 12:12 bluesheep100

@bluesheep100 Sadly all Laravel is built like this. You have to know what is hidden under the carpet in order to not get nasty surprises. This is why I avoid using features from it when I can do them my self in an easier and more explicit way.

marius-ciclistu avatar Dec 01 '25 12:12 marius-ciclistu

I personally think most of the value in Laravel and similar frameworks is lowering the barrier to entry by allowing devs to avoid reinventing the wheel and avoid running into common issues.

I have never contributed to Laravel, but I would like to get started. If any decision is reached on a solution I would like to submit a PR for it, if possible.

Here are the two possible solutions I have in mind (I am open to discuss and/or implement others)

A. Simply throwing an error about conflicts on optimize

Simply throwing an error about conflicts on `optimize` When the user runs `php artisan optimize`, anytime a route is registered, use the URL of that route as an incoming request in the router and if it's matched by another route, throw an error.
// web.php
<?php
// Before
Route::resource('/users', UsersController::class)->names('users');
Route::get('users/inactive', [UsersController::class, 'inactive_index'])->name('users.inactive');
$ php artisan optimize

Error: Route users.inactive matches users.show

There would be some edge cases wrt. routes with parameters, but even if those routes are simply ignored this error could have value in giving the users something to google instead of being in the dark.

B. Adding additional routes to a resourceful route using Rails-like member and collection functions

Adding additional routes to a resourceful route using Rails-like `member` and `collection` functions We can avoid this problem entirely by allowing and encouraging resourceful routing with custom routes attached to a resourceful route, similar to how Rails does it.

Here are two examples that would result in completely equivalent route names and endpoints:

// web.php
<?php
// Before
Route::post('users/{user}/follow', [UsersController::class, 'follow'])->name('users.follow');
Route::get('users/inactive', [UsersController::class, 'inactive_index'])->name('users.inactive');
Route::resource('/users', UsersController::class)->names('users');

// After
Route::resource('/users', UsersController::class)->names('users')
    // $resource_controller is UsersController::class
    // - Avoids repeating the controller name
    // - Allows using a different controller instead
    // - Allows us to use the same familiar Route::verb syntax
    //        instead of having to learn an entirely new syntax that doesn't repeat the controller
    ->member(function($resource_controller) { 
        // URL: POST 'users/{user}/follow/' 
        // ACTION: UsersController->follow
        // NAME: users.follow
        Route::post('follow/', [$resource_controller, 'follow'])->name('follow');
    })
    ->collection(function($resource_controller) {
        // URL: GET 'users/inactive/' 
        // ACTION: UsersController->inactive
        // NAME: users.inactive
        Route::get('inactive/', [$resource_controller, 'inactive'])->name('inactive');
    });

ellnix avatar Dec 06 '25 15:12 ellnix