9

I've got a observer that has a update method:

ObserverServiceProvider.php

public function boot()
{
    Relation::observe(RelationObserver::class);
}

RelationObserver.php

public function updated(Relation $relation)
{
    $this->cache->tags(Relation::class)->flush();
}

So when I update a relation in my controller:

public function update(Request $request, Relation $relation)
{
     $relation->update($request->all()));
     return back();
}

Everything is working as expected. But now I've got a pivot table. A relation belongsToMany products.

So now my controller method looks like this:

public function update(Request $request, Relation $relation)
{
    if(empty($request->products)) {
        $relation->products()->detach();
    } else {
        $relation->products()->sync(collect($request->products)->pluck('id'));
    }

    $relation->update($request->all());

    return back();
}

The problem is that the observer is not triggered anymore if I only add or remove products.

How can I trigger the observer when the pivot table updates aswel?

Thanks

Jamie
  • 10,302
  • 32
  • 103
  • 186
  • Just FYI, you shouldn't need to have the if statement in the controller as `$relation->products()->sync(collect($request->products)->pluck('id'));` will simply detach the products for you if `$request->products` is empty or null. – Rwd Jul 14 '17 at 13:08
  • @RossWilson I guess that's not true. I receive an error if array is ```[]```. https://stackoverflow.com/questions/40489712/laravel-sync-not-working-with-empty-array – Jamie Jul 14 '17 at 13:09
  • I think this would be considered a mass update, Laravel does not fire the `saved()` and `updated()` model events for mass updates - because the model isn't actually retrieved. [See the notice under Mass Updates](https://laravel.com/docs/5.4/eloquent#inserting-and-updating-models) – Yat23 Jul 14 '17 at 13:10
  • @RossWilson sorry you're right thanks – Jamie Jul 14 '17 at 13:11
  • @Yat23 hmmm so it's not possible I guess. – Jamie Jul 14 '17 at 13:12
  • Yeah, it use to be an issue but it was changed in 5.3.29 :) – Rwd Jul 14 '17 at 13:18

3 Answers3

13

As you already know, Laravel doesn't actually retrieve the models nor call save/update on any of the models when calling sync() thus no event's are created by default. But I came up with some alternative solutions for your problem.


1 - To add some extra functionality to the sync() method:

If you dive deeper into the belongsToMany functionality you will see that it tries to guess some of the variable names and returns a BelongsToMany object. Easiest way would be to make your relationship function to simply return a custom BelongsToMany object yourself:

public function products() {

    // Product::class is by default the 1. argument in ->belongsToMany calll
    $instance = $this->newRelatedInstance(Product::class);

    return new BelongsToManySpecial(
        $instance->newQuery(),
        $this,
        $this->joiningTable(Product::class), // By default the 2. argument
        $this->getForeignKey(), // By default the 3. argument
        $instance->getForeignKey(), // By default the 4. argument
        null // By default the 5. argument
    );
}

Or alternatively copy the whole function, rename it and make it return the BelongsToManySpecial class. Or omit all the variables and perhaps simply return new BelongsToManyProducts class and resolve all the BelongsToMany varialbes in the __construct... I think you got the idea.

Make the BelongsToManySpecial class extend the original BelongsToMany class and write a sync function to the BelongsToManySpecial class.

public function sync($ids, $detaching = true) {

    // Call the parent class for default functionality
    $changes = parent::sync($ids, $detaching);

    // $changes = [ 'attached' => [...], 'detached' => [...], 'updated' => [...] ]
    // Add your functionality
    // Here you have access to everything the BelongsToMany function has access and also know what changes the sync function made.

    // Return the original response
    return $changes
}

Alternatively override the detach and attachNew functions for similar results.

protected function attachNew(array $records, array $current, $touch = true) {
    $result = parent::attachNew($records, $current, $touch);

    // Your functionality

    return $result;
}

public function detach($ids = null, $touch = true)
    $result = parent::detach($ids, $touch);

    // Your functionality

    return $result;
}

If you want to dig deeper and want to understand what's going on under the hood then analyze the Illuminate\Database\Eloquent\Concerns\HasRelationship trait - specifically the belongsToMany relationship function and the BelongsToMany class itself.


2 - Create a trait called BelongsToManySyncEvents which doesn't do much more than returns your special BelongsToMany class

trait BelongsToManySyncEvents {

    public function belongsToMany($related, $table = null, $foreignKey = null, $relatedKey = null, $relation = null) {

        if (is_null($relation)) {
            $relation = $this->guessBelongsToManyRelation();
        }

        $instance = $this->newRelatedInstance($related);
        $foreignKey = $foreignKey ?: $this->getForeignKey();
        $relatedKey = $relatedKey ?: $instance->getForeignKey();

        if (is_null($table)) {
            $table = $this->joiningTable($related);
        }

        return new BelongsToManyWithSyncEvents(
            $instance->newQuery(), $this, $table, $foreignKey, $relatedKey, $relation
        );
    }

}

Create the BelongsToManyWithSyncEvents class:

class BelongsToManyWithSyncEvents extends BelongsToMany {

    public function sync($ids, $detaching = true) {

        $changes = parent::sync($ids, $detaching);

        // Do your own magic. For example using these variables if needed:
        // $this->get() - returns an array of objects given with the sync method
        // $this->parent - Object they got attached to
        // Maybe call some function on the parent if it exists?

        return $changes;
    }

}

Now add the trait to your class.


3 - Combine the previous solutions and add this functionality to every Model that you have in a BaseModel class etc. For examples make them check and call some method in case it is defined...

$functionName = 'on' . $this->foreignKey . 'Sync';

if(method_exists($this->parent), $functionName) {
    $this->parent->$functionName($changes);
}

4 - Create a service

Inside that service create a function that you must always call instead of the default sync(). Perhaps call it something attachAndDetachProducts(...) and add your events or functionality


As I didn't have that much information about your classes and relationships you can probably choose better class names than I provided. But if your use case for now is simply to clear cache then I think you can make use of some of the provided solutions.

Artur K.
  • 3,263
  • 3
  • 38
  • 54
8

When I search about this topic, it came as the first result. However, for newer Laravel version you can just make a "Pivot" model class for that.

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class PostTag extends Pivot
{
    protected $table = 'post_tag';

    public $timestamps = null;
}

For the related model

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->using(PostTag::class);
}

and you have to put your declare your observer in EventServiceProvider as stated in Laravel Docs

PostTag::observe(PostTagObserver::class);

Reference: Observe pivot tables in Laravel

Kidd Tang
  • 1,821
  • 1
  • 14
  • 17
1

Just add:

public $afterCommit = true;

at the beginning of the observer class.. It will wait until the transactions are done, then performs your sync which should then work fine..

Please check Laravel's documentation for that.

It seems this solutions was just added in Laravel 8.

darroosh
  • 751
  • 2
  • 7
  • 18