MySQL Recursive CTEs for PHP Developers: Querying Hierarchical Data Without the Pain
Recursive CTEs in MySQL let you query trees, org charts, and nested categories in a single query. Here's how to use them effectively with PHP.
Every PHP developer eventually hits the “tree problem.” You have categories with subcategories, employees with managers, comments with replies, or menu items with children — and you need to query the entire hierarchy. The traditional PHP approach is either multiple queries in a loop (slow), loading everything into memory and building the tree in PHP (wasteful), or using the nested set model (complex to maintain). MySQL’s recursive Common Table Expressions solve this cleanly in a single query, and they’ve been available since MySQL 8.0.
If you’ve been avoiding CTEs because the syntax looks intimidating, this guide will walk you through practical patterns you can drop into your PHP applications today.
The Basic Syntax
A recursive CTE has two parts: the anchor member (your starting point) and the recursive member (how to find the next level). They’re connected with UNION ALL:
WITH RECURSIVE tree AS (
-- Anchor: start with root nodes
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
-- Recursive: join children to the current level
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM categories c
INNER JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY depth, name;
The anchor query runs once and returns the root nodes. MySQL then repeatedly executes the recursive part, joining against the results from the previous iteration, until no new rows are produced. The depth column tracks how deep each node is in the hierarchy.
Real-World Pattern: Category Breadcrumbs
One of the most common uses is building breadcrumbs. Given a leaf category, walk up the tree to the root:
WITH RECURSIVE breadcrumb AS (
-- Start from the current category
SELECT id, name, parent_id, 1 AS level
FROM categories
WHERE id = :category_id
UNION ALL
-- Walk up to each parent
SELECT c.id, c.name, c.parent_id, b.level + 1
FROM categories c
INNER JOIN breadcrumb b ON c.id = b.parent_id
)
SELECT * FROM breadcrumb ORDER BY level DESC;
This walks from the leaf node upward. The ORDER BY level DESC gives you root-first ordering, perfect for rendering Home > Electronics > Phones > Smartphones.
In Laravel, you can run this with a raw query or use DB::select():
$breadcrumbs = DB::select("
WITH RECURSIVE breadcrumb AS (
SELECT id, name, parent_id, 1 AS level
FROM categories
WHERE id = ?
UNION ALL
SELECT c.id, c.name, c.parent_id, b.level + 1
FROM categories c
INNER JOIN breadcrumb b ON c.id = b.parent_id
)
SELECT * FROM breadcrumb ORDER BY level DESC
", [$categoryId]);
Each row in $breadcrumbs is a stdClass object with id, name, parent_id, and level properties. You can render them directly in your Blade template:
<nav class="breadcrumbs">
@foreach ($breadcrumbs as $crumb)
@if (!$loop->last)
<a href="{{ route('category.show', $crumb->id) }}">{{ $crumb->name }}</a>
<span>/</span>
@else
<span class="current">{{ $crumb->name }}</span>
@endif
@endforeach
</nav>
Pattern: Full Subtree With Path
Sometimes you need to fetch an entire subtree and know the full path to each node. This is useful for sitemaps, admin panels, and export features:
WITH RECURSIVE subtree AS (
SELECT id, name, parent_id, 0 AS depth,
CAST(name AS CHAR(1000)) AS path
FROM categories
WHERE id = :root_id
UNION ALL
SELECT c.id, c.name, c.parent_id, s.depth + 1,
CONCAT(s.path, ' > ', c.name)
FROM categories c
INNER JOIN subtree s ON c.parent_id = s.id
)
SELECT * FROM subtree ORDER BY path;
The path column accumulates the full breadcrumb trail for every node. The CAST in the anchor is important — without it, MySQL may truncate the concatenated path in deeper hierarchies. Adjust the length as needed for your data.
Pattern: Organizational Chart Queries
Recursive CTEs shine for org chart queries. Find all employees who report to a specific manager, directly or indirectly:
function getAllReports(int $managerId): Collection
{
$reports = DB::select("
WITH RECURSIVE reports AS (
SELECT id, name, manager_id, title, 1 AS level
FROM employees
WHERE manager_id = ?
UNION ALL
SELECT e.id, e.name, e.manager_id, e.title, r.level + 1
FROM employees e
INNER JOIN reports r ON e.manager_id = r.id
)
SELECT * FROM reports ORDER BY level, name
", [$managerId]);
return collect($reports);
}
You can count total reports per manager, calculate depth of the reporting chain, or filter to a specific level — all within the same CTE by adding conditions to the outer SELECT.
Pattern: Threaded Comments
For comment systems with nested replies, a recursive CTE lets you fetch an entire comment thread in the correct display order:
WITH RECURSIVE thread AS (
-- Top-level comments on a post
SELECT id, body, user_id, parent_id, created_at,
0 AS depth,
CAST(LPAD(id, 10, '0') AS CHAR(1000)) AS sort_path
FROM comments
WHERE post_id = :post_id AND parent_id IS NULL
UNION ALL
-- Replies
SELECT c.id, c.body, c.user_id, c.parent_id, c.created_at,
t.depth + 1,
CONCAT(t.sort_path, '.', LPAD(c.id, 10, '0'))
FROM comments c
INNER JOIN thread t ON c.parent_id = t.id
)
SELECT * FROM thread ORDER BY sort_path;
The sort_path trick builds a string like 0000000001.0000000005.0000000012 that sorts comments in threaded order — parent first, then its children, then their children. The depth column tells you how much to indent each comment in your UI.
Performance Considerations
Recursive CTEs are not a silver bullet. Here are the things to watch:
Index the parent column. The recursive join (c.parent_id = t.id) runs once per level of depth. Without an index on parent_id, each iteration is a full table scan. This is the single most important optimization:
ALTER TABLE categories ADD INDEX idx_parent_id (parent_id);
Set a depth limit. If your data could have cycles (even accidentally), add a depth guard to prevent infinite recursion:
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM categories c
INNER JOIN tree t ON c.parent_id = t.id
WHERE t.depth < 50 -- Safety limit
)
SELECT * FROM tree;
MySQL also has a system variable cte_max_recursion_depth (default 1000) that acts as a global safety net.
Know when to denormalize. If you’re querying the same hierarchy thousands of times per second and the tree rarely changes, consider caching the materialized path or using a closure table alongside the adjacency list. Recursive CTEs are perfect for moderate query volumes and trees that change frequently — when the simplicity of the adjacency list model matters more than raw read speed.
Wrapping It Into a Laravel Scope
If you use this pattern often, wrap it in a query scope or a trait on your Eloquent model:
// app/Models/Category.php
public function scopeWithAncestors(Builder $query, int $categoryId): Builder
{
return $query->fromSub(function ($sub) use ($categoryId) {
$sub->selectRaw("
WITH RECURSIVE ancestors AS (
SELECT * FROM categories WHERE id = ?
UNION ALL
SELECT c.* FROM categories c
INNER JOIN ancestors a ON c.id = a.parent_id
)
SELECT * FROM ancestors
", [$categoryId]);
}, 'categories');
}
Or for maximum clarity, just use DB::select() in a dedicated repository method. Not everything needs to go through Eloquent’s query builder, and raw SQL is perfectly fine when it makes the intent clearer.
Why This Matters Now
The adjacency list model (parent_id on each row) is the most intuitive way to model hierarchical data, and it’s what most developers reach for first. The historical problem was that SQL couldn’t query it efficiently — which is why alternatives like nested sets and materialized paths became popular despite their complexity. Recursive CTEs remove that limitation entirely. You get the simple data model and the powerful queries.
If you’re running MySQL 8.0 or later — and by now, you almost certainly are — there’s no reason to avoid recursive CTEs. They’re standard SQL, they’re well-optimized in MySQL’s query engine, and they turn multi-query tree traversals into single, readable statements.
Sources:
- MySQL 8.0 Reference Manual — WITH (Common Table Expressions) — Oracle
- Recursive CTE in MySQL — MySQL Tutorial
- Laravel Database: Query Builder — Laravel Documentation