Nested Resource Controllers and Routes: Laravel CRUD Example

When building CRUD-like projects, sometimes you want some items be accessible only with their parent, for example in countries-cities relationships, you don’t want to list all the cities in the world, but only by country, like /countries/123/cities, where 123 is country_id. This article will show you how to do it, using Route::resource() and usual CRUD controllers.

Here’s our demo project – CRUD for Countries (mostly generated with our QuickAdminPanel):

You can see Cities column here, and that link on the number will be the only way to access City management. There won’t be any specific /cities URL, only the one that contains Country.

That’s how you would typically describe two CRUDs in routes/web.php file, right?

Route::resource('countries', 'CountriesController');
Route::resource('cities', 'CitiesController');

Now, let’s meet NESTED RESOURCE CONTROLLERS – here’s the syntax that you can use, with a dot symbol:

Route::resource('countries.cities', 'CitiesController');

That means that all URLs should contain /countries/[country_id]/ prefix. Also, it means that $country_id should be a parameter in ALL methods of CitiesController.

Here’s the full list of how URLs will look for all seven resource methods for Cities CRUD:

  • index($country_id) – GET request to /countries/X/cities
  • create($country_id) – GET request to /countries/X/cities/create
  • store($country_id, Request $request) – POST request to /countries/X/cities
  • show($country_id, City $city) – GET request to /countries/X/cities/Y
  • edit($country_id, City $city) – GET request to /countries/X/cities/Y/edit
  • update($country_id, City $city, Request $request) – PUT request to /countries/X/cities/Y
  • destroy($country_id, City $city) – DELETE request to /countries/X/cities/Y
List of cities by country – CitiesController@index method

As you can see, $country_id is a parameter everywhere now. Here’s the full code for our CitiesController:

class CitiesController extends Controller
{
    public function index($country_id)
    {
        $cities = City::where('country_id', $country_id)->get();
        return view('admin.cities.index', compact('cities', 'country_id'));
    }

    public function create($country_id)
    {
        return view('admin.cities.create', compact('country_id'));
    }

    public function store($country_id, Request $request)
    {
        City::create($request->all() + ['country_id' => $country_id]);
        return redirect()->route('countries.cities.index', $country_id);
    }

    public function edit($country_id, City $city)
    {
        return view('admin.cities.edit', compact('country_id', 'city'));
    }

    public function update($country_id, Request $request, City $city)
    {
        $city->update($request->all());
        return redirect()->route('countries.cities.index', $country_id);
    }

    public function show($country_id, City $city)
    {
        return view('admin.cities.show', compact('country_id', 'city'));
    }

    public function destroy($country_id, City $city)
    {
        $city->delete();
        return redirect()->route('countries.cities.index', $country_id);
    }
}

Here are some details to pay attention:

  • We’re passing ‘country_id’ to all the views in compact() method, cause then it is used in Blade to form all the links (will have example below)
  • We’re redirecting to route with naming countries.cities.index and passing the parameter of country_id
  • When storing the city record, we don’t have country_id in $request->all(), so we merge it from the method parameter: City::create($request->all() + [‘country_id’ => $country_id]);
Country won’t be a choice in city create form, we will take country_id from URL

Now, here’s how our Blade files are structured.

First, resources/views/admin/cities/index.blade.php should contain a few URLs:

Link for Adding new city to a particular country:

<a href="{{ route("countries.cities.create", $country_id) }}">Add City</a>

Link to Edit a particular city:

<a href="{{ route('countries.cities.edit', [$city->country_id, $city->id]) }}">Edit</a>

Form to Delete a particular city:

<form action="{{ route('countries.cities.destroy', [$city->country_id, $city->id]) }}" method="POST">
    <input type="hidden" name="_method" value="DELETE">
    <input type="hidden" name="_token" value="{{ csrf_token() }}">
    <input type="submit" class="btn btn-xs btn-danger" value="Delete">
</form>

As you can see, sometimes we have parameter country_id as part of $city object, in other cases – we take it from Controller as $country_id.

A few more last examples.

File resources/views/admin/cities/create.blade.php contains a form to add a city, which looks like this:

<form action="{{ route("countries.cities.store", $country_id) }}" method="POST">

File resources/views/admin/cities/edit.blade.php contains a form to update the city, which looks like this:

<form action="{{ route("countries.cities.update", [$city->country_id, $city->id]) }}" method="POST">

And that’s it! This way, we made our cities accessible to manage only by country, and not in one huge list of cities.

Interestingly, in the official Laravel documentation you can find Nested Resources only in 5.1 version docs, in current 5.7 they seem to be removed. So probably this feature was not popular enough? But hey, it’s still working!

Like our articles?
Check out our Laravel online courses!

7 COMMENTS

  1. It’s also likely it was removed because nested controllers appears to imply that you’d only be able to load /countries/12/cities/24/edit if City 24 belonged to country 12, but in reality laravel doesn’t care by default.

  2. I am not acting smart around here, but for example, I found a very convenient CQRS approach, send everything to the command, and control all changes in aggregate. So smooth and clean. what do you guys think about it? Here is all the controller logic and route example, and it is templated, you just create needed commands or queries in appropriate classes with its own logic.

    Route::post(‘countries/{countryId}/cities/{cityId}/edit’, ‘AppropriateController’);

    //in Controller
    public function __invoke(
    CommandGateway $commandGateway,
    $countryId,
    $cityId
    ) {
    // Send it to the CQRS command
    $commandGateway->send(EditCityCommand::create(
    $countryId,
    $cityId
    ));
    return $this->withSuccess();
    }

    // EditCommand class
    public static function create(
    string $countryId,
    string $cityId
    ) {
    return new static([
    ‘countryId’ => $countryId,
    ‘cityId’ => $cityId
    ]);
    }

    /** @return int */
    public function cityId(): int
    {
    return $this->payload()[‘cityId’];
    }

    /** @return int*/
    public function countryId(): int
    {
    return $this->payload()[‘countryId’];
    }

LEAVE A REPLY

Please enter your comment!
Please enter your name here