Laravel belongsToMany on same table: how do I create a symmetrical relation? - laravel

I am building an admin panel using Laravel nova. I have an Items table, that has a belongsToMany to itself, using (of course) a pivot table.
This in itself is working, but when I specify that item A is related to item B, the inverse of that relation is not stored, so that results in not being found on item B.
I need it to, because the relationship is symmetrical, meaning that if A relates to B, B also relates to A.
What would be the best way to store and retrieve the relation?
This is my current code that defines the relation, and that works one way.
class Item extends Model
{
public function related(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'item_related', 'item_id', 'related_id');
}
}
So: imagine I have a table with the items A, B and C, and the relations are defined as such in the pivot table
item_id - related_id
A - B
A - C
B - C
So basically, when I do B->related, I want it to return [A, C]. Now it will only return C, and C does not have any related items at all
There are two possible solutions here, one being to insert the inverse relation as well, resulting in
item_id - related_id
A - B
B - A
A - C
C - A
B - C
C - B
This will yield the correct results, but how to automatically force laravel to save the inverted relation as well, and making sure that deletions etc are correctly done?
The other option would be is 'merging' two related belongsToMany calls.
class Item extends Model
{
public function related(): BelongsToMany
{
// return merged $this->relatedFrom and $this->relatedTo
}
public function relatedFrom(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'item_related', 'related_id', 'item_id');
}
public function relatedTo(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'item_related', 'item_id', 'related_id');
}
}
This would also cause all kind of problems.
So what would be a good approach here?

I opted for the method of adding the reverse relation to the pivot table, using an observer and a Pivot model
Normally, a model is not made when having a belongsToMany relation. In this case I needed one, so I manually created one.
The Pivot model, note it extending Pivot instead of Model, the observer being added in the boot method, and the $timestamps which I set to false:
namespace App\Models;
use App\Observers\RelationObserver;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class ItemRelated extends Pivot
{
/**
* The table associated with the model.
*
* #var string
*/
protected $table = 'item_related';
public $timestamps = false;
public static function boot(): void
{
parent::boot();
parent::observe(new RelationObserver);
}
/**
* The attributes that are mass assignable.
*
* #var array
*/
protected $fillable = [
'item_id',
'related_id',
];
/**
* The attributes that should be cast to native types.
*
* #var array
*/
protected $casts = [
'item_id' => 'integer',
'related_id' => 'integer',
];
}
The observer only needs a created and deleted method, inserting and deleting the reverse relation after the other is inserted or deleted respectively:
namespace App\Observers;
use App\Models\ItemRelated;
class RelationObserver
{
public function created(ItemRelated $itemRelated): void
{
if (ItemRelated::where([
['item_id', '=', $itemRelated->related_id],
['related_id', '=', $itemRelated->item_id],
])->doesntExist()) {
$itemRelatedReverse = new ItemRelated();
$itemRelatedReverse->item_id = $itemRelated->related_id;
$itemRelatedReverse->related_id = $itemRelated->item_id;
$itemRelatedReverse->save();
}
}
public function deleted(ItemRelated $itemRelated): void
{
ItemRelated::where([
['item_id', '=', $itemRelated->related_id],
['related_id', '=', $itemRelated->item_id],
])->delete();
}
}
At last I need to tell the relation on the Item table to use the pivot table, by chaining the using method:
public function related(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'item_related', 'item_id', 'related_id')
->using(ItemRelated::class);
}

Related

Laravel - one-to-one relation through pivot table with eager load

I have this relationship
A Movement can have multiples steps
A Step can belongs to multiples Movements
So a had to create a pivot table and a belongsToMany relationship, but my pivot table have some extras columns, like finished and order
I want to have two relationships, one to get all steps from a movement and another one to get the current step from the movement (the last finished step)
I know how to get all steps
public function steps()
{
return $this->belongsToMany(MovementStep::class, 'movement_movement_steps')
->withPivot('order', 'finished')
->orderBy('pivot_order');
}
But how about the current step? I need this kind of relationship, but returning only one record and be able to eager load it cause I'm passing it to vue.js
public function current_step()
{
return $this->belongsToMany(MovementStep::class, 'movement_movement_steps')
->withPivot('order', 'finished')
->where('finished', true)
->orderBy('pivot_order', 'desc');
}
Notice, I'd like to do that without extras packages
alternative solution, but with extra package: Laravel hasOne through a pivot table (not the answer marked as correct, the answer from #cbaconnier)
A different approach from the answer provided by #mrhn is to create a custom relationship. Brent from Spatie did an excellent article about it
Although my answer will do the exact same queries than the one provided by staudenmeir's package it makes me realized that either you use the package, this answer or #mrhn answer, you may avoid the n+1 queries but you may still ends up will a large amount of hydrated models.
In this scenario, I don't think it's possible to avoid one or the other approach. The cache could be an answer though.
Since I'm not entirely sure about your schema, I will provide my solution using the users-photos example from my previous answer.
User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
public function photos()
{
return $this->belongsToMany(Photo::class);
}
public function latestPhoto()
{
return new \App\Relations\LatestPhotoRelation($this);
}
}
LastestPhotoRelation.php
<?php
namespace App\Relations;
use App\Models\User;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
class LatestPhotoRelation extends Relation
{
/** #var Photo|Builder */
protected $query;
/** #var User */
protected $user;
public function __construct(User $user)
{
parent::__construct(Photo::query(), $user);
}
/**
* #inheritDoc
*/
public function addConstraints()
{
$this->query
->join(
'user_photo',
'user_photo.photo_id',
'=',
'photos.id'
)->latest();
// if you have an ambiguous column name error you can use
// `->latest('movement_movement_steps.created_at');`
}
/**
* #inheritDoc
*/
public function addEagerConstraints(array $users)
{
$this->query
->whereIn(
'user_photo.user_id',
collect($users)->pluck('id')
);
}
/**
* #inheritDoc
*/
public function initRelation(array $users, $relation)
{
foreach ($users as $user) {
$user->setRelation(
$relation,
null
);
}
return $users;
}
/**
* #inheritDoc
*/
public function match(array $users, Collection $photos, $relation)
{
if ($photos->isEmpty()) {
return $users;
}
foreach ($users as $user) {
$user->setRelation(
$relation,
$photos->filter(function (Photo $photo) use ($user) {
return $photo->user_id === $user->id; // `user_id` came with the `join` on `user_photo`
})->first() // Photos are already DESC ordered from the query
);
}
return $users;
}
/**
* #inheritDoc
*/
public function getResults()
{
return $this->query->get();
}
}
Usage
$users = \App\Models\User::with('latestPhoto')->limit(5)->get();
The main difference from Brent's article, is that instead of using a Collection we are returning the latest Photo Model.
Laravel has a way to create getters and setters that act similar to columns in the database. These can perfectly solve your problem and you can append them to your serialization.
So instead your current_step is gonna be an accessor (getter). The syntax is getCurrentStepAttribute() for the function which will make it accessible on the current_step property. To avoid N + 1, eager load the steps when you retrieve the model(s) with the with('steps') method. Which is better than running it as a query, as it will execute N times always.
public function getCurrentStepAttribute() {
return $this->steps
->where('finished', true)
->sortByDesc('pivot_order')
->first();
}
Now you can use the append property on the Movement.php class, to include your Eloquent accessor.
protected $appends = ['current_step'];

Laravel Eloquent How Can I Select Using Condition "where" for pivot table

I have three database tables called user(id,name), group(id,name) and user_group(user_id, group_id,valid_before) with relations many to many.
class User extends Model
{
protected $table = 'user';
public function groups()
{
return $this->belongsToMany(Group::class, 'user_group')
->withPivot('valid_before');
}
}
class Group extends Model
{
protected $table = 'group';
public $timestamps = false;
public function user()
{
return $this->belongsToMany(User::class, 'user_group');
}
}
How can I select all users (using Eloquent) who have
valid_before < $some_date
?
There are many ways to achieve this goal. I'll show you an example using query scopes.
In your User class you have to make a little update:
class User extends Model
{
protected $table = 'user';
public function groups()
{
return $this->belongsToMany(Group::class, 'user_group')
//->withPivot('valid_before'); <-- Remove this
}
}
and create a scope in your Group model:
class Group extends Model
{
protected $table = 'group';
public $timestamps = false;
public function user()
{
return $this->belongsToMany(User::class, 'user_group');
}
/**
* This scope gets as input the date you want to query and returns the users collection
*
* #param \Illuminate\Database\Eloquent\Builder $query
* #param string $date
* #return \Illuminate\Database\Eloquent\Builder
*/
public function scopeUsersValidBefore($query, $date)
{
return $query->users()->wherePivot('valid_before', '<', $date);
}
}
Now, I imagine you have a GroupController that somewhere creates a query to retrieve the valid before users. Something like:
// [...]
$users = Group::usersValidBefore($yourDate)->get();
// [...]
If you want to create the query from the other side, I mean you want to use the User model and list all the Users that has a pivot relation with valid_before populated, than the right approach is creating a UserGroup intermediate model that can be easily used to create a query.
If you are using Laravel 8.x.x
It's much easier with Inline Relationship Existence Queries
If you would like to query for a relationship's existence with a single, simple where condition attached to the relationship query, you may find it more convenient to use the whereRelation and whereMorphRelation methods. For example, we may query for all posts that have unapproved comments:
use App\Models\Post;
$posts = Post::whereRelation('comments', 'is_approved', false)->get();
Of course, like calls to the query builder's where method, you may also specify an operator:
$posts = Post::whereRelation(
'comments', 'created_at', '>=', now()->subHour()
)->get();

Using toHasOne macro with MorpMany relationship

I have multiple Model classes that utilize a HasRetirements trait class. Both models use a MorphMany relationship to target the associated retirements table model for each model. Inside the HasRetirements trait class, I also have a isRetired() method as well as a currentRetirement() method. These methods are shown below.
I have come across a macro that can be chained onto an Eloquent relationship so that you can retrieve a single record. The macro toHasOne() utilizes model relationships through a hasMany relationship however my question is could this also be used for a morphMany relationship since it's polymorphic.
https://scotch.io/tutorials/understanding-and-using-laravel-eloquent-macros
public function currentRetirement()
{
return $this->retirements()->whereNull('ended_at')->latest()->toHasOne();
}
public function isRetired()
{
return $this->retirements()->whereNull('ended_at')->exists();
}
With Laravel 5.5, you could register a macro returning a derived class from the BelongsToMany relation. This derived class also could be an anonymous class if you are not planning on using it anywhere else. Within the derived class, you need to override the match method and return the single object as a relation or null otherwise
BelongsToMany::macro('asSingleEntity', function() {
return new class(
$this->related->newQuery(),
$this->parent,
$this->table,
$this->foreignPivotKey,
$this->relatedPivotKey,
$this->parentKey,
$this->relatedKey,
$this->relationName) extends BelongsToMany {
/**
* Match the eagerly loaded results to their parents.
*
* #param array $models
* #param \Illuminate\Database\Eloquent\Collection $results
* #param string $relation
* #return array
*/
public function match(array $models, Collection $results, $relation)
{
$dictionary = $this->buildDictionary($results);
// Once we have an array dictionary of child objects we can easily match the
// children back to their parent using the dictionary and the keys on the
// the parent models. Then we will return the hydrated models back out.
foreach ($models as $model) {
if (isset($dictionary[$key = $model->{$this->parentKey}])) {
$model->setRelation(
// $relation, $this->related->newCollection($dictionary[$key]) // original code
$relation, array_first($dictionary[$key])
);
} else {
$model->setRelation($relation, null);
}
}
return $models;
}
};
});
Then, you could simply use it within the model.
return $this
->belongsToMany(\App\Models\Entity::class, 'pivot_table_name')
->asSingleEntity();

Laravel Eloquent "siblings" as a relationship?

class PageRelation extends Eloquent
{
public $incrementing = false;
public $timestamps = false;
protected $table = 'page_relation';
protected $casts = [
'parent' => 'int', // FK to page
'child' => 'int', // FK to page
'lpc' => 'int',
];
protected $fillable = [
'lpc',
];
public function children()
{
return $this->hasMany(Page::class, 'category_id', 'child');
}
public function parents()
{
return $this->hasMany(Page::class, 'category_id', 'parent');
}
public function siblings()
{
// ... return $this->hasMany(Page::class ...
// how do I define this relationship?
}
}
In my design a sibling is (as you might expect) a record that shares the same parent but not itself (exclude current child). How can I achieve this?
This is not a duplicate of Laravel Eloquent Relationships for Siblings because 1) the structure is different, 2) I would like to return a relationship, not a query result, I know how to query this, but I want the power of eager loader.
I don't think you can do that with Laravel's in-built relations. What I would suggest doing is creating your own relation type that extends HasMany and use that.
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class HasManySiblings extends HasMany
{
public function addConstraints()
{
if (static::$constraints) {
if (is_null($foreignKeyValue = $this->getParentKey())) {
$this->query->whereNull($this->foreignKey);
} else {
$this->query->where($this->foreignKey, '=', $foreignKeyValue);
$this->query->whereNotNull($this->foreignKey);
}
$this->query->where($this->localKey, '!=', $this->parent->getAttribute($this->localKey));
}
}
public function getParentKey()
{
return $this->parent->getAttribute($this->foreignKey);
}
}
By extending the HasMany class and providing your own implementation of addConstraints you are able to control what gets added to the query for related models. Usually, what Laravel would do here is add where parent_id = <your model ID> but I've changed it here to add where parent_id = <your model PARENT ID> (if your model's parent_id is null it will instead add where parent_id is null). I've also added an extra clause to ensure that the calling model is not included in the resulting collection: and id != <your model ID>.
You can use it like this in your Page model:
class Page extends Model
{
public function siblings()
{
return new HasManySiblings(
$this->newRelatedInstance(Page::class)->newQuery(), $this, 'parent_id', 'id'
);
}
}
Now you should be able to load the siblings like this:
$page = Page::find(1);
dd($page->siblings);
Please note though, I have only tested this for retrieving related models and it may not work when using the relation for other purposes such as saving related models etc.
Also, please note that in my examples above I've used parent_id instead of parent as in your question. Should be straight swap though.
I am not sure if it works with your model which is kinda marginal because you are relating same objects with a middle table. But,
hasManyThrough()
could be a solution for this.
"... has many siblings through parent."
https://laravel.com/docs/5.6/eloquent-relationships#has-many-through
This is off-topic but bare me with this. I have this suggestion for the way you are handling these relations. You don't need PageRelation model, you can define belongsToMany relation on Page model directly. Moreover, you dont need extra attribute parent, this is kind of inconsistent, defining parent and child both, only children are enough to determine parents. So Instead of two seperate columns, You can reverse the keys when you retrieve the relation. Let me show you with an example what I mean:
pages:
keep this table intact
pages_relation:
- id
- page_id (foreign key to id on page)
- child_id (foreign key to id on page)
And then define two relations in your model:
class Page extends Model
{
public function children()
{
return $this->belongsToMany('App\Page', 'pages_relation', 'page_id', 'child_id');
}
public function parents()
{
return $this->belongsToMany('App\Page', 'pages_relation', 'child_id', 'page_id');
}
}
You can stick to whatever feels good to you. But, I feel this is more consistent. As, there is only single source of truth.
If A is a child of B, then B has to be a parent of A, its obvious, only "A is child of B" is enough to state "B is a parent of A".
I have tested this, it works very well.
EDIT
You can extend BelongsToMany relation to get BelongsToManySiblings realtionship, and just override the addWhereConstraints method.
class BelongsToManySiblings extends BelongsToMany
{
protected function addWhereConstraints()
{
$parentIds = \DB::table($this->table)
->select($this->foreignPivotKey)
->where($this->relatedPivotKey, '=', $this->parent->{$this->parentKey})
->get()->pluck($this->foreignPivotKey)->toArray();
$this->query->whereIn(
$this->getQualifiedForeignPivotKeyName(),
$parentIds
)->where(
$this->getQualifiedRelatedPivotKeyName(),
'<>',
$this->parent->{$this->parentKey}
)->groupBy($this->getQualifiedRelatedPivotKeyName());
return $this;
}
}
Then you can add siblings relationship method on your Page model:
public function siblings()
{
return new BelongsToManySiblings(
$this->newRelatedInstance(Page::class)->newQuery(),
$this,
'pages_relation',
'parent_id',
'child_id',
'id',
'id',
$this->guessBelongsToManyRelation()
);
}
Note: This case does not work for eager loads, eager load needs overriding match and addEagerContraints methods on the BelongsToManySiblings class. You can peek the BelongsToMany class on laravel source to see an example how it eager loads the relations.

laravel4 How can i use eloquent in my controller?

I would like to create a relationship between 2 tables with eloquent but i can't find exactly how to proceed...
Here are my 2 models with relationship :
Table "etablissement":
<?php class Etablissement extends Eloquent {
/**
* The database table used by the model.
*
* #var string
*/
protected $table = 'etablissement';
public function annulation()
{
return $this->hasMany('Annulation');
}}
Table "annulation":
<?php class Annulation extends Eloquent {
/**
* The database table used by the model.
*
* #var string
*/
protected $table = 'annulation_remboursement';
public function etablissement ()
{
return $this->belongsTo('Etablissement');
}}
In the "Etablissement" table there is an id for each etablissement (id_etablissement) and in the "annulation" there is a column with the id_etablissement. How can i return in my controller a relation in order to have the etablissement's name with the annulation->id_etablissement :
class AnnulationsController extends \BaseController {
/**
* Display a listing of the resource.
*
* #return Response
*/
public function index()
{
}
It should be something like this within your index method:
$annulation = Annulation::find(1);
$annulation->etablissement->name
The annulation_remboursement table should have a establissement_id field.
Perhaps the error may be in the keys of the relation.
In https://laravel.com/docs/4.2/eloquent#one-to-one we see:
Take note that Eloquent assumes the foreign key of the relationship based on the model name. In this case, Phone model is assumed to use a user_id foreign key. If you wish to override this convention, you may pass a second argument to the hasOne method. Furthermore, you may pass a third argument to the method to specify which local column that should be used for the association:
return $this->hasOne('Phone', 'foreign_key');
return $this->hasOne('Phone', 'foreign_key', 'local_key');
[...] One To Many: Again, you may override the conventional foreign key by passing a second argument to the hasMany method. And, like the hasOne relation, the local column may also be specified:
return $this->hasMany('Comment', 'foreign_key');
return $this->hasMany('Comment', 'foreign_key', 'local_key');
* You should also checkout Defining The Inverse Of A Relation at the same page.
So, in your case you have a key named id_etablissement but Laravel is searching for etablissement_id. If you wish to override this behaviour and specify a key you should do something like:
protected $table = 'etablissement';
public function annulation()
{
return $this->hasMany('Annulation','id_etablissement');
}
and according to "The Inverse Of A Relation"
protected $table = 'annulation_remboursement';
public function etablissement ()
{
return $this->belongsTo('Etablissement','id_etablissement');
}
Note that I didn't put any of the local keys, but those will be the third parameter of the relation.

Resources