Eloquent attach/detach/sync fires any event? - php

I have a laravel project, and I need to make some calculations immediately after I save a model and attach some data to it.
Is there any event that is triggered in laravel after calling attach (or detach/sync)?

No, there are no relation events in Eloquent. But you can easily do it yourself (Given for example Ticket belongsToMany Component relation):
// Ticket model
use App\Events\Relations\Attached;
use App\Events\Relations\Detached;
use App\Events\Relations\Syncing;
// ...
public function syncComponents($ids, $detaching = true)
{
static::$dispatcher->fire(new Syncing($this, $ids, $detaching));
$result = $this->components()->sync($ids, $detaching);
if ($detached = $result['detached'])
{
static::$dispatcher->fire(new Detached($this, $detached));
}
if ($attached = $result['attached'])
{
static::$dispatcher->fire(new Attached($this, $attached));
}
}
event object as simple as this:
<?php namespace App\Events\Relations;
use Illuminate\Database\Eloquent\Model;
class Attached {
protected $parent;
protected $related;
public function __construct(Model $parent, array $related)
{
$this->parent = $parent;
$this->related = $related;
}
public function getParent()
{
return $this->parent;
}
public function getRelated()
{
return $this->related;
}
}
then a basic listener as a sensible example:
// eg. AppServiceProvider::boot()
$this->app['events']->listen('App\Events\Relations\Detached', function ($event) {
echo PHP_EOL.'detached: '.join(',',$event->getRelated());
});
$this->app['events']->listen('App\Events\Relations\Attached', function ($event) {
echo PHP_EOL.'attached: '.join(',',$event->getRelated());
});
and usage:
$ php artisan tinker
>>> $t = Ticket::find(1);
=> <App\Models\Ticket>
>>> $t->syncComponents([1,3]);
detached: 4
attached: 1,3
=> null
Of course you could do it without creating Event objects, but this way is more convenient, flexible and simply better.

Steps to solve your problem:
Create custom BelongsToMany relation
In BelongsToMany custom relation override attach, detach, sync and updateExistingPivot methods
In overriden method dispatch desired events.
Override belongsToMany() method in Model and return your custom relation not default relation
and that's it. I created package that already doing that: https://github.com/fico7489/laravel-pivot

Laravel 5.8 now fires events on ->attach()
Check out: https://laravel.com/docs/5.8/releases
And search for: Intermediate Table / Pivot Model Events
https://laracasts.com/discuss/channels/eloquent/eloquent-attach-which-event-is-fired?page=1

Update:
From Laravel 5.8 Pivot Model Events are dispatched like normal model.
https://laravel.com/docs/5.8/releases#laravel-5.8
You just need to add using(PivotModel::class) to your relation and events will work on the PivotModel.
Attach($id) will dispatch Created and Creating
Detach($id) will dispatch Deleting and Deleted,
Sync($ids) will dispatch the needed events too [Created,Creating,Deleting,Deleted]
Only dispatch() with out id doesn't dispatch any event until now.

Related

How to pass parameter to a laravel elequent model's event observer

I have a model in laravel and I want to do something after the first time which an object of my model is created. the simplest way is to add a static boot method inside my model's class like the code below:
class modelName extends Model
{
public static function boot()
{
parent::boot();
self::created(function ($model) {
//the model created for the first time and saved
//do something
//code here
});
}
}
so far so good! the problem is: the ONLY parameter that created method accepts is the model object itself(according to the documentation) :
Each of these methods receives the model as their only argument.
https://laravel.com/docs/5.5/eloquent#events
I need more arguments to work with after model creation. how can I do that?
Or is there any other way to do something while it's guaranteed that the model has been created?
laravel version is 5.5.
You're close. What I would probably do would be to dispatch an event right after you actually create the model in your controller. Something like this.
class WhateverController
{
public function create()
{
$model = Whatever::create($request->all());
$anotherModel = Another::findOrFail($request->another_id);
if (!$model) {
// The model was not created.
return response()->json(null, 500);
}
event(new WhateverEvent($model, $anotherModel));
}
}
I solved the issue using static property in eloquent model class:
class modelName extends Model
{
public static $extraArguments;
public function __construct(array $attributes = [],$data = [])
{
parent::__construct($attributes);
self::$extraArguments = $data ;
public static function boot()
{
parent::boot();
self::created(function ($model) {
//the model created for the first time and saved
//do something
//code here
self::$extraArguments; // is available in here
});
}
}
It works! but I don't know if it may cause any other misbehavior in the application.
Using laravel events is also a better and cleaner way to do that in SOME cases.but the problem with event solution is you can't know if the model has been created for sure and it's time to call the event or it's still in creating status ( and not created status).

Laravel Observer Create Working but Delete Not Working

I am Trying to Using Observer for Deleting With Relationship But Problem Is When i DD in Created Function Its Working Fine But When i DD In Deleted Function It Shows Nothing (POSTMAN) Means Neither Working Nor Error With Same Everything
Here Is Api:
$api->post('store','App\Http\Controllers\CustomerController#store');
$api->delete('delete/{id}','App\Http\Controllers\CustomerController#destroy');
Here Is Observer file made by artisan
namespace App\Observers;
use App\Customer;
class CustomerObserver
{
public function created(Customer $customer)
{
dd($customer);
}
public function deleted(Customer $customer)
{
dd($customer);
}
}
Here is Customer Controller
class CustomerController extends Controller
{
public function store(Request $request)
{
return Customer::store($request->person);
}
public function destroy($id)
{
$delete = Customer::where('person_id',$id);
$delete->delete();
}
}
Here Is Customer Model File.
class Customer extends Model
{
//Relationship Start From Here
public function person()
{
return $this->belongsTo(Person::class);
}
//End Here
public static function store($request)
{
//Call to Person Model And Save User
$user = Person::store($request);
//Create object of Customer Model
$customer = new Customer();
$customer->fill($request['customers']);
$customer->person()->associate($user)->save();
//return customer
return $customer;
}
}
I know this might be a late reply and not sure if you are still looking for the answer. I think the issue is about how you delete your Customer model.
When you do something like
$delete = Customer::where('person_id',$id);
$delete->delete();
You are executing a mass delete statement. As stated in laravel document, mass deletes will not fire any model events for the models that are deleted This is the reason your deleted observer event didn't fire.
When executing a mass delete statement via Eloquent, the deleting and deleted model events will not be fired for the deleted models. This is because the models are never actually retrieved when executing the delete statement
Now look at how you create a Customer. You create a model one at a time. Therefore your created observer does get run.
//Create object of Customer Model
$customer = new Customer();
$customer->fill($request['customers']);
$customer->person()->associate($user)->save();
To solve your problem, the easiest way is to retrieve all the models and delete one by one, so that you can trigger the event.
foreach (Customer::where('person_id',$id)->get() as $delete) {
$delete->delete();
}
can you do all things
1 add line in Customer::observe(CustomerObserver::class); in CustomerServiceProvider in boot method
add CustomerServiceProvider in app.php file in provider array
composer dump-autoload
php artisan config:cache
I was also having this problem, however I was calling the delete method from the repository, which prevents the Observer's deleted event from being triggered. When I used the delete from the mode it worked normally.
Only these Methods Works
$customer=Customer::where('id',$id)->first();
if($customer){
$customer->delete();
}
Or
$customer=Customer::find($id)->delete();

Yii2: Can I attach events from outside the model class?

I am currently writing a class that caches model data for a select field.
Now obviously, if any model that is affecting this select field gets inserted, updated or deleted, the cache must be refreshed.
To handle this, I'd like to use the model events of Yii2. For example, if EVENT_AFTER_INSERT is triggered in the model Album, I want to execute the code to refresh the cache of the album select data.
Now I could do this the classical way and add an event to the model Album like this:
class Album extends ActiveRecord {
public function init(){
$this->on(self::EVENT_AFTER_INSERT, [$this, 'refresh_cache']);
$this->on(self::EVENT_AFTER_UPDATE, [$this, 'refresh_cache']);
$this->on(self::EVENT_AFTER_DELETE, [$this, 'refresh_cache']);
}
// ...
}
That would work, yes. Problem is, I'd need to include this code in any model I'd like to create a select field from at any point of development. It's not such a big deal, but you can easily forget it while coding and if the behavior needs to change at some point, you need to update a whole bunch of models.
Now here is my question: Is there any possibility to add events to a model from another component? My idea would be to create a component, that knows about all used select data caches and adds the necessary model events accordingly. any idea how to achieve this or something similar?
you just need create a behaviour and attach it to your various models. see the basic guide and speciffically the Behavior::events() use case
so i went ahead and wrote an example
class RefreshCacheBehavior extends \yii\base\Behavior
{
public function events() {
return [
\yii\db\ActiveRecord::EVENT_AFTER_INSERT => 'refreshCache',
\yii\db\ActiveRecord::EVENT_AFTER_UPDATE => 'refreshCache',
\yii\db\ActiveRecord::EVENT_AFTER_DELETE => 'refreshCache',
];
}
/**
* event handler
* #param \yii\base\Event $event
*/
public function refreshCache($event) {
// model that triggered the event will be $this->owner
// do things with Yii::$app->cache
}
}
class Album extends ActiveRecord {
public function behaviors() {
return [
['class' => RefreshCacheBehavior::className()],
];
}
// ...
}
Is there any possibility to add events to a model from another component?
Yes! You can use class level event handlers. The line of code below shows how to do that.
Event::on(ActiveRecord::className(), ActiveRecord::EVENT_AFTER_INSERT, function ($event) {
Yii::debug(get_class($event->sender) . ' is inserted');
});
You can use same code in your init method and bind it to your class method instead of that closure.
I would create a class implementing BootstrapInterface and add it to config. Then I would handle those class level events there!
Do yourself a favour and read about events in the Guide as well as the API Documentation
on() is a public method, so you can always attach event to already instantiated object. This may be useful if you're using some kind of factory do build your objects:
public function createModel($id) {
$model = Album::findOne($id);
if ($model === null) {
// some magic
}
$model->on(Album::EVENT_AFTER_INSERT, [$this, 'refresh_cache']);
$model->on(Album::EVENT_AFTER_UPDATE, [$this, 'refresh_cache']);
$model->on(Album::EVENT_AFTER_DELETE, [$this, 'refresh_cache']);
return $model;
}

Laravel observer for pivot table

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
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.
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
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.

Accessors (Getter) & Mutators (Setter) On a Pivot Table in Laravel

I have a pivot table that connects users to workspaces. On the pivot table, I also have a column for role, which defines the users role for that workspace. Can I provide Accessor (Getter) & Mutator (Setter) methods on the role inside the pivot table? I have been trying to look all over, but details on pivot tables in eloquent are pretty sparse.
I am not sure if I have to setup a custom pivot model? If I do, an example would be awesome as the documentation on pivot models is very basic.
Thanks.
If all you need to do is access additional fields on the pivot table, you just need to use the withPivot() method on the relationship definition:
class User extends Model {
public function workspaces() {
return $this->belongsToMany('App\Models\Workspace')->withPivot('role');
}
}
class Workspace extends Model {
public function users() {
return $this->belongsToMany('App\Models\User')->withPivot('role');
}
}
Now your role field will be available on the pivot table:
$user = User::first();
// get data
foreach($user->workspaces as $workspace) {
var_dump($workspace->pivot->role);
}
// set data
$workspaceId = $user->workspaces->first()->id;
$user->workspaces()->updateExistingPivot($workspaceId, ['role' => 'new role value']);
If you really need to create accessors/mutators for your pivot table, you will need to create a custom pivot table class. I have not done this before, so I don't know if this will actually work, but it looks like you would do this:
Create a new pivot class that contains your accessors/mutators. This class should extend the default Pivot class. This new class is the class that is going to get instantiated when User or Workspace creates a Pivot model instance.
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class UserWorkspacePivot extends Pivot {
getRoleAttribute() {
...
}
setRoleAttribute() {
...
}
}
Now, update your User and Workspace models to create this new pivot table class, instead of the default one. This is done by overriding the newPivot() method provided by the Model class. You want to override this method so that you create an instance of your new UserWorkspacePivot class, instead of the default Pivot class.
class User extends Model {
// normal many-to-many relationship to workspaces
public function workspaces() {
// don't forget to add in additional fields using withPivot()
return $this->belongsToMany('App\Models\Workspace')->withPivot('role');
}
// method override to instantiate custom pivot class
public function newPivot(Model $parent, array $attributes, $table, $exists) {
return new UserWorkspacePivot($parent, $attributes, $table, $exists);
}
}
class Workspace extends Model {
// normal many-to-many relationship to users
public function users() {
// don't forget to add in additional fields using withPivot()
return $this->belongsToMany('App\Models\User')->withPivot('role');
}
// method override to instantiate custom pivot class
public function newPivot(Model $parent, array $attributes, $table, $exists) {
return new UserWorkspacePivot($parent, $attributes, $table, $exists);
}
}
I figured out how to use Accessors and Mutators on the Pivot table (I'm using Laravel 5.8)
You must use using() on your belongsToMany relationships, for example:
namespace App;
use Illuminate\Database\Eloquent\Model;
class User extends Model {
public function workspaces() {
return $this->belongsToMany('App\Workspace')->using('App\UserWorkspace');
}
}
namespace App;
use Illuminate\Database\Eloquent\Model;
class Workspace extends Model {
public function users() {
return $this->belongsToMany('App\User')->using('App\UserWorkspace');
}
}
So, use your Pivot model:
namespace App;
use Illuminate\Database\Eloquent\Relations\Pivot;
class UserWorkspace extends Pivot {
public function getRoleAttribute() {
// your code to getter here
}
public function setRoleAttribute($value) {
// your code to setter here
}
}
This is a difficult question. The solutions I can think of are smelly and may cause some problems later on.
I am going to extend on Patricus's answer to make it work.
I was going to comment on Patricus's answer but there is simply too much to explain. To make his solution work with attach and sync we must do some ugly things.
The Problem
First let's identify the problem with his solution. His getters and setters do work but the belongsToMany relationship doesn't use the Pivot model when running sync, attach, or detach. This means every time we call one of these with the $attributes parameter the non-mutated data will be put into the database column.
// This will skip the mutator on our extended Pivot class
$user->workspaces()->attach($workspace, ['role' => 'new role value']);
We could just try to remember that every time we call one of these we can't use the second parameter to attach the mutated data and just call updateExistingPivot with the data that must be mutated. So an attach would be what Patricus stated:
$user->workspaces()->attach($workspace);
$user->workspaces()->updateExistingPivot($workspaceId, ['role' => 'new role value']);
and we could never use the correct way of passing the pivot attributes as the attach methods second parameter shown in the first example. This will result in more database statements and code rot because you must always remember not to do the normal way. You could run into serious problems later on if you assume every developer, or even yourself, will just know not to use the attach method with the second parameter as it was intended.
The Solution (untested and imperfect)
To be able to call attach with the mutator on the pivot columns you must do some crazy extending. I haven't tested this but it may get you on the right path if you feel like giving it a try. We must first create our own relationship class that extends BelongsToMany and implements our custom attach method:
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class UserWorkspaceBelongsToMany extends BelongsToMany {
public function attach($id, array $attributes = [], $touch = true)
{
$role = $attributes['role'];
unset($attributes['role']);
parent::attach($id, $attributes, $touch);
$this->updateExistingPivot($id, ['role' => $role], $touch);
}
// You will need sync here too
}
Now we have to make each Model::belongsToMany use our new UserWorkspaceBelongsToMany class instead of the normal BelongsToMany. We do this by mocking the belongsToMany in our User and Workspace class:
// put this in the User and Workspace Class
public function userWorkspaceBelongsToMany($related, $table = null, $foreignKey = null, $otherKey = null, $relation = null)
{
if (is_null($relation)) {
$relation = $this->getBelongsToManyCaller();
}
$foreignKey = $foreignKey ?: $this->getForeignKey();
$instance = new $related;
$otherKey = $otherKey ?: $instance->getForeignKey();
if (is_null($table)) {
$table = $this->joiningTable($related);
}
$query = $instance->newQuery();
return new UserWorkspaceBelongsToMany($query, $this, $table, $foreignKey, $otherKey, $relation);
}
As you can see, we are still calling the database more but we don't have to worry about someone calling attach with the pivot attributes and them not getting mutated.
Now use that inside your models instead of the normal belongsToMany:
class User extends Model {
public function workspaces() {
return $this->userWorkspaceBelongsToMany('App\Models\Workspace')->withPivot('role');
}
}
class Workspace extends Model {
public function users() {
return $this->userWorkspaceBelongsToMany('App\Models\User')->withPivot('role');
}
}
Its impossible to use setters, will not affect pivot table... make the change in the controller instead.

Categories