Laravel Routing Gotchas

Photo by Ivan Rudoy on Unsplash

Laravel Routing Gotchas

ยท

9 min read

Featured on Hashnode

Laravel's routing conventions are great. They do lots of very helpful magic things, but sometimes if I don't fully understand the magic I can find myself fighting against it. Today I finally understood one of my most common issues, so in this post I'll explain how it works before I forget!

(I'm taking bets on how long until I forget about this and find this post when googling for the solution ๐Ÿ˜…)

Nested Resource Controllers

Laravel's resource controllers are a great way to make it easy to follow conventions. Provide a base path and a controller and it generates conventional routes for you - amazing!

My Laravel apps are primarily for providing APIs, not UIs, so I usually use api resource controllers. It's basically the same, just without the create and edit routes to show the create/edit forms.

I would highly recommend watching Adam Wathan's Cruddy by Design talk. It's influenced how I define my routes and controllers and I think makes my apps a lot easier to understand. Resource controllers are a tool to use to follow the conventions outlined in Adam's talk.

I often find myself using nested resources. If I had a Patient model and patients could have Appointments, I would want routes like this:

GET         /patients                                       | patients.index
POST        /patients                                       | patients.store
GET         /patients/{patient}                             | patients.show
PUT|PATCH   /patients/{patient}                             | patients.update
DELETE      /patients/{patient}                             | patients.destroy

GET         /patients/{patient}/appointments                | patients.appointments.index
POST        /patients/{patient}/appointments                | patients.appointments.store
GET         /patients/{patient}/appointments/{appointment}  | patients.appointments.show
PUT|PATCH   /patients/{patient}/appointments/{appointment}  | patients.appointments.update
DELETE      /patients/{patient}/appointments/{appointment}  | patients.appointments.destroy

Thankfully, this is only two lines of code with Laravel:

// in api.php

Route::apiResource('patients', PatientsController::class);

Route::apiResource('patients.appointments', PatientAppointmentsController::class);

If you run php artisan route:list you will see the 10 routes as listed above, great!

Now imagine we have created our PatientAppointmentsController and it looks something like this:

class PatientAppointmentsController {
  // index, store methods above here

  public function show(Patient $patient, Appointment $appointment)
  {
    return $appointment;
  }

  // update, destroy methods below here
}

This code is so simple to write and understand but there are two subtle gotchas which have left me scratching my head several times:

Gotcha #1

The names of the arguments affect how the code works.

In your controller methods, the argument names must exactly match the parameter names in the route definition.

If you run php artisan route:list you will see parts of the route paths in curly brackets e.g. {patient} and {appointment}. Those are the route parameters. Your argument names ($patient and $appointment in this case) must exactly match those route parameters for implicit route model binding to work.

In the above example, Laravel will use the IDs provided to the {patient} and {appointment} parameters to load Patient and Appointment models and provide them as the $patient and $appointment arguments to the controller method.

My understanding of how it works is:

  1. Laravel looks at the definition of the patients.appointments.show route and the signature of the PatientAppointmentsController@show method
  2. It sees that there is a route parameter named patient and an argument named patient that is type-hinted as a Patient model
  3. It decides to be helpful and calls something like this pseudocode: Patient::findOrFail($routeParameters['patient'])
  4. If it finds the model it passes it to the controller method, if not it throws a ModelNotFoundException resulting in a 404 response
  5. Repeat steps 2-4 for the appointment parameter

Imagine if we wanted our Patient model to be in a variable named $user instead of $patient. This code won't work:

class PatientAppointmentsController {
  public function show(Patient $user, Appointment $appointment)
  {
    return $appointment;
  }
}

In this case, Laravel will see an argument $user that wants to be a Patient. There is no route parameter named user so it can't use route model binding. Laravel still tries to be useful, using zero config resolution with the service container to new up a Patient model. I think this is basically the equivalent of just calling $patient = new Patient(). This Patient model will not have any attributes, relations, connections, etc set. It will be just an empty Patient model.

So you won't get your Patient model based on the ID in the route parameter as expected. Not only that, but instead of binding an Appointment model for $appointment it will instead pass a string as that argument and you will get a type error:

Argument #2 ($appointment) must be of type App\\Models\\Appointment, string given

Another example of this I often run into is with models which have multiple words in their name:

// in api.php

Route::apiResource('processes.metrics', ProcessMetricsController::class);
class ProcessMetricsController {
  // index, store methods above here

  public function show(Process $process, ProcessMetric $processMetric)
  {
    return $processMetric;
  }

  // update, destroy methods below here
}

This is my own fault for wanting the model to be named ProcessMetric to differentiate with other kinds of metrics in the app. And then also wanting the URLs to be /processes/{process}/metrics etc rather than /processes/{process}/process-metrics, which would be the standard Laravel convention.

In this case $process will be bound to a Process model successfully, but $processMetric does not match the {metric} route parameter name set by default so Laravel will resolve an empty ProcessMetric model from the service container. Your API will return an empty object ๐Ÿคฆ๐Ÿปโ€โ™‚๏ธ.

Your two solutions here are:

  1. Make sure your argument names match up with the route parameter names (i.e. rename $user to $patient, or $processMetric to $metric)
  2. Override the route parameter names.

To override the route parameter names, go to your routes file and update your resource routes definition:

// in api.php

Route::apiResource('patients.appointments', PatientAppointmentsController::class)
  ->parameters([
        'patients' => 'user',
    ]);

// or

Route::apiResource('patients.appointments', PatientAppointmentsController::class)
  ->parameter('patients', 'user');

Note: it's patients, not patient. It uses the plural resource name, not the route parameter name. You can see the resource name(s) in the first argument to Route::apiResource('patients.appointments'). The two resources here are named patients and appointments.

Gotcha #2

The order of the arguments is important. You must also include "parent" resources to access the nested resource.

I'll explain what this means with an example. Let's look again at the show() method on a nested resource controller:

class PatientAppointmentsController {
  public function show(Patient $patient, Appointment $appointment)
  {
    return $appointment;
  }
}

We're not using the $patient argument so you might think it's safe to remove it:

class PatientAppointmentsController {
  public function show(Appointment $appointment)
  {
    return $appointment;
  }
}

Laravel looks at the route parameter names and the argument names and matches them up, right? I thought this would be marginally better for performance as maybe it wouldn't have to load the Patient model from the database and we could save a query.

This code doesn't work however. Laravel seems to require you have both the $patient and $appointment arguments.

The way in which this code doesn't work caused me to lose about an hour, thinking I was losing my mind. Remember how Laravel's service container will try and new up an empty model if its argument name doesn't match a route parameter name?

If you leave out the $patient argument, as in the example above, Laravel will give you an empty Appointment model. I couldn't figure out why my model didn't have data - it took me ages to realise what was going on.

So be warned: when writing nested resource controller methods, always include the "parent" models as arguments, otherwise you're going to have a bad time.

Scoping nested resources

To tee up my third gotcha, let's look again at our patients and appointments example. Imagine you have two patients:

  • A Patient with id 1 and Appointments with ids 11 and 12
  • A Patient with id 2 and Appointments with ids 21 and 22

Let's try making a few requests to our patients.appointments.show route:

GET /patients/1/appointments/11    โœ… Gets appointment 11 which belongs to patient 1, perfect.
GET /patients/1/appointments/12    โœ… Gets appointment 12 which belongs to patient 1, perfect.

GET /patients/2/appointments/21    โœ… Gets appointment 21 which belongs to patient 2, perfect.
GET /patients/2/appointments/22    โœ… Gets appointment 22 which belongs to patient 2, perfect.

GET /patients/1/appointments/21    โŒ Gets appointment 21 which belongs to patient 2, bad.
GET /patients/1/appointments/22    โŒ Gets appointment 22 which belongs to patient 2, bad.

GET /patients/2/appointments/11    โŒ Gets appointment 11 which belongs to patient 1, bad.
GET /patients/2/appointments/12    โŒ Gets appointment 12 which belongs to patient 1, bad.

The same "scoping" issue will apply to our patients.appointments.update and patients.appointments.destroy routes.

We could solve this in our controller code with something like this:

class PatientAppointmentsController {
  public function show(Patient $patient, int $appointmentId)
  {
    return $patient->appointments()->findOrFail($appointmentId);
  }
}

And that works great. However Laravel provides a way of doing this on the route definition:

// in api.php

Route::apiResource('patients.appointments', PatientAppointmentsController::class)
  ->scoped([
        'appointment' => 'id',
    ]);

If you want, instead of id you can use any other column on the appointments table, e.g. a slug.

Note: both our controller-code solution and the scoped() solution assume you have a appointments() relationship defined on your Patient model.

Now you can revert our changes to PatientAppointmentsController@show as Laravel will effectively add that condition to our query for us:

class PatientAppointmentsController {
  public function show(Patient $patient, Appointment $appointment)
  {
    return $appointment;
  }
}

If we run the same requests from earlier we will get:

GET /patients/1/appointments/11    โœ… Gets appointment 11 which belongs to patient 1, perfect.
GET /patients/1/appointments/12    โœ… Gets appointment 12 which belongs to patient 1, perfect.

GET /patients/2/appointments/21    โœ… Gets appointment 21 which belongs to patient 2, perfect.
GET /patients/2/appointments/22    โœ… Gets appointment 22 which belongs to patient 2, perfect.

GET /patients/1/appointments/21    โœ… 404 as it cannot find an appointment where id = 21 and patient_id = 1
GET /patients/1/appointments/22    โœ… 404 as it cannot find an appointment where id = 22 and patient_id = 1

GET /patients/2/appointments/11    โœ… 404 as it cannot find an appointment where id = 11 and patient_id = 2
GET /patients/2/appointments/12    โœ… 404 as it cannot find an appointment where id = 12 and patient_id = 2

Great! It works as we want it to. But...

Gotcha #3

If you want to override a route parameter name and add a scope at the same time your code would look something like this:

// in api.php
Route::apiResource('patients.appointments', PatientAppointmentsController::class)
  ->parameters([
        'patients' => 'user',
    ])
  ->scoped([
        'appointment' => 'id',
    ]);

Notice anything? The parameters() method uses the resource name but the scoped() method uses the route parameter name. patients vs appointment.

I'm not kidding when I say I have lost hours by passing patient instead of patients and appointments instead of appointment in cases like this.

Make sure to use the resource name for overriding parameters and the route parameter name for scoping.

Learn from my mistakes

Well there you have it. Those are some Laravel routing gotchas which are subtle enough that I regularly forget about them. Next time I run into them I can just read this post again.

Thanks for reading,

dave-calnan-signature.avif

ย