Modeling recursive relationships in Laravel Nova

I'm thinking of starting kind of a series of posts where I share tips for using Laravel Nova when building real world apps.

Today's problem is the following, we have a list of tags each one can have many subTags and can also have many superTags. So how do we model this kind of many-to-many recursive relationship?

Let's start at the base of this, the database.

We need two tables, one for the tags and one for pivoting records.

The first one is a simple id and name table,

Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->timestamps();
});

the second is just two ids foreign keyed to the first table, basic stuff.

Schema::create('tag_related_tag', function (Blueprint $table) {
$table->unsignedInteger('tag_id');
$table->unsignedInteger('sub_tag_id');
 
$table->foreign('tag_id')
->references('id')->on('tags')
->onUpdate('cascade')
->onDelete('cascade');
 
$table->foreign('sub_tag_id')
->references('id')->on('tags')
->onUpdate('cascade')
->onDelete('cascade');
 
$table->primary(['tag_id', 'sub_tag_id']);
});

Next, let's set up our model, we need two relations on the Tag model, one for the superTags and one for the subTags, now because we're doing something out of convention, we need to tell Laravel where to find what it's looking for.

Let's start with the superTags first.

The params for the belongsToMany is as follows: * The FQCN of the related class: self::class in our case which would translate to App\Tag. * The pivot table name: tag_related_tag in our case. * Foreign pivot key: this would be the id of the instance we have, and since we're querying for the superTags, this needs to be the sub_tag_id column. * Related pivot key: this would be the id of the related model, so this needs to be the tag_id column.

public function superTags()
{
return $this->belongsToMany(
self::class,
'tag_related_tag',
'sub_tag_id',
'tag_id'
);
}

likewise, the relation for the subTags would look like this

public function subTags()
{
return $this->belongsToMany(
self::class,
'tag_related_tag',
'tag_id',
'sub_tag_id'
);
}

Great, now let's dive into Nova.

To model the relations, it helps to know that the make method on the BelongsToMany field type accepts three parameters:

  • Display Name: this is what's displayed in the UI.
  • Actual Name: this is the actual name of the relation on the model.
  • Nova Resource FQCN.

Hence, it'll look like this

BelongsToMany::make('Super Tags', 'superTags', 'App\Nova\Tag'),
BelongsToMany::make('Sub Tags', 'subTags', 'App\Nova\Tag'),

If we try this in the browser it'll work pretty great actually!

... until we try to attach the same model twice or attach the model to its self.

Usually, Nova won't let us attach the same model twice, but for whatever reason (If you know what's going on, please tell me), it just won't do that. So what do we do? we hack it xD

There's a method on the base resource class that we can override to customize the query when other resources wanna relate to it. let's tackle the relate-to-self issue first.

public static function relatableQuery(NovaRequest $request, $query)
if ($request->resource() == 'App\Nova\Tag') {
$tag = $request->findResourceOrFail();
 
return $query->where('id', '!=', $tag->id);
}
 
return parent::relatableQuery($request, $query);
}

Looking nice, now how do we find if the request is coming from the SubTags or the SuperTags page?

For the duplicate attachments issue, we need to know which 'resource' is calling us, if it's the SuperTags we need to query the superTags related to our model and then exclude them from our returned result, and vice-versa.

Unfortunately, Nova doesn't pass anything obvious that we can use directly in our override to tell which is which. The best I could come up with is to use the HTTP_REFERRER header, this will include a helpful piece of data, the viaRelation in the query string. so this will leave us with.

// ...
 
if ($this->requestComingFrom($request, 'subTags')) {
$query->whereNotIn('id', $tag->subTags()->pluck('id'));
}
 
if ($this->requestComingFrom($request, 'superTags')) {
$query->whereNotIn('id', $tag->superTags()->pluck('id'));
}
 
// ...
 
public function requestComingFrom($request, $relation)
{
return str_contains(
$request->server->get('HTTP_REFERER'), ["viaRelationship={$relation}"]
);
}

and the whole override would look like this,

public static function relatableQuery(NovaRequest $request, $query)
{
if ($request->resource() == 'App\Nova\Category') {
$category = $request->findResourceOrFail();
 
if ($this->requestComingFrom($request, 'subTags')) {
$query->whereNotIn('id', $tag->subTags()->pluck('id'));
}
 
if ($this->requestComingFrom($request, 'superTags')) {
$query->whereNotIn('id', $tag->superTags()->pluck('id'));
}
 
return $query->where('id', '!=', $category->id);
}
 
return parent::relatableQuery($request, $query);
}
 
public function requestComingFrom($request, $relation)
{
return str_contains(
$request->server->get('HTTP_REFERER'), ["viaRelationship={$relation}"]
);
}

Looking hacky nice -thumbs up emoji here-!

This is it for today, I'd love to hear what you have to say on this, leave me a message at <[email protected]>.