Laravel has many undocumented features, and unfortunately, unless you're dedicating large chunks of time to digging through the source code and tracking what's going on, these features are likely to pass you by. Fortunately, I have done, and continue to do, exactly that.
Most of you reading this will be familiar with the concept of Laravel's route model binding, but for those who aren't, allow me to explain. When a route is being handled, if that route has a parameter whose name matches a parameter on its handler, which is typed as a model, Laravel will attempt to resolve the model using the value from the URL.
Take this route, with its user route parameter, and a handler parameter user, with the type of App
.
use App\Models\User;
Route::get('/profile/{user}', function (User $user) {
//
});
When someone visits https://mysite.com/profile/1, this route will be hit, and the function that handles the route will
receive an instance of User
that represents the user with ID 1. You could also define the route parameter like
{user:username}, so you could instead use https://mysite.com/profile/ollieread, and have the user resolved by the
username field/column.
Laravel's documentation covers this in more detail, and even briefly touches on how the model is actually resolved. If you aren't familiar with the ins and outs of this feature, I highly recommend reading the docs before continuing with this article.
Assuming that you've either done that, or don't need to, let's continue.
How it Works
Anyone who has created a route within Laravel and either not attached the web middleware group, or created a custom
middleware group, knows that there's a very important piece of middleware when it comes to route model binding. I'm
talking about the Illuminate
middleware which is
responsible for resolving all bindings, whether implicit or explicit.
The most important bits of this middleware, for our current purpose at least, are lines 40-41.
$this->router->substituteBindings($route);
$this->router->substituteImplicitBindings($route);
The first line deals with explicit bindings, and the second deals with implicit bindings. Explicit bindings are where you explicitly tell Laravel specifically how to resolve a route parameter, and it's the way that we're encouraged to handle non-model route parameters that are not injectable through the container.
It's only fair
I think it's only fair that readers of this article should nod their head in recognition of the fact that I did not make a very obvious, and easy joke about the usage of the term explicit.
After all, I am a professional.
However, we don't care about explicit bindings, we care about the implicit ones, the ones where the binding resolution is implied (read: magic). Because, what if we're writing an ORM as an alternative to Eloquent, and we want the objects in there to be automatically resolvable for routes? Or perhaps we're using objects to represent static data on the filesystem, and we want to be able to use them in routes?
So how exactly does it work?
The Magic of Implicit Bindings
It all obviously starts with the call to Illuminate
, which by default is implemented by Illuminate
, which you can see here and below.
public function substituteImplicitBindings($route)
{
$default = fn () => ImplicitRouteBinding::resolveForRoute($this->container, $route);
return call_user_func(
$this->implicitBindingCallback ?? $default, $this->container, $route, $default
);
}
This method offloads the job of resolving the implicit bindings to Illuminate
whose full source can be found here.
Complete Customisation
Nine times out of ten, your implicit bindings will be handled this way, though as you can see from this method, it's
possible to change that. If you call Illuminate
with a Closure
, it will be used to handle the binding. The callback will receive
the following arguments in the following order.
Illuminate- The typing is never explicitly stated, but unless you're doing something very different, or only using parts of Laravel, this will actually be an instance of\ Container \ Container Illuminate.\ Foundation \ Application Illuminate- An object representing the route that is being handled.\ Routing \ Route Closure- A callback for the default handling of implicit bindings, which accepts no arguments and callsIlluminate.\ Routing \ ImplicitRouteBinding :: resolveForRoute ()
This is a very good way to customise this logic, though it's only really useful if you're able to write a callback that can handle a large number of custom classes without having to update it for each one. If you're having to add every new class to your custom handler, it's just implicit bindings with extra steps.
The method that handles the parameter resolution is Illuminate
, which you can see here. The method itself is huge, so
I'm not going to go through it all here, but I will highlight a few key parts that are important to our purposes.
Version Discrepancy
The code in this article is taken from the latest version of Laravel, which is v12.41.1, so some of the code may not match what you have in front of you. This particular class was modified slightly in v12.19.0.
On line 29 the method loops over the return from a call to Illuminate
which in this instance, will return all parameters from the route handler whose type is a
subclass of Illuminate
.
foreach ($route->signatureParameters(['subClass' => UrlRoutable::class]) as $parameter) {
This is actually where we could stop, because we've reached the bit we need to know about, which is the interface. Feel free to skip to the next section, though there are a couple of additional bits that, while not massively relevant right now, are useful to know.
Handler parameters whose name does not match the route parameter name, which are checked here on lines 30 through 32,
are skipped. The Illuminate
method
will only return a valid value if the handler parameter's name exactly matches a route parameter, or whose name is
exactly the same when converted to snake case using Illuminate
.
if (! $parameterName = static::getParameterName($parameter->getName(), $parameters)) {
continue;
}
The final bit of this method that's worth noticing may possibly save you a headache or two when debugging in the future.
On lines 36-38 there's a short-circuiting statement that skips the rest of the loop's iteration if the route parameter
is already an instance of Illuminate
. This is primarily
here so that binding resolution doesn't generate multiple unnecessary queries, attempting to implicitly resolve a route
parameter that has already been resolved explicitly.
if ($parameterValue instanceof UrlRoutable) {
continue;
}
It should be noted that the specific way that this handles existing values allows you to both explicitly and implicitly
resolve the same parameter. For example, if the route parameter should contain a UUID, you could add an implicit binding
that validates and converts it to an instance of Ramsey
. If the
matching handler parameter is a model, this value will be passed to the appropriate methods, all of which accept
mixed
.
Taking the Model out of Route Model Binding
Making a custom non-model class work with implicit route parameter binding is as simple as implementing the Illuminate
interface. Before you get to that, let's take a
quick look at the methods on the interface to help avoid any possible confusion or oddities.
The Illuminate\Contracts\Routing\UrlRoutable::getRouteKeyName()
Method
This isn't the first method in the interface, but it should be mentioned before the actual first method. It is
responsible for returning the name of the column, attribute, or field that contains the route key. Its default
implementation, Illuminate
returns the
name of the attribute containing the primary key. It's also present on Illuminate
using the Illuminate
trait, whose implementation proxies the call to the underlying
resource.
The only usages of this method are in the Illuminate
class,
and specifically relate to implementations of the Illuminate
interface. They are Illuminate
and Illuminate
. Your custom class most likely won't need to care
about this method, though you do still need to implement it. Its docblock specifies the type string
, but the method
itself has no return type.
My Recommendation
If the concept of a "route key name" doesn't apply to your implementation of Illuminate
, I suggest throwing an exception inside this method, to avoid any odd
cases where it's called. If the exception is thrown, it's a good indicator that something somewhere has gone wrong.
The Illuminate\Contracts\Routing\UrlRoutable::getRouteKey()
Method
This method should return the value that will be used to resolve the route parameter. Its default implementation, Illuminate
returns the model's primary key via Illuminate
. The Illuminate
uses the Illuminate
trait, which also implements this method, and just proxies it to
the resource.
This method is used by the Illuminate
class when formatting
parameters, which allows you to provide instances of Illuminate
, such as models, when generating URLs. It's also used by the URI helper
class Illuminate
when providing query parameters.
The Illuminate\Contracts\Routing\UrlRoutable::resolveRouteBinding()
Method
The Illuminate
method is
arguably the most important one. It's where the actual resolution of the final resource happens. It has a default
implementation on the Illuminate
class and Illuminate
trait. As you can imagine, the one on the trait proxies, but
the one on the model calls Illuminate
, which adds a where clause and returns the query object, which is then executed and its
first result returned.
For your implementation of this method, that is a pretty big thing to consider that may change how you go about it. This
method is not static
, so an instance of the class will have already been created when this
method is called. Luckily,
this instance is created using the container, so you do have access to dependency injection, though I do know that this
creates a bit of a dirty solution.
This method has the following two parameters, which appear in the following order.
mixed$value- This is the value of the route parameter as it was provided, whether from the URL or an explicit binding. The parameter type is only specified in the docblock.string| null $field- This is usuallynull, unless the binding field was provided when defining the route. This is the:usernamebit from the start of this article.
While the method itself doesn't enforce a specific return type, the docblock specifies it as Model
, so if you're
using any static analysis tools, they're going to complain a lot.
My Recommendation
Have this method return $this
and populate itself in place, with any dependencies needed to
perform the resolution
injected in the constructor. If necessary, simply set a property with the passed-in value, and make the class instance a
deferred object.
The Illuminate\Contracts\Routing\UrlRoutable::resolveChildRouteBinding()
Method
The final method on this interface makes it glaringly obvious that this system was very much created for models, and
model wrappers (resources), without much consideration for other classes. It has the same issues as the above method
also not being static
but again, the instance it's called on will have been through the
container, so there is some
saving grace there.
It is of course implemented on both the Illuminate
class
and Illuminate
trait. Though the implementation on
the trait throws an Exception
, that's right, a base exception with the message
Resources may not be implicitly resolved from route bindings.
This method has the following three parameters, which appear in the following order.
string$childType- This is the "name of the child type", which when dealing with a model would be the relationship name, though could in theory be anything with a custom implementation.mixed$value- This is the value of the route parameter as it was provided, whether from the URL or an explicit binding. The parameter type is only specified in the docblock.string| null $field- This is usuallynull, unless the binding field was provided when defining the route. This is the:usernamebit from the start of this article.
Unless otherwise specified, route parameters are scoped based on the order they appear. The assumption is made that
every route parameter is a child of the one that comes before it. When the implicit bindings are being processed
inside Illuminate
, an attempt is made
to retrieve a parent. If scoped bindings are not disabled, and there's a route parameter before this one, its value,
which should have already been resolved, will be used.
What this means for us is that the Illuminate
will be called on the parent route parameter, and the
$childType
parameter on the method, will contain the name of the child route parameter,
which is the current one.
Confused? Yeah, it took me a while to wrap my head around it.
Imagine the following route definition that lets you access entries within datasets that aren't typical databases, such as XML or CSV files.
use App\Data\Dataset;
use App\Data\Entry;
Route::get('/data/{dataset}/{entry}', function (Dataset $dataset, Entry $entry) {
//
});
When the route parameters are being processed for a request to /data/agents/47, the following would happen.
- The
{dataset}parameter would come first, which would be mapped toApp, resulting in a call to\ Data \ Dataset Appwith arguments\ Data \ Dataset :: resolveRouteBinding () 'agents'andnull. - Next comes the
{entry}parameter, but since scoped bindings are enabled, this would result in a call toAppwith arguments\ Data \ Dataset :: resolveChildRouteBinding () 'entry',47, andnull.
I think this may be an oversight, but because of how the bindings are handled, an instance of App
will have already been created by the container, even though it's only used in a call to
get_class
in the case of an error.
What's Next?
How you go about using this, whether you even want to or not, is entirely up to you. It's very clear from the code that although it looks very flexible on the surface, it's really intended just for models. I have ideas for how to improve this, and it's something I may very well PR soon. Like always, my intention was to share with you the inner details that aren't covered in the documentation. So go, have fun, create classes capable of being implicitly resolved, or look deeper into the implementation and get as frustrated as I did the first time I needed to investigate it.
The Hidden Parts of Laravel
This article was an interesting one to write, as I think it hits a nice balance between covering the internals of how a feature works, as well as expanding on the bit that isn't covered in the docs. I'll be covering more of those in coming articles, but for now I hope that you find this useful, whether as a solution to a problem you're having, or as a way to learn more about Laravel.