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 Appointment
s, 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:
- Laravel looks at the definition of the
patients.appointments.show
route and the signature of thePatientAppointmentsController@show
method - It sees that there is a route parameter named
patient
and an argument namedpatient
that is type-hinted as aPatient
model - It decides to be helpful and calls something like this pseudocode:
Patient::findOrFail($routeParameters['patient'])
- If it finds the model it passes it to the controller method, if not it throws a
ModelNotFoundException
resulting in a 404 response - 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:
- Make sure your argument names match up with the route parameter names (i.e. rename
$user
to$patient
, or$processMetric
to$metric
) - 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 ids11
and12
- A Patient with id
2
and Appointments with ids21
and22
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,