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 id
s 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]>.