Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Lelectrolux/db59349e1d3a7481f6e91b7c001df418 to your computer and use it in GitHub Desktop.
Save Lelectrolux/db59349e1d3a7481f6e91b7c001df418 to your computer and use it in GitHub Desktop.

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.


Routes
// 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');
Models
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,
        ]);
    }
}
Repository
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);
                }
            });
         });
     }
 }
Controllers
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 {}
    }
}
View
{-- '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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment