gadreel's avatar

Route Model Binding

https://laravel.com/docs/8.x/routing#route-model-binding

I want to find another way to do the route model binding but more dynamic. I find Laravel's route model binding (implicit and explicit) a bit limited for the way I want to use it.

On my code I have a lot of Controllers' that have methods that begin with the following:

if (!$model = Model::with('Relation')->find($id)) {
	return back()->withErrorMsg('Something went wrong')
}

OR

if (!$model = Model::with('Another Relation')->withTrashed()->find($id)) {
	return back()->withErrorMsg('Something went wrong')
} 

OR

if (!$model = Model::find($id)) {
	return back()->withErrorMsg('Something went wrong')
} 

Instead of repeating these blocks of code on a each method I want this to be done before reaching the controller. If the record is not found then have my own redirect logic if the record is found pass the model to the controller (similar way to what Laravel does) but with more control.

Laravel's route model binding will just find the record or throw an exception and if you need any relations you have to do extra call to the DB which is not ideal. Even if you override the UrlRoutable resolveRouteBinding method you wont be able to cover all the other scenarios I have above unless you create multiple model classes that extend the base model with different resolveRouteBinding logic. Also there is t no place to define your own redirect logic if the model is not found.

I tried to do this in a similar way that Laravel's does the Form Requests but the problem is that at the end the class that is passed to the controller is an object of BatchWithTrashedBinding and in order to get the model is to do the $binding->model

class ModelBinding implements FindModelWhenResolved
{
    use FindModelWhenResolvedTrait;

    /**
     * The container instance.
     *
     * @var Container
     */
    protected $container;

    /**
     * The request instance.
     *
     * @var Request
     */
    protected $request;

    /**
     * The route key.
     *
     * @var string $key
     */
    protected $key;

    /**
     * The model to bind.
     *
     * @var string
     */
    protected $model;

    /**
     * @var Closure
     */
    protected $query;

    /**
     * Begin querying a model with eager loading.
     *
     * @var array|string $relations
     */
    protected $relations = [];

    /**
     * Throw model not found exception.
     */
    protected function modelNotFound()
    {
        throw (new ModelNotFoundException())
            ->redirectTo($this->redirect());
    }

    /**
     * Get the URL to redirect to on a validation error.
     *
     * @return RedirectResponse|JsonResponse
     */
    protected function redirect()
    {
        return back();
    }

    /**
     * Set the Request instance.
     *
     * @param  Request  $request
     * @return $this
     */
    public function setRequest(Request $request): ModelBinding
    {
        $this->request = $request;

        return $this;
    }

    /**
     * Set the container implementation.
     *
     * @param  Container  $container
     * @return $this
     */
    public function setContainer(Container $container): ModelBinding
    {
        $this->container = $container;

        return $this;
    }

    /**
     * Get the route value.
     *
     * @return Route|object|string
     */
    protected function getRouteValue()
    {
        return $this->request->route($this->key);
    }
}
trait FindModelWhenResolvedTrait
{
    /**
     * Find the model of the resolved instance.
     */
    public function findResolved() {
        $query = $this->prepareNewQuery();

        $this->prepareAdditionalQueries($query, $this->setQueries());

        if (!$model = $this->find($query)) {
            $this->modelNotFound();
        } else {
            $this->model = $model;
        }
    }

    /**
     * Prepare the instance.
     *
     * @return mixed
     */
    private function prepareNewQuery()
    {
        $instance = $this->container->make($this->model);

        return $instance->newQuery();
    }

    /**
     * Prepare the instance queries.
     *
     * @param Builder $query
     * @param Closure $closure
     */
    private function prepareAdditionalQueries(Builder $query, Closure $closure)
    {
        $closure($query->newQuery());
    }

    /**
     * Find the model.
     *
     * @param Builder $query
     * @return mixed
     */
    private function find(Builder $query)
    {
        $value = $this->getRouteValue();

        return $query->where($query->getModel()->getRouteKeyName(), $value)->first();
    }

    /**
     * Set queries to the model.
     *
     * @return Closure
     */
    protected function setQueries(): Closure
    {
        return function () {};
    }
}
class ModelWithTrashedBinding extends ModelBinding
{
    /**
     * Define the route key.
     *
     * @var string $key
     */
    protected $key = 'model_id';

    /**
     * The model instance.
     *
     * @var Model
     */
    public $model = Model::class;

    /**
     * Where to redirect.
     *
     * @return RedirectResponse|JsonResponse
     */
    public function redirect()
    {
        $value = $this->getRouteValue();

        if ($this->request->wantsJson()) {
            return response()->json([
                'return' => false,
                'message' => 'Something went wrong'
            ]);
        }

       
            return redirect()->route('a route name')
                ->withErrorMsg('Something went wrong');
    }

    /**
     * Set additional queries.
     *
     * @return Closure
     */
    protected function setQueries(): Closure
    {
        return function(Builder $query) {
            $query->with('a relation')->withTrashed();
        };
    }
}
AppServiceProvider

/**
     * Boot the application events.
     *
     * @return void
     */
    public function boot()
    {
        $this->app->afterResolving(FindModelWhenResolved::class, function ($resolved, $app) {
            $resolved->findResolved();
        });

        $this->app->resolving(ModelBinding::class, function ($model, $app) {
            $model->setContainer($app)->setRequest($app->make(Request::class));
        });
    }

Then I use it like this on a controller.

class ExampleController extends Controller
{
    /**
     * Update a model.
     *
     * @param Request $request
     * @param BatchWithTrashedBinding $binding
     * @param  $id
     * @return RedirectResponse
     */
    public function update(Request $request, BatchWithTrashedBinding $binding, $id): RedirectResponse
    {
        return $this->doUpdate($request, $binding->model, $id);
    }
}
0 likes
14 replies
tykus's avatar

IMHO you are over-thinking this problem

you have to do extra call to the DB

This is not correct. You are finding by $id, that is one query. You are loading (eager or lazy) a relation; that is another query. Always two queries.

All of the scenarios you have listed are do-able using implicit and explicit route model binding. If you want to include trashed records, you can explicitly bind to a different wildcard name which can include the trashed records.

Also there is t no place to define your own redirect logic if the model is not found

Also incorrect; you can handle a ModelNotFound exception in app/Exceptions/Handler.php

gadreel's avatar

@tykus Thank you for your reply. Maybe you are right (that I am over thinking the problem).

If it's not much of a trouble...

Can you give me an example what do you mean with "a different wildcard" how to do this?

And also an example how to place the redirect logic in the ModelNotFound Exception?

tykus's avatar

You can add an explicit binding on a custom wildcard, for example, if I want an any_user wildcard to include trashed, my route definition will use the any_user wildcard:

Route::get('users/{any_user}' , function (User $anyUser) {
	// $anyUser may be soft-deleted
});
Route::bind('any_user', function ($id) {
    return User::withTrashed()->findOrFail($id);
});

Whereas, the conventional {user} wildcard will behave as normal, i.e. User::findOrFail($id) without the withTrashed scope.

https://laravel.com/docs/8.x/routing#customizing-the-resolution-logic

Snapey's avatar

Laravel's route model binding will just find the record or throw an exception and if you need any relations you have to do extra call to the DB which is not ideal

Laravel does do one query for the model and then in your controller you can load the relations you need.

$model->load('relation');

Yes, this is two queries, but so is

$model=Model::with('relation')->find($id);

Eloquent ORM will always do this as two distinct queries

gadreel's avatar

@tykus @snapey Yes I was totally wrong regarding the extra queries. Thanks a lot for clarifying this to me.

gadreel's avatar

@tykus @snapey The application I want to use the route model binding is modular. Each module has different routes and different RouteServiceProvider.

The Modules A and B they have similar routes but due to namespacing they execute different Controllers.

For example:

Route::namespace('Modules\ModuleA\Http\Controllers')->group(function () {
                        Route::prefix('batch/{batch_id}')->as('batch.')->group(function () {
                            Route::get('/', 'ExampleController@show')->name('show');
                        });
});
Route::namespace('Modules\ModuleB\Http\Controllers')->group(function () {
                        Route::prefix('batch/{batch_id}')->as('batch.')->group(function () {
                            Route::get('/', 'ExampleController@show')->name('show');
                        });
});

As you can see they both use the {batch_id} key. Is there a way I can instruct Route::bind or Route::model to be used differently depending on the Module? The reason I am asking this is because the Models are also different on each Module (they might have relations related to that Module only). So, if the route on Module B was requested I want to bind the batch_id to return the Modules\ModuleB\Models\Batch and in other cases the Modules\ModuleA\Models\Batch.

Otherwise the only solution I see is to change the name of the keys on the routes like {modulea_batch_id} and {moduleb_batch_id}...that will be very troublesome.

Thanks.

Snapey's avatar

It should bind to the model mentioned in the controller signature?

Snapey's avatar

in the controller in module A you will hint module A Batch model.

The route model binding with find the model in the correct module

Snapey's avatar

So no problem then? No need to mess with the Route Model Binding logic

gadreel's avatar

I am wondering if type hint works only on implicit binding. Because the explicit binding you have to return the model.

The goal is to be able to write my own resolution logic, throw my own exception that will redirect the user wherever I want and also by using the same key to bind different models.

Snapey's avatar

The binding is done according to what is specified by the controller... not the route. It matters nothing that multiple routes apparently share the same parameter name

Please or to participate in this conversation.