First, I'm happy to delete the following or move it to a gist if it doesn't belong here, but I think some real life example/use case might be a good idea. I will answer question if you have any.
I got a few places where this feature would be handy. I run an app where we have a bunch of stuff (Hotel/Restaurant/Shop/Activity) classified by Region (Like US State if it speaks more to you) then City.
Here is a stripped down example.
// actually, those are generated from an array ['hotel', 'restaurant', 'activity', ...] in the
// RouteServiceProvider, and the controllers are generated too, but beside the point
Route::get('/hotels/')->name('hotel.show')->uses('ListHotelController');
Route::get('/hotels/{region}/{city}/{hotel}')->name('hotel.list')->uses('ShowHotelController');
class Region extends Model {
public function cities() {
$this->hasMany(City::class)->orderBy('name');
}
}
class City extends Model {
public function hotels() {
$this->hasMany(Hotel::class)->orderBy('name');
}
public function region() {
$this->belongsTo(Region::class);
}
}
interface HasUrl {
public function url();
}
class Hotel extends Model implement HasUrl{
public function city() {
$this->belongsTo(City::class);
}
// Then in my views I can use $hotel->url() to get a link easily.
public function url() {
return route('hotel.show', [
$this->city->region,
$this->city,
$this,
]);
}
}
class BaseRepository {
// ex: 'hotel'
protected $model;
public function __construct($model) {
$this->model = $model;
}
public function list() {
$regions = Region::with('city.'.$this->model)->get();
$this->setInverseRelations($regions);
return $regions;
}
// this is the ugly part
protected function setInverseRelations($regions) {
$regions->each(function($region, $_) {
$region->cities->each(function($city, $_) use ($region) {
$city->setRelation('region', $region);
$city->{str_plural($this->model)}->each(function($model, $_) use ($city) {
$model->setRelation('city', $city);
}
});
});
}
}
abstract class ListModelController {
protected $repository;
protected $model;
public function __invoke() {
return view('model.list')
->with(['regions' => $this->repository->list(), 'modelType' => $this->model]);
}
}
class ListHotelController extends ListModelController {
public function __construct() {
$this->model = 'hotel';
$this->repository = new class($this->model) extend BaseRepository {}
}
}
{-- 'model.list' view, obviously hugely simplified --}
<ul>
@foreach($regions as $region)
<li>
<p>{{ $region->name }}</p>
<ul>
@foreach($region->city as $city)
<li>
<p>{{ $city->name }}</p>
<ul>
@foreach($city->{$modelType} as $model)
{-- LOOK HERE, $model->url()--}
<li><a href="{{ $model->url() }}">{{ $model->name }}</a></li>
@endforeach
</ul>
</li>
@endforeach
</ul>
</li>
@endforeach
</ul>
The whole setInverseRelations($regions)
mess is necessary to prevent the N+1 problem in that view.
Instead of the url()
method, we could have done
public function createUrlFrom(Region $region, City $city) {
return route('hotel.list', [$region, $city, $this]);
}
but it felt strange to pass related model from outside the class, and would be strange when working from the other side :
//In places where we loaded only one hotel
$this->createUrlFrom($this->city->region, $this->city);
This is one of the place I could clean up if this feature is added.
Side notes :
- All Models redefine the
getRouteKey
method - All Models implements the
HasUrl
interface (which is a bit more than 1 method actually)
Complete side step, ignore if you don't have time, or tell me where to put it for later.
Nested model routing, and routing from multiple parts isn't the easiest to do in Laravel. Is that something you are interested in expanding/power up ?
2 things gripped me :
- No easy/clean way to validate relations.
With a route like '/{region}/{city}/{hotel}', where the Hotel belongs to the city and the City to the Region, you either have to use \Route::input('region')
inside route model bindings closures, and be relying of parameter order, or validate after SubstitueBindings was applied in a middleware (or modify SubstituteBindings).
- No easy/clean way to bind 1 model from 2+ parts.
With a route like '/{first}/{second}' you can't easily match SomeModel::where('first', $route->parameter('first'))->where('second', $route->parameter('second'))->findOrFail();
to a variable name in your controller without using a bunch of $route->forgetParameter()
and $route->setParameter()
in a middleware. I'm still trying to understand why you have to forget parameters.