Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

locopetey's avatar

Append new attribute based on relationships

Hi,

For brevity, i have three tables with the following columns:

Categories -id -name -slug

Topics -id -name -slug -category_id

Subtopics -id -name -slug -topic_id

Calling the below get us a nicely nested JSON output..

$body = Category::with(['topic', 'topic.subtopic'])->get();

However, i want to get the absolute URL by appending it to each JSON property into Topics and Subtopics with concatenating all slugs into a new 'URL' attribute.

For example:

-URL attribute for Topics would be /category_slug/topic slug

-URL attribute for Subtopics would be /category_slug/topic slug/subtopic_slug

Thanks!

0 likes
10 replies
MichalOravec's avatar
Level 75

First set your relationships https://laravel.com/docs/8.x/eloquent-relationships#defining-relationships

Topic model

protected $appends = ['url'];

public function getUrlAttribute()
{
    return "/{$this->category->slug}/{$this->slug}";
}

Subtopic model

protected $appends = ['url'];

public function getUrlAttribute()
{
    return "/{$this->topic->category->slug}/{$this->topic->slug}/{$this->slug}";
}

Or you can also use route helper to create an absolute url.

rodrigo.pedra's avatar

You can define accessors in each model, or you can manually assign the URLs if it is needed on a single endpoint.

To manually add the URLs you can try this:

// when fetching nested relations the parent is already eager loaded
$body = Category::with(['topic.subtopic'])
    ->get()
    ->map(function (Category $category) {
        $category->topic->url = \route('topic', [
            'category' => $category, 
            'topic' => $category->topic
        ]);
        $category->topic->append('url');

        $category->topic->subtopic->url = \route('subtopic', [
            'category' => $category,
            'topic' => $category->topic,
            'subtopic' => $category->topic->subtopic,
        ]);
        $category->topic->subtopic->append('url');

        return $category;
    });

And in your ./routes/web.php add these routes:

Route::get('/{category:slug}/{topic:slug}', [TopicController::class, 'index'])
    ->name('topic');
Route::get('/{category:slug}/{topic:slug}/{subtopic:slug}', [SubtopicController::class, 'index'])
    ->name('subtopic');

Using the :slug suffix in each route parameter will tell Laravel to use each model's slug attribute as the route binding, also when resolving the route it will try to scope the child model to its parent model.

So if someone tries to access a manually constructed URL with a Topic that doesn't belong to a Category, or a Subtopic that doesn't belong to a Topic, they will get a 404.

Reference: https://laravel.com/docs/8.x/routing#implicit-model-binding-scoping

EDIT: added missing controller to the routes' definitions. Make sure to use your own controller name, and if using the ::class notation to import them on your routes file.

You can also use string based notation as such:

Route::get('/{category:slug}/{topic:slug}', 'App\Http\Controllers\TopicController@index')
    ->name('topic');

Which can shortened if you have the namespace configured in your RouteServiceProvider

Route::get('/{category:slug}/{topic:slug}', 'TopicController@index')
    ->name('topic');
locopetey's avatar

Amazing @michaloravec !!!!! Works for Topics..Getting this error on Subtopic:

Trying to get property 'slug' of non-object

here is Subtopic

namespace App\Models;

use Illuminate\Database\Eloquent\Model;


class Subtopic extends Model
{
    protected $hidden = array('topic_id', 'id', 'created_at', 'updated_at');

    protected $appends = ['url'];

    public function topic()
    {
        return $this->belongsTo('App\Models\Topic');
    }

    public function getUrlAttribute ()
    {
    	return "/{$this->category->slug}/{$this->topic->slug}/{$this->slug}";
    }

here is Category

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    //use HasFactory;
    protected $hidden = array('id','parent_id','created_at', 'updated_at');

    protected $appends = ['url'];

    public function topic()
    {
        return $this->hasMany('App\Models\Topic');
    }

    public function getUrlAttribute ()
    {
        return "/{$this->slug}";
    }

rodrigo.pedra's avatar

I looked at your table definitions and seems that a category has many topics and a topic has many subtopics, in my first example I assumed it was a belongs to relation due to the singular relation names.

If that is the case, you'll need to iterate over each collection:

$body = Category::with(['topic.subtopic'])
    ->get()
    ->map(function (Category $category) {
        $category->topic->each(function (Topic $topic) use ($category) {
            $topic->url = \route('topic', [
                'category' => $category, 
                'topic' => $topic,
            ]);
            $topic->append('url');

            $topic->subtopic->each(
                function (Subtopic $subtopic) use ($category, $topic) {
                    $subtopic->url = \route('subtopic', [
                        'category' => $category, 
                        'topic' => $topic,
                        'subtopic' => $subtopic,
                    ]);
                    $subtopic->append('url');
                }
            );
        });

        return $category;
    });
MichalOravec's avatar

@locopetey In Subtopic has to be $this->topic->category->slug for slug of category

public function getUrlAttribute()
{
    return "/{$this->topic->category->slug}/{$this->topic->slug}/{$this->slug}";
}

By the way if you have hasMany relationship it's better when you use a plural form.

So topics instead of topic in Category model

public function topics()
{
    return $this->hasMany('App\Models\Topic');
}
locopetey's avatar

works! Thanks a ton..I'll take a look at the plural..breaks when I use. Do i have to change model name?

locopetey's avatar

cool, i also noticed in my output that in addition to the URL attribute created, it's now also including all cat and topic data in the JSON within the subtopic. How can i remove if I don't need all the extra info?

rodrigo.pedra's avatar

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.

Please or to participate in this conversation.