Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.3] Fire BelongsToMany relation events for attach/detach/sync/toggle #14988

Conversation

jarektkaczyk
Copy link
Contributor

BelongsToMany relations is the only missing part in the events system in eloquent.

It has been requested for a long time and has many applications, eg.
#10435 #10545 #10770 #4022 #2303 #1703 #8452

This PR is BC however it also brings change in the public interface, that is all the methods (attach/detach/sync/toggle) get additional false return value in case *ing event halts the execution. As is it doesn't break BC however there might be edge case:

1. one relies on array return value from sync or toggle
2. then adds syncing/toggling event handler that can halt execution in some circumstances
3. the return value under those circumstances is no longer array but false


  • It introduces 8 new events:
eloquent.attaching.[RELATION]: Parent\Model
eloquent.attached.[RELATION]: Parent\Model
eloquent.detaching._RELATION_: Parent\Model
eloquent.detached._RELATION_: Parent\Model
eloquent.syncing._RELATION_: Parent\Model
eloquent.synced._RELATION_: Parent\Model
eloquent.toggling._RELATION_: Parent\Model
eloquent.toggled._RELATION_: Parent\Model
  • and appropriate methods on the Model to register these events (due to different nature, they have additional parameter - relation name), eg:
// Model::attaching($relation, function ($parent, Support\Collection $pivotData) { ... })
User::attaching('roles', function ($user, $pivotData) {
    // payload contains parent of the relation AND pivot data being inserted to the table
    $pivotData == Collection([
        ['user_id' => $user->id, 'role_id' => ATTACHED_ID, 'some_pivot_field' => 'some pivot value'],
        ...
    ]);
})

// manually
$dispatcher->listen('eloquent.attaching.roles: App\User', function ($user, $pivotData) { ... })

Since the $pivotData payload may vary depending on the event, here's complete list of its content:

  • attaching - like in the example above - Collection with raw data to be inserted to the pivot table
  • attached - same as above
  • detaching - Collection of ids being detached OR empty Collection
  • detaching - Collection of detached ids OR empty Collection
  • syncing - Collection of id => [ ..pivot data.. ] entries to be inserted/updated
  • synced - Collection of changes made (the same as return value of sync only wrapped in Collection)
  • toggling - Collection of id => [ ..pivot data.. ] entries to be inserted/updated
  • toggled - Collection of changes made (the same as return value of sync only wrapped in Collection)

and an example of all events being fired when syncing:

>>> $user->roles()->sync([1, 2 => ['extra' => 'provided pivot value']])

user 1 syncing roles: {"1":[],"2":{"extra":"provided pivot value"}}
user 1 detaching roles: ["6"]
user 1 detached roles: ["6"]
user 1 attaching roles: [{"user_id":1,"role_id":1}]
user 1 attached roles: [{"user_id":1,"role_id":1,}]
user 1 attaching roles: [{"user_id":1,"role_id":2,"extra":"provided pivot value"}]
user 1 attached roles: [{"user_id":1,"role_id":2,"extra":"provided pivot value"}]
user 1 synced roles: {"attached":[1,2],"detached":[6],"updated":[]}

All of the *ing events are, just like existing ones, capable of halting the action if your listener returns false:

// Bill can't be an admin:
User::attaching('roles', function ($user, $pivotData) use ($adminRole) {
   if ($user->name == 'Bill'
      && $pivotData->contains(function ($record) use ($adminRole) {
           return $record['role_id'] == $adminRole->id;
      })
   ) {
       return false;
   }
})

You can also adjust pivot data in the callback, just like you'd do with exiting events:

// We need UUID for the pivot records (well... yeah):
User::attaching('roles', function ($user, $pivotData) {
   foreach ($pivotData as $key => $record) {
      $record['uuid'] = data_get($record, 'uuid', Uuid::generate()); // generate if not provided already
      $pivodData->put($key, $record);
   } 
})


>>> $user->roles()->attach(5)
>>> $user->roles()->find(5)
Role {
  'name' => 'editor',
  ...
  'pivot' => Pivot {
    'user_id' => 1,
    'role_id' => 5,
    'uuid' => 'a-nice-auto-generated-uuid'
  }
}

Notes on the commits:

  1. the code in BelongsToMany has been a bit refactored in order to use Collection where events require that
  2. new tests added + some brittle tests refactored

@jarektkaczyk jarektkaczyk changed the title 5.3 Fire BelongsToMany relation events for attach/detach/sync/toggle [5.3] Fire BelongsToMany relation events for attach/detach/sync/toggle Aug 24, 2016
@jarektkaczyk jarektkaczyk force-pushed the 5.3-fire-BelongsToMany-relation-events branch from 7fa262e to 8530bcb Compare August 24, 2016 02:33
@axyr
Copy link

axyr commented Aug 27, 2016

I really like this.

Should $model->belongsToRelation()->associate($otherModel)

not also have an event?

@jarektkaczyk
Copy link
Contributor Author

jarektkaczyk commented Aug 27, 2016

1 this is only for belongsToMany, that is operations on pivot table, which
can't be tracked in any way in eloquent directly, unlike belongsTo
2 associate doesn't run the query, so it's not real association until you
call save

Jarek Tkaczyk


// we expect to call $relation->wherePivot()
$relation->getQuery()->shouldReceive('where')->once()->andReturn($relation);
// This is our test! The wherePivot() params also need to be called
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "This is our test!" mean?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should ask @GrahamCampbell :)
I guess it refers to the wherePivot line below. There are bunch of tests in this class that are mocking CUT and require refactoring, but I didn't want to do that, so PR doesn't get even bigger and harder to review.

@jarektkaczyk
Copy link
Contributor Author

ping @taylorotwell

@GrahamCampbell
Copy link
Member

GrahamCampbell commented Sep 8, 2016

ping @taylorotwell

Pending you removing "This is our test!" @jarektkaczyk.

@jarektkaczyk
Copy link
Contributor Author

WAT?

That's an old test, out of scope of this PR, and btw your test Graham (or at least git blames you) ;)

@GrahamCampbell
Copy link
Member

image

@jarektkaczyk
Copy link
Contributor Author

jarektkaczyk commented Sep 8, 2016

well, as irrelevant as it is, here's the time travel:

// This is our test! The wherePivot() params also need to be called

so git blamed your tabs to spaces as latest.

Now, back to the feature being proposed.

@GrahamCampbell
Copy link
Member

GrahamCampbell commented Sep 8, 2016

I'd appreciate you not taking the piss. Please just remove it. I don't care who added it.

@taylorotwell
Copy link
Member

I really don't think this is ever a good solution to a problem to be honest. If you want something to happen before or after you attach / detach / toggle / whatever, you should make a repository class that actually does those things before calling ->attach. That saves us having to maintain all of this complicated event code throughout Eloquent.

@andrewkamm
Copy link

@jarektkaczyk Did you find a clean way to implement this since this PR was rejected? I agree that this would be really useful to have in Laravel core.

@jarektkaczyk
Copy link
Contributor Author

jarektkaczyk commented Dec 17, 2016 via email

@lukepolo
Copy link
Contributor

lukepolo commented Dec 17, 2016 via email

@andy-berry-dev
Copy link

We needed attach/detach events at Agency Core for sending things like User notifications when new tasks were assigned.

I've created a Gist with the extensions we added to make it work - https://gist.github.com/andyberry88/be3c45380568fc359cb61e00c4249704. It's basically the changes suggested by @jarektkaczyk above but taken out into app classes. I've had to extract it from other extensions we've added so hopefully it works. It should at least give an idea of the changes required.

@andrewkamm
Copy link

@andyberry88 Thanks!

@lukepolo
Copy link
Contributor

lukepolo commented Jan 4, 2017

@jarektkaczyk maybe we could work together to make it a package ? Not sure if theres a ton of demand for it but it would save me time in some other applications.

@jarektkaczyk
Copy link
Contributor Author

jarektkaczyk commented Jan 4, 2017

@lukepolo I can help you out with the package, just drop me an email with what assistance you need 🏄‍♂️

@lephleg
Copy link

lephleg commented May 11, 2017

I can also assist any attempt to make this formal.
I am really having a hard time to find a proper workaround for this, as I need to track relationship changes for logging purposes in L4.

@lukepolo
Copy link
Contributor

I sadly do not have the time todo this as previously thought, you can see if @jarektkaczyk still wants to help out

@neylsongularte
Copy link

Experimental package:

https://github.com/neylsongularte/eloquent-extra-events

@fico7489
Copy link

fico7489 commented Dec 6, 2017

I created a well documented and well-tested package for this problem https://github.com/fico7489/laravel-pivot.
You can do much more with my package that with this one neylsongularte/eloquent-extra-events.
You can get parent model, changed pivot ids, and relation name on which is updating performed.

@jarektkaczyk jarektkaczyk deleted the 5.3-fire-BelongsToMany-relation-events branch July 12, 2018 08:24
@jehoshua02
Copy link

@taylorotwell If we create a repository class, is there a way to get Nova to utilize it?

We are doing some caching and want to invalidate/recalculate the cache when many-to-many relationships change.

Do you have any recommendations for how we can do this? For now I will see if we can use laravel-pivot.

@vpillinger
Copy link

vpillinger commented Apr 18, 2019

@taylorotwell Sorry to necro this comment on you; however, perhaps there is a better solution here than creating new events or leaving the current functionality as-is?

If you want something to happen before or after you attach / detach / toggle / whatever, you should make a repository class that actually does those things before calling ->attach.

While I generally agree with this statement regarding business logic like sending notifications, there are good use-cases for wanting events when updating pivots. For example, a common use-case is to implement auditing and having some sort of events fire allows one to setup auditing events in a general way. For example, in an auditing package.

My understanding is that sync() does not trigger the normal eloquent events even if we create a custom pivot model. Therefore, currently one can audit on a belongsTo relationship by listening to the model being updated, but there is not a way to listen to a belongsToMany relationship as the information is contained in an unrelated model that isn't triggering events.

So maybe the appropriate solution is to just make sync() and related methods trigger these default events if a custom Pivot model exists?

Currently one can just ignore these relationship functions and update the pivots manually with a save() statement to get eloquent events. So these convenient functions just become a newbie trap instead of something that one should really use.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.