#[RouteParameter] Does Not Bind Your Model
If `#[RouteParameter]` in your Laravel form request receives a string instead of a model, the issue is usually not that the attribute failed to bind. It is that the attribute never binds anything in the first place.
Laravel's #[RouteParameter] attribute is a neat way to give route parameters a typed home in classes resolved by the container.
I started using it in form requests so that route-bound models were available during authorisation without falling back to request()->route('event') or adding @var annotations.
But there's a sharp edge here: #[RouteParameter] only reads the current route parameter value. It does not perform route model binding.
So if you use it like this:
class UpdateEventRequest extends FormRequest
{
public function __construct(
#[RouteParameter('event')] public readonly Event $event,
) {
parent::__construct();
}
}
and the corresponding controller action does not give Laravel enough information to implicitly bind {event}, the attribute may receive the raw route string instead of an Event model.
This post is specifically about that interaction: #[RouteParameter] inside a form request, implicit route model binding, and why the matching controller method signature still matters.
Why #[RouteParameter] is useful
Laravel's container attributes are a relatively modern part of the framework. They first appeared in Laravel 11 as part of the service container's contextual attributes feature, alongside attributes like #[Config], #[Cache], and #[CurrentUser].
They solve a real problem.
For a long time, if you wanted to access a route-bound model somewhere outside the controller signature, you might do something like this:
$event = request()->route('event');
If {event} has been resolved through Laravel's route model binding, then $event will be an Event model instance at runtime.
But your editor, static analyser, and future self do not necessarily know that. You have to just trust the framework to make it true.
So you end up adding a PHPDoc annotation:
/** @var Event $event */
$event = request()->route('event');
That works, but it's kinda grim. The type information lives in a comment, and the actual code still says "give me whatever route parameter exists with this name".
#[RouteParameter] gives that value a typed place to land:
use App\Event;
use Illuminate\Container\Attributes\RouteParameter;
class UpdateEventRequest extends FormRequest
{
public function __construct(
#[RouteParameter('event')] public readonly Event $event,
) {
//
}
}
That's much nicer. It's explicit, constructor-promoted, readonly if you want it to be, and your tooling can understand the expected type.
This is especially tempting in form requests, where you may want route-bound models available in authorize() or rules().
Note: the framework will method-inject in authorize and rules, but I find it simpler to define it once and use it everywhere.
use App\Event;
use Illuminate\Container\Attributes\RouteParameter;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventRequest extends FormRequest
{
public function __construct(
#[RouteParameter('event')] public readonly Event $event,
) {
parent::__construct();
}
public function authorize(): bool
{
return $this->user()->can('update', $this->event);
}
public function rules(): array
{
return [
// ...
];
}
}
This is clean, expressive, and type-safe.
Until it's not.
Why I moved the model into the request
The place this surfaced for me was not a toy example.
In the Laracon AU platform, I had a number of controller actions where the route-bound models existed mostly for guard clauses:
class UpdateEventController
{
public function __invoke(UpdateEventSettingsRequest $request, Event $event)
{
abort_unless($request->user()->can('update', $event), 403);
// The rest of the controller only needs validated request data...
}
}
The controller was not really using $event as part of the action. It was only there so the controller could make an authorisation decision before doing the actual work.
That felt like form request territory.
Laravel form requests already have an authorize() method, and moving the guard there keeps the controller focused on handling the successful path:
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('event'));
}
}
So the next step seemed obvious: move the route-bound model into the request as well.
class UpdateEventRequest extends FormRequest
{
public function __construct(
#[RouteParameter('event')] public readonly Event $event,
) {
parent::__construct();
}
public function authorize(): bool
{
return $this->user()->can('update', $this->event);
}
}
Now the controller can drop the unused route-bound model parameter:
class UpdateEventController
{
public function __invoke(UpdateEventSettingsRequest $request)
{
// ...
}
}
That's where the problem appeared.
The refactor looks reasonable: the authorisation concern moved into the form request, and the controller no longer advertises a dependency it doesn't use.
But removing Event $event from the controller signature also removed the hint Laravel was using to perform implicit route model binding.
The gotcha
Imagine this route:
Route::put('/events/{event:public_id}', UpdateEventController::class);
And this controller:
class UpdateEventController
{
public function __invoke(UpdateEventRequest $request)
{
// ...
}
}
At first glance, that looks reasonable. The controller doesn't need the Event directly because the form request already has it.
But this can fail with an error like:
UpdateEventSettingsRequest::__construct():
Argument #1 ($event) must be of type App\Event, string given
The form request expected an Event model, but Laravel passed it the raw route parameter string.
The fix is simple, but looks strange if you don't know what's happening:
class UpdateEventController
{
public function __invoke(UpdateEventSettingsRequest $request, Event $event)
{
// ...
}
}
Even if you never use $event in the controller body, adding it to the controller signature can be enough to make the form request receive an Event model instead of a string.
That feels odd.
Why should the controller method need a parameter that the form request already has?
What the attribute actually does
Laravel's RouteParameter attribute is very small.
At the time of writing, its resolver looks like this:
public static function resolve(self $attribute, Container $container)
{
return $container->make('request')->route($attribute->parameter);
}
That's the whole trick.
#[RouteParameter('event')] is essentially a way of saying:
request()->route('event');
that allows you to put a real, typed property into your constructor.
But, it doesn't query the database. It doesn't inspect the Event type. It doesn't call resolveRouteBinding(). It doesn't perform implicit route model binding.
It simply returns whatever value is currently stored on the route for that parameter name.
If the route parameter is currently an Event, you get an Event.
If the route parameter is currently a string, you get a string.
The attribute solves the type-expression problem, not the binding problem.
Where route model binding happens
Laravel route parameters start life as strings from the URL.
For a route like this:
Route::put('/events/{event:public_id}', UpdateEventController::class);
the initial route parameter is effectively:
[
'event' => 'evt_abc123',
]
Laravel's SubstituteBindings middleware is responsible for replacing those raw values with bound values.
The relevant part looks like this:
// ...
$this->router->substituteBindings($route);
$this->router->substituteImplicitBindings($route);
// ...
Explicit bindings are handled first, then implicit bindings.
The important bit for this issue is implicit binding. Laravel determines which route parameters should be implicitly bound by reflecting the route action signature.
Internally, Laravel looks for route signature parameters that implement UrlRoutable:
// ...
foreach ($route->signatureParameters(['subClass' => UrlRoutable::class]) as $parameter) {
// Resolve the route parameter into a model...
}
// ...
Eloquent models implement UrlRoutable, so this works when your controller method says:
public function __invoke(UpdateEventRequest $request, Event $event)
Laravel sees Event $event in the route action signature, matches it to {event} parameter, and resolves ot into an Event model.
But if your controller only says this:
public function __invoke(UpdateEventRequest $request)
then there is no Event $event in the route action signature.
Your form request constructor is not the route action signature. Laravel does not use it to discover implicit route bindings.
So by the time #[RouteParameter('event')] is resolved, the route may still contain the raw string from the URL.
The request flow
This is the rough flow for a controller route using a form request:

The ordering is the important part.
The form request is validated before the controller method body runs, but implicit route binding has already had its chance by then.
If Laravel could discover the model from the controller signature, the form request receives the model.
If Laravel could not discover the model from the controller signature, the form request receives whatever was already on the route.
The rule of thumb
If a form request constructor uses #[RouteParameter] for an implicitly bound model, keep that same model typed on the controller action signature.
class UpdateEventRequest extends FormRequest
{
public function __construct(
#[RouteParameter('event')] public readonly Event $event,
) {
parent::__construct();
}
}
Pair it with:
class UpdateEventController
{
public function __invoke(UpdateEventRequest $request, Event $event)
{
// ...
}
}
It can feel redundant, but it gives Laravel the information it needs to perform implicit route model binding before the form request asks for the route parameter.
What about $this->route('event')?
The same underlying rule applies.
Inside a form request, this:
$this->route('event');
also reads the current route parameter value.
If implicit binding has resolved the parameter, you will get the model.
If implicit binding has not resolved the parameter, you will get the raw string.
The difference is that #[RouteParameter] lets you express the expected type in the constructor. That makes the failure more obvious because PHP will throw a type error instead of letting the unexpected value drift further into your code.
Explicit bindings are different
This post is about implicit route model binding.
If you register an explicit binding with Route::model() or Route::bind(), Laravel can bind the parameter by name:
Route::model('event', Event::class);
or:
Route::bind('event', function (string $value) {
return Event::where('public_id', $value)->firstOrFail();
});
Those bindings do not depend on discovering Event $event from the controller method signature in the same way.
But in my experience, most Laravel applications rely heavily on implicit route model binding, particularly with routes like {event:public_id}. In that common case, the controller or route closure signature still matters.
Other failures can look similar
There are other reasons a Laravel route parameter might still be a string when you expected a model. The route might not be using the SubstituteBindings middleware, the parameter name might not match, a custom binding might be wrong, or the route might not be using implicit binding at all.
This post isn't trying to cover every route model binding failure mode.
The specific gotcha here is narrower: when you use #[RouteParameter] in a form request constructor, it can look like the attribute should resolve the model because the constructor parameter is typed as a model. It does not. It only reads whatever Laravel has already placed on the route.
PHP's type system ensures that is the case at runtime, and your IDE is able to get full type support as well.
The takeaway
#[RouteParameter] is a great attribute. I like using it. It gives route parameters a clean, typed home and avoids the need for little @var annotations scattered through request objects.
But it's not magic model binding.
It doesn't turn a string into a model.
It only reads the route parameter after Laravel has had whatever opportunity it was given to bind it.
So if #[RouteParameter] in your Laravel form request receives a string instead of a model, check the corresponding controller method. If the model's only typed in the form request constructor, Laravel may never have discovered it for implicit route model binding.
The attribute tells PHP what you expect.
The controller signature tells Laravel what to bind.
Written by Michael Dyrynda
Principal Engineer, Laravel enthusiast, and open source contributor. I write about web development, PHP, and the problems I solve along the way.