Skip to content

Commit 3b73c51

Browse files
committed
Fix BelongsToMany cache handling with custom implementation
Introduced `CachingBelongsToMany` to ensure proper cache invalidation after operations like attach, detach, sync, and updateExistingPivot. Updated the `ModelRelationships` trait to utilize this custom implementation, resolving bugs caused by reliance on non-existent Laravel events. Updated documentation to reflect these changes.
1 parent 9b59196 commit 3b73c51

File tree

4 files changed

+247
-49
lines changed

4 files changed

+247
-49
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to `laravel-model-cache` will be documented in this file.
44

5+
## [1.1.2] - 2025-05-21
6+
7+
### Fixed
8+
- Fixed implementation of the `ModelRelationships` trait to properly handle BelongsToMany operations. Replaced the event-based approach (which was relying on non-existent Laravel events) with a custom BelongsToMany relationship class that flushes the cache after attach, detach, sync, syncWithoutDetaching, and updateExistingPivot operations.
9+
- Updated `CachingBelongsToMany` class to properly extend Laravel's BelongsToMany class and maintain the relationship contract. This resolves the "must return a relationship instance" error when accessing relationship properties after operations like attach() and detach().
10+
511
## [1.1.1] - 2025-05-19
612

713
### Fixed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,15 +404,17 @@ $post->detachRelationshipAndFlushCache('tags', [1, 3]);
404404

405405
### Automatic Cache Invalidation
406406

407-
The trait also registers event listeners that automatically flush the cache when Laravel's relationship methods are
408-
used:
407+
The trait also automatically flushes the cache when Laravel's standard relationship methods are
408+
used by providing a custom BelongsToMany relationship implementation:
409+
409410

410411
```php
411412
// These operations will automatically flush the cache
412413
$post->tags()->attach(1);
413414
$post->tags()->detach([2, 3]);
414415
$post->tags()->sync([1, 4, 5]);
415416
$post->tags()->updateExistingPivot(1, ['featured' => true]);
417+
$post->tags()->syncWithoutDetaching([1, 5]);
416418
```
417419

418420
This ensures that your cached queries always reflect the current state of your model relationships.

src/CachingBelongsToMany.php

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace YMigVal\LaravelModelCache;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
7+
8+
class CachingBelongsToMany extends BelongsToMany
9+
{
10+
/**
11+
* The parent model that should have its cache flushed.
12+
*
13+
* @var Model
14+
*/
15+
protected $cacheableParent;
16+
17+
/**
18+
* Create a new belongs to many relationship instance.
19+
*
20+
* @param \Illuminate\Database\Eloquent\Builder $query
21+
* @param \Illuminate\Database\Eloquent\Model $parent
22+
* @param string $table
23+
* @param string $foreignPivotKey
24+
* @param string $relatedPivotKey
25+
* @param string $parentKey
26+
* @param string $relatedKey
27+
* @param string $relationName
28+
* @param Model $cacheableParent
29+
* @return void
30+
*/
31+
public function __construct($query, $parent, $table, $foreignPivotKey,
32+
$relatedPivotKey, $parentKey, $relatedKey,
33+
$relationName = null, $cacheableParent = null)
34+
{
35+
parent::__construct(
36+
$query, $parent, $table, $foreignPivotKey,
37+
$relatedPivotKey, $parentKey, $relatedKey, $relationName
38+
);
39+
40+
// Store the parent model that has the cache trait
41+
$this->cacheableParent = $cacheableParent ?: $parent;
42+
}
43+
44+
/**
45+
* Attach a model to the parent.
46+
*
47+
* @param mixed $id
48+
* @param array $attributes
49+
* @param bool $touch
50+
* @return void
51+
*/
52+
public function attach($id, array $attributes = [], $touch = true)
53+
{
54+
// Call parent method to perform the actual attach
55+
parent::attach($id, $attributes, $touch);
56+
57+
// Flush cache after operation
58+
$this->flushCache('attach');
59+
}
60+
61+
/**
62+
* Detach models from the relationship.
63+
*
64+
* @param mixed $ids
65+
* @param bool $touch
66+
* @return int
67+
*/
68+
public function detach($ids = null, $touch = true)
69+
{
70+
// Call parent method to perform the actual detach
71+
$result = parent::detach($ids, $touch);
72+
73+
// Flush cache after operation
74+
$this->flushCache('detach');
75+
76+
return $result;
77+
}
78+
79+
/**
80+
* Sync the intermediate tables with a list of IDs or collection of models.
81+
*
82+
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
83+
* @param bool $detaching
84+
* @return array
85+
*/
86+
public function sync($ids, $detaching = true)
87+
{
88+
// Call parent method to perform the actual sync
89+
$result = parent::sync($ids, $detaching);
90+
91+
// Flush cache after operation
92+
$this->flushCache('sync');
93+
94+
return $result;
95+
}
96+
97+
/**
98+
* Update an existing pivot record on the table.
99+
*
100+
* @param mixed $id
101+
* @param array $attributes
102+
* @param bool $touch
103+
* @return int
104+
*/
105+
public function updateExistingPivot($id, array $attributes, $touch = true)
106+
{
107+
// Call parent method to perform the actual update
108+
$result = parent::updateExistingPivot($id, $attributes, $touch);
109+
110+
// Flush cache after operation
111+
$this->flushCache('updateExistingPivot');
112+
113+
return $result;
114+
}
115+
116+
/**
117+
* Sync the intermediate tables with a list of IDs without detaching.
118+
*
119+
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
120+
* @return array
121+
*/
122+
public function syncWithoutDetaching($ids)
123+
{
124+
// Call parent method to perform the actual sync
125+
$result = parent::syncWithoutDetaching($ids);
126+
127+
// Flush cache after operation
128+
$this->flushCache('syncWithoutDetaching');
129+
130+
return $result;
131+
}
132+
133+
/**
134+
* Flush the model cache after a relationship operation.
135+
*
136+
* @param string $operation
137+
* @return void
138+
*/
139+
protected function flushCache($operation)
140+
{
141+
if (method_exists($this->cacheableParent, 'flushModelCache')) {
142+
$this->cacheableParent->flushModelCache();
143+
} else {
144+
if (method_exists($this->cacheableParent, 'flushCache')) {
145+
$this->cacheableParent->flushCache();
146+
} else {
147+
throw new \Exception('The parent model must have a flushCache() or flushModelCache() method defined. Make sure your model uses the HasCachedQueries trait. The ModelRelationships trait should be used in conjunction with the HasCachedQueries trait. See the documentation for more information.');
148+
}
149+
}
150+
151+
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
152+
logger()->info("Cache flushed after {$operation} operation for model: " . get_class($this->cacheableParent));
153+
}
154+
}
155+
}

src/ModelRelationships.php

Lines changed: 82 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,59 +3,64 @@
33
namespace YMigVal\LaravelModelCache;
44

55
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
67

78
/**
89
* Helper trait to flush cache when relationship methods are called.
910
*
1011
* This trait can be used alongside HasCachedQueries to ensure that
1112
* operations on model relationships also flush the cache appropriately.
13+
* @method newRelatedInstance(string $related)
14+
* @method getForeignKey()
15+
* @method joiningTable(string $related)
16+
* @method getKeyName()
1217
*/
1318
trait ModelRelationships
1419
{
1520
/**
16-
* Initialize the trait.
21+
* Override the belongsToMany relationship method to return a custom
22+
* relationship class that handles cache flushing after operations.
1723
*
18-
* @return void
19-
*/
20-
public function initializeModelRelationships()
21-
{
22-
// Register a callback to execute after Eloquent relationship methods
23-
$this->registerModelEvent('belongsToMany.saved', function ($relation, $parent, $ids, $attributes) {
24-
$this->flushRelationshipCache($parent);
25-
});
26-
27-
$this->registerModelEvent('belongsToMany.attached', function ($relation, $parent, $ids, $attributes) {
28-
$this->flushRelationshipCache($parent);
29-
});
30-
31-
$this->registerModelEvent('belongsToMany.detached', function ($relation, $parent, $ids, $attributes) {
32-
$this->flushRelationshipCache($parent);
33-
});
34-
35-
$this->registerModelEvent('belongsToMany.synced', function ($relation, $parent, $ids, $attributes) {
36-
$this->flushRelationshipCache($parent);
37-
});
38-
39-
$this->registerModelEvent('belongsToMany.updated', function ($relation, $parent, $ids, $attributes) {
40-
$this->flushRelationshipCache($parent);
41-
});
42-
}
43-
44-
/**
45-
* Flush cache after a relationship operation.
46-
*
47-
* @param Model $model
48-
* @return void
24+
* @param string $related
25+
* @param string|null $table
26+
* @param string|null $foreignPivotKey
27+
* @param string|null $relatedPivotKey
28+
* @param string|null $parentKey
29+
* @param string|null $relatedKey
30+
* @param string|null $relation
31+
* @return \YMigVal\LaravelModelCache\CachingBelongsToMany
4932
*/
50-
protected function flushRelationshipCache(Model $model)
33+
public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null,
34+
$parentKey = null, $relatedKey = null, $relation = null)
5135
{
52-
if (method_exists($model, 'flushModelCache')) {
53-
$model->flushModelCache();
36+
// Get the original relationship instance
37+
$instance = $this->newRelatedInstance($related);
38+
39+
$foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey();
40+
$relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey();
41+
42+
// Determine the relationship name if not provided
43+
if (is_null($relation)) {
44+
$relation = $this->guessBelongsToManyRelation();
45+
}
5446

55-
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
56-
logger()->info("Cache flushed after relationship operation for model: " . get_class($model));
57-
}
47+
// Generate table name if not provided
48+
if (is_null($table)) {
49+
$table = $this->joiningTable($related);
5850
}
51+
52+
// Create our caching BelongsToMany relationship
53+
return new CachingBelongsToMany(
54+
$instance->newQuery(),
55+
$this,
56+
$table,
57+
$foreignPivotKey,
58+
$relatedPivotKey,
59+
$parentKey ?: $this->getKeyName(),
60+
$relatedKey ?: $instance->getKeyName(),
61+
$relation,
62+
$this
63+
);
5964
}
6065

6166
/**
@@ -77,12 +82,18 @@ public function syncRelationshipAndFlushCache($relation, array $ids, $detaching
7782
// Flush the cache
7883
if (method_exists($this, 'flushModelCache')) {
7984
$this->flushModelCache();
80-
81-
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
82-
logger()->info("Cache flushed after sync operation for model: " . get_class($this));
85+
} else {
86+
if (method_exists($this->cacheableParent, 'flushCache')) {
87+
$this->cacheableParent->flushCache();
88+
} else {
89+
throw new \Exception('The parent model must have a flushCache() or flushModelCache() method defined. Make sure your model uses the HasCachedQueries trait. The ModelRelationships trait should be used in conjunction with the HasCachedQueries trait. See the documentation for more information.');
8390
}
8491
}
8592

93+
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
94+
logger()->info("Cache flushed after detach operation for model: " . get_class($this));
95+
}
96+
8697
return $result;
8798
}
8899

@@ -106,11 +117,17 @@ public function attachRelationshipAndFlushCache($relation, $ids, array $attribut
106117
// Flush the cache
107118
if (method_exists($this, 'flushModelCache')) {
108119
$this->flushModelCache();
109-
110-
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
111-
logger()->info("Cache flushed after attach operation for model: " . get_class($this));
120+
} else {
121+
if (method_exists($this->cacheableParent, 'flushCache')) {
122+
$this->cacheableParent->flushCache();
123+
} else {
124+
throw new \Exception('The parent model must have a flushCache() or flushModelCache() method defined. Make sure your model uses the HasCachedQueries trait. The ModelRelationships trait should be used in conjunction with the HasCachedQueries trait. See the documentation for more information.');
112125
}
113126
}
127+
128+
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
129+
logger()->info("Cache flushed after detach operation for model: " . get_class($this));
130+
}
114131
}
115132

116133
/**
@@ -132,12 +149,30 @@ public function detachRelationshipAndFlushCache($relation, $ids = null, $touch =
132149
// Flush the cache
133150
if (method_exists($this, 'flushModelCache')) {
134151
$this->flushModelCache();
135-
136-
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
137-
logger()->info("Cache flushed after detach operation for model: " . get_class($this));
152+
} else {
153+
if (method_exists($this->cacheableParent, 'flushCache')) {
154+
$this->cacheableParent->flushCache();
155+
} else {
156+
throw new \Exception('The parent model must have a flushCache() or flushModelCache() method defined. Make sure your model uses the HasCachedQueries trait. The ModelRelationships trait should be used in conjunction with the HasCachedQueries trait. See the documentation for more information.');
138157
}
139158
}
140159

160+
if (config('model-cache.debug_mode', false) && function_exists('logger')) {
161+
logger()->info("Cache flushed after detach operation for model: " . get_class($this));
162+
}
163+
141164
return $result;
142165
}
166+
167+
/**
168+
* Get the relationship name from the backtrace.
169+
*
170+
* @return string
171+
*/
172+
protected function guessBelongsToManyRelation()
173+
{
174+
list($one, $two, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
175+
176+
return $caller['function'];
177+
}
143178
}

0 commit comments

Comments
 (0)