- Home
- Blog
- Information
- Technology
- Laravel
- Building Hierarchical Blog Categories in Laravel
Building Hierarchical Blog Categories in Laravel
How this blog models a three-level category tree with an adjacency list, cached depth/path columns and a single scope that returns every post under a branch.
Most blogs get away with flat categories. Mine needed Travelling / Europe / Sweden, and the moment you allow nesting you inherit a set of small problems: breadcrumbs, cycles, depth limits and the question of which posts belong to a mid-level node.
Modelling the tree
A self-referencing parent_id is enough for storage. The staudenmeir/laravel-adjacency-list package adds recursive relations such as descendants() and ancestorsAndSelf() on top of it using common table expressions, which work on MySQL 8, PostgreSQL and SQLite alike.
To keep the hot paths cheap, every category also caches its depth and a materialized path such as /1/4/9/. An observer keeps both columns in sync whenever a node moves.
Key points
- Store
parent_idonly; derive everything else. - Cache
depthandpathfor cheap breadcrumbs and subtree queries. - Enforce cycle and depth rules in one observer.
- Bust the cached tree from the observer, not from controllers.
Fetching posts for a branch
The public category page must include posts from every descendant. With the cached path this is a single LIKE query for the ids followed by the usual published() scope, so the listing never touches a recursive query.
Example in code
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
class Category extends Model
{
/** Published posts in this category and every descendant. */
public function allPosts(): Builder
{
return Post::query()
->published()
->whereIn('category_id', $this->descendantsAndSelfIds());
}
/** @return list<int> */
public function descendantsAndSelfIds(): array
{
return static::query()
->where('path', 'like', $this->path.'%')
->pluck('id')
->all();
}
}
Guarding the invariants
A category must never become its own ancestor, and the tree should not grow deeper than the configured limit. Both rules live in the observer so the admin panel, the seeders and the tests all share the same behaviour.
The sidebar tree looked simple until the fourth level of categories showed up.
Takeaways
Nesting is cheap to store and expensive to get wrong. Cache the derived columns, keep the rules in one place and let the tests prove the breadcrumb still reads Travelling / Europe / Sweden.