That is because in your accessor you are lazy loading each subtopic's topic, and each topic's category.
When you eager load in your query:
Category::with(['topic.subtopic'])
Or, if you updated your relations names:
Category::with(['topics.subtopics'])
The eager loading doesn't set the parent relation for each child model, this is by design and have been discussed/requested several times on Laravel's issue tracker on GitHub.
So when calling the parent models in each child accessors you are running an extra query for each parent model. If you have Laravel Debugbar or Laravel Telescope installed you can check for yourself.
One easy solution is to add the parent relations to the hidden attribute of each model.
For example:
class Subtopic extends Model {
protected $hidden = ['topic_id', 'id', 'created_at', 'updated_at', 'topic'];
// ....
}
This still won't prevent the lazy loading, but will hide the related model from the JSON output.
You can also loop through the results and call ->makeHidden(...) for each topic and subtopic in a similar manner as I suggested if you only want to temporarily hide the related model. (In section #2 below I give an usage example )
To prevent lazy loading the parent model, in case you want to keep the URL accessor, you can use one of these options:
1. Eager load each parent relation in the query
$body = Category::with([
'topic' => function ($relation) {
$relation->with([
'category', // parent relation
'subtopic' => function ($relation) {
$relation->with(['topic']);
}
]);
}
])
->get();
The downside of this approach is that you are creating duplicate objects in memory for each parent model. This will still run several queries, but lot less then one extra query for each topic (to get the topic's category) and two extra queries for each subtopic (to get the subtopic's topic, and this new topic instance's category).
Note: For this option you still need to add the related model to the hidden array.
2. Loop over the retrieved results and set the parent
$body = Category::with(['topic.subtopic'])
->get()
->map(function (Category $category) {
$category->topic->each(function (Topic $topic) use ($category) {
// this will prevent lazy loading setting the parent model
// with the already created instance
$topic->setRelation('category', $category);
// this will hide the related model from output
$topic->makeHidden('category');
$topic->subtopic->each(function (Subtopic $subtopic) use ($topic) {
$subtopic->setRelation('topic', $topic);
$subtopic->makeHidden('topic');
});
});
return $category;
});
Downside is the boilerplate, but in my opinion is the most memory/query efficient.
3. Add a $with property to each child model to always eager load the parent model
For example:
class Subtopic extends Model
{
protected $with = ['topic'];
// ...
}
Reference: https://laravel.com/docs/8.x/eloquent-relationships#eager-loading
Search for sub-section Eager Loading By Default
Downside of this approach is that the parent model will always be eager loaded even when you don't need it, for example in a view that you are editing a topic and don't need its category data.
Note: For this option you still need to add the related model to the hidden array.
===
Additional note: I am not advocating against adding the accessor to each model, it is very handy and very useful approach.
But unfortunately, relying on related models to build up a model's dynamic attribute brings the need to circumvent the associated penalties when dealing with eager/lazy loading and JSON output.