Back to Course |
Structuring Databases in Laravel 11

Unlimited Levels of Parent-Children

In this lesson, let's examine the hierarchy structure for eShop categories/subcategories. How can you structure that in the database with unlimited subcategories without losing too much performance? I will show you three ways, including using an external package.


The Task

I've seeded a small database of categories. The parent category, with category_id NULL, has subcategories for toys, clothes, Lego, etc.

Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->foreignId('category_id')->nullable()->constrained('categories');
$table->timestamps();
});

Our task is to show the entire tree of categories with subcategories.


Without Package

So, on the Category Model, there is a hasMany relationship to subcategories.

app/Models/Category.php:

use Illuminate\Database\Eloquent\Relations\HasMany;
 
class Category extends Model
{
protected $fillable = [
'name',
'category_id',
];
 
public function subcategories(): HasMany
{
return $this->hasMany(Category::class);
}
}

In the Controller, we get categories with the whereNull('category_id') condition, which gets the root categories and then eager loads the subcategories.

app/Http/Controllers/CategoryController.php:

use App\Models\Category;
use Illuminate\Contracts\View\View;
 
class CategoryController extends Controller
{
public function __invoke(): View
{
$categories = Category::with('subcategories.subcategories')
->whereNull('category_id')
->get();
 
return view('categories.index', compact('categories'));
}
}

Then, in the View, we make a typical foreach loop.

resources/views/categories/index.blade.php:

<ul>
@foreach($categories as $category)
<li>{{ $category->name }}</li>
@if($category->subcategories->count())
<ul>
@foreach($category->subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@if($subcategory->subcategories->count())
<ul>
@foreach($subcategory->subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@endforeach
</ul>
@endif
@endforeach
</ul>
@endif
@endforeach
</ul>

This gives us two-level deep categories.


Unlimited Levels?

How do we transform that into an unlimited level if you want to show level four, which exists in the database?

Do we add another level when eager loading and another level in the View? What if we want to have level five or six in the future?

Let's make it more flexible. First, the view has repeating parts. Everything from the, if subcategories count can be extracted into a separate View file and included instead.

resources/views/categories/index.blade.php:

<ul>
@foreach($categories as $category)
<li>{{ $category->name }}</li>
@if($category->subcategories->count())
<ul>
@foreach($category->subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@if($subcategory->subcategories->count())
<ul>
@foreach($subcategory->subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@endforeach
</ul>
@endif
@endforeach
</ul>
@endif
@includeWhen($category->subcategories->count(), 'categories.subcategory', ['subcategories' => $category->subcategories])
@endforeach
</ul>

Then, in the categories.subcategory, we don't need to make an if-check because the View will be included only when there are subcategories.

In the categories.subcategory View, we only show the subcategory name and again recursively include the View.

resources/views/categories/subcategory.blade.php:

<ul>
@foreach($subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@includeWhen($subcategory->subcategories->count(), 'categories.subcategory', ['subcategories' => $subcategory->subcategories])
@endforeach
</ul>

Now, we have all categories shown in the browser.

But now, we need help with the queries because we have loaded only two levels.

Let's eagerly load only one subcategory relationship in the Controller.

class CategoryController extends Controller
{
public function __invoke(): View
{
$categories = Category::with('subcategories.subcategories')
$categories = Category::with('subcategories')
->whereNull('category_id')
->get();
 
return view('categories.index', compact('categories'));
}
}

Instead, let's add the eager load to the relationship itself in the Model.

app/Models/Category.php:

class Category extends Model
{
protected $fillable = [
'name',
'category_id',
];
 
public function subcategories(): HasMany
{
return $this->hasMany(Category::class);
return $this->hasMany(Category::class)->with('subcategories');
}
}

Instead of 21 queries, we have only nine.

So, we're using eager loading for an unlimited amount of subcategories.


With Package

Now, we will see how to get the same unlimited categories using the Laravel Adjacency List package.

First, we must install the package and prepare a Model by adding the HasRecursiveRelationships trait.

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

app/Models/Category.php:

use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
 
class Category extends Model
{
use HasRecursiveRelationships;
 
protected $fillable = [
'name',
'category_id',
];
}

By default, the package expects a parent column named parent_id. This column can be overwritten using the getParentKeyName() method.

app/Models/Category.php:

class Category extends Model
{
use HasRecursiveRelationships;
 
protected $fillable = [
'name',
'category_id',
];
 
public function getParentKeyName(): string
{
return 'category_id';
}
}

Next, we can get the categories in the Controller and make them into a tree.

app/Http/Controllers/CategoryController.php:

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

The result is a Collection, and each subcategory is called children.

Staudenmeir\LaravelAdjacencyList\Eloquent\Collection {#352 ▼
#items: array:1 [▼
0 => App\Models\Category {#1359 ▼
#connection: "sqlite"
#table: "categories"
#primaryKey: "id"
#keyType: "int"
+incrementing: true
#with: []
#withCount: []
+preventsLazyLoading: false
#perPage: 15
+exists: true
+wasRecentlyCreated: false
#escapeWhenCastingToString: false
#attributes: array:7 [▶]
#original: array:7 [▶]
#changes: []
#casts: []
#classCastCache: []
#attributeCastCache: []
#dateFormat: null
#appends: []
#dispatchesEvents: []
#observables: []
#relations: array:1 [▼
"children" => Staudenmeir\LaravelAdjacencyList\Eloquent\Collection {#1323 ▼
#items: array:2 [▼
0 => App\Models\Category {#1357 ▶}
1 => App\Models\Category {#1356 ▶}
]
#escapeWhenCastingToString: false
}
]
#touches: []
+timestamps: true
+usesUniqueIds: false
#hidden: []
#visible: []
#fillable: array:2 [▶]
#guarded: array:1 [▶]
}
]
#escapeWhenCastingToString: false
}

We can make the same foreach loop in the View and include another View if a category has children.

resources/views/categories/index.blade.php:

<ul>
@foreach($categories as $category)
<li>{{ $category->name }}</li>
@includeWhen($category->hasChildren(), 'categories.subcategory', ['subcategories' => $category->children])
@endforeach
</ul>

resources/views/categories/subcategory.blade.php:

<ul>
@foreach($subcategories as $subcategory)
<li>{{ $subcategory->name }}</li>
@includeWhen($subcategory->hasChildren(), 'categories.subcategory', ['subcategories' => $subcategory->children])
@endforeach
</ul>

And we have the same result in the browser.

With the previous method, we eagerly loaded each subcategory, which results in many queries if you have a lot of subcategories. This package uses Common Table Expressions (CTE), which makes only one query to get root and child records.

The package provides various relationships and other useful methods.