Eloquent: Recursive hasMany Relationship with Unlimited Subcategories

Tutorial last revisioned on July 17, 2023 with Laravel 10

Quite often in e-shops you can see many level of categories and subcategories, sometimes even unlimited. This article will show you how to achieve it elegantly with Laravel Eloquent in two methods.

We will be building a mini-project to views children shop sub-categories, five level deep, like this:

hasMany Relationship with Unlimited Subcategories


Method 1: Eager Loading Children Categories

Database Migration

Here's a simple schema of DB table:

Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedBigInteger('category_id')->nullable();
$table->foreign('category_id')->references('id')->on('categories');
$table->timestamps();
});

We just have a name field, and then relationship to the table itself. So most parent category will have category_id = NULL, and every other sub-category will have its own parent_id.

Here's our data in the database:

DB categories


Eloquent Model and Relationships

First, in app/Models/Category.php we add a simple hasMany() method, so category may have other subcategories:

use Illuminate\Database\Eloquent\Relations\HasMany;
 
class Category extends Model
{
// ...
 
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
}

Now comes the biggest "trick" of the article. Did you know that you can describe recursive relationship? Like this:

public function childrenCategories(): HasMany
{
return $this->hasMany(Category::class)->with('categories');
}

So, if you call Category::with('categories'), it will get you one level of "children", but Category::with('childrenCategories') will give you as many levels as it could find.


Route and Controller method

Now, let's try to show all the categories and subcategories, as in the example above.

In routes/web.php, we add this:

use App\Http\Controllers\CategoryController;
 
Route::get('categories', [CategoryController::class, 'index']);

Then, app/Http/CategoryController.php looks like this:

use App\Models\Category;
 
public function index()
{
$categories = Category::whereNull('category_id')
->with('childrenCategories')
->get();
return view('categories', compact('categories'));
}

As you can see, we're loading only parent categories, with children as relationships. Simple, huh?


View and Recursive Sub-View

Finally, to the Views structure. Here's our resources/views/categories.blade.php:

<ul>
@foreach ($categories as $category)
<li>{{ $category->name }}</li>
<ul>
@foreach ($category->childrenCategories as $childCategory)
@include('child_category', ['child_category' => $childCategory])
@endforeach
</ul>
@endforeach
</ul>

As you can see, we load the main categories, and then load children categories with @include.

The best part is that resources/views/admin/child_category.blade.php will use recursive loading of itself. See the code:

<li>{{ $child_category->name }}</li>
@if ($child_category->categories)
<ul>
@foreach ($child_category->categories as $childCategory)
@include('child_category', ['child_category' => $childCategory])
@endforeach
</ul>
@endif

As you can see, inside of child_category.blade.php we have @include('child_category'), so the template is recursively loading children, as long as there are categories inside of the current child category.


Method 2: Recursive Relationships Using Common Table Expressions (CTE)

This method will use a staudenmeir/laravel-adjacency-list package.

composer require staudenmeir/laravel-adjacency-list:"^1.0"

Eloquent Model and Migration

In the Model, we need to add the HasRecursiveRelationships trait.

use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
 
class Category extends Model
{
use HasRecursiveRelationships;
 
// ...
}

The Migration by default package uses the parent_id column.

Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedBigInteger('parent_id')->nullable();
$table->foreign('parent_id')->references('id')->on('categories');
$table->timestamps();
});

But if you have a different column, it can be customized by overriding getParentKeyName():

use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
 
class Category extends Model
{
use HasRecursiveRelationships;
 
public function getParentKeyName(): string
{
return 'category_id';
}
 
// ...
}

Controller

In the Controller, we need to get categories into a tree list.

use App\Models\Category;
 
class CategoryController extends Controller
{
public function index()
{
$categories = Category::tree()->get()->toTree();
 
return view('categories', compact('categories'));
}
}

View and Recursive Sub-View

Finally, we need to show categories. We will re-use the same Blade files categories.blade.php and child_category.blade.php for the Views.

<ul>
@foreach ($categories as $category)
<li>{{ $category->name }}</li>
<ul>
@foreach ($category->children as $childCategory)
@include('child_category', ['child_category' => $childCategory])
@endforeach
</ul>
@endforeach
</ul>

As you can see, first, we load categories as usual, and then we get subcategories using the children relationship method, which comes from the package, and then load children categories with @include.

And inside of child_category.blade.php, we get the children again and use the @include again.

<li>{{ $child_category->name }}</li>
<ul>
@foreach ($child_category->children as $childCategory)
@include('child_category', ['child_category' => $childCategory])
@endforeach
</ul>

The best part of using this method is that it only makes one query to the DB, and you avoid all of the N+1 problems.

cte query


And, that's it! We have unlimited level of subcategories - in database, in Eloquent relationships, and in Views.

avatar

Hello teacher, good afternoon! It's all right?.

I have two tables, one movements and another cart_moviments, in cart_moviments I get more than one record for the same id movement_id, in my model I use hasMany and return normal with the relationships of the controller with with, so in the resource, how am I going to get the individual value for each id?.

I would like to get the value of each


Ex: 

vehicle_cart_id_entrance  => $this->cartMoviment->type['Entrada'],
vehicle_cart_id_exit           => $this->cartMoviment->type['Saida']
 
My code:
 
'vehicle_Cart'                  => $this->cartMoviment ?? 'N/A',
	
//Return Json

data": [
    {
      "id": 123,
      "type": "Entrada",
      "department_id": "60071",
      "person_id": "21",
      "person_name": "I",
      "company_id": 41,
      "company_name": "GOOGLE",
      "vehicle_id": "2",
      "vehicle_board": "OAN8440",
      "vehicle_status": "Ativo",
      "vehicle_manufacturer": "ONIX",
      "vehicle_type": "CAVALO",
      "vehicle_Cart": [
        {
          "id": 114,
          "moviment_id": "123",
          "type": "Entrada",
          "vehicle_cart_id": null,
          "status": "Ativo",
          "user_id": "361910e3-c173-c061-e053-20fb96965d02",
          "created_at": "02-12-2022 14:55:46",
          "updated_at": "02-12-2022 14:55:46"
        },
        {
          "id": 115,
          "moviment_id": "123",
          "type": "Saida",
          "vehicle_cart_id": null,
          "status": "Ativo",
          "user_id": "361910e3-c173-c061-e053-20fb96965d02",
          "created_at": "02-12-2022 14:56:37",
          "updated_at": "02-12-2022 14:56:37"
        }
      ],
Thanks
avatar

Hi, It's not a quick question I could answer in a comment, unfortunately, without actually running your system locally and debugging it.

avatar

Thank you. This is useful. Could you please elaborate, why we need this is migration: " $table->foreign('category_id')->references('id')->on('categories');" as I do not understand why we need this and this: "$table->unsignedBigInteger('category_id')->nullable();"?

avatar

It's actually an older article from 2019, recent syntax of this would be one line of $table->foreignId('category_id')->nullable()->constrained();

avatar
Илья Покровский

Many thanks for the article. I have been looking for such an explanation for a long time. But I want to ask a question. I've always used this way: First, I recursively went through the category IDs and ended up with a parent and several children. After that I made a request: ->whereIn('categories', $categoryIds)

How much worse is my method than the one proposed? Does it take longer to run? Do I get it right?

avatar

How can you use withCount() for that relationship?

avatar

Little bit complex but useful when you working with API.

     $data = Category::all()->toArray();

    // Separate parent and child categories
    $parents = [];
    $children = [];

    foreach ($data as $category) {
        if ($category['parent_id'] === null) {
            $parents[$category['id']] = $category;
        } else {
            $children[$category['id']] = $category;
        }
    }
    // Function to organize children recursively
    function organizeChildren($parentId, &$children)
    {
        $result = [];
        foreach ($children as $child) {
            if ($child['parent_id'] === $parentId) {
                $child['children'] = organizeChildren($child['id'], $children);
                $result[] = $child;
            }
        }
        return $result;
    }

    // Organize child category under their respective parent
    foreach ($parents as &$parent) {
        // dd($parent);
        $parent['children'] = organizeChildren($parent['id'], $children);
    }

    // Extract only the parent categories as a nested array
    return array_values($parents);
avatar

In the line foreach ($parents as &$parent), the & symbol before $parent indicates that we are passing the loop variable by reference rather than by value. This means that any changes made to $parent inside the loop will directly affect the original array element in the $parents array.

Without the &, the loop variable would be passed by value, creating a copy of each element from the $parents array, and modifying $parent inside the loop would not affect the original elements in the $parents array.

By using &$parent, we can modify the elements of $parents directly within the loop, which is necessary in this case because we want to add the children array to each parent module to organize its respective children and their nested children.

avatar
public static function Categories()
{
    return Category::where('status', '=', '1')
			->with('childCategories')->get();
}
public function childCategories()
{
    return $this->hasMany(Category::class, 'parent_id')
			->where('status', '=', '1')->with('childCategories');
}
	

The first level category status check condition is working fine. But Second level category (Recursive) onwards, the status check condition is not working. I have tried with different solution

public static function Categories()
{
    return Category::where('status', '=', '1')
			->with(['childCategories' => function($q) {
						$q->where('status', '=', '1');
			}])->get();
	}

Above this solution also not working

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 57 courses (1055 lessons, total 46 h 42 min)
  • 78 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials