33namespace Ginkelsoft \EncryptedSearch \Observers ;
44
55use Illuminate \Database \Eloquent \Model ;
6+ use Illuminate \Database \Eloquent \SoftDeletes ;
67use Ginkelsoft \EncryptedSearch \Traits \HasEncryptedSearchIndex ;
78
89/**
910 * Class SearchIndexObserver
1011 *
11- * A global Eloquent event listener that automatically maintains
12- * encrypted search indexes for models using the
13- * {@see HasEncryptedSearchIndex} trait.
12+ * Synchronizes the encrypted search index with Eloquent model lifecycle events.
1413 *
15- * This observer listens to all Eloquent model events via the wildcard pattern:
14+ * This observer listens to all model-level Eloquent events and ensures that
15+ * the search index remains accurate after any create, update, delete,
16+ * or restore operation.
1617 *
17- * Event::listen('eloquent.*: *', SearchIndexObserver::class);
18+ * - Only acts on models that use the {@see HasEncryptedSearchIndex} trait.
19+ * - Handles soft-delete events ("restored", "forceDeleted") safely.
20+ * - Prevents Laravel from attempting to call non-existent methods on models
21+ * that do not use {@see SoftDeletes}.
1822 *
19- * It reacts to model lifecycle events such as created, updated, saved,
20- * touched, restored, deleted, and forceDeleted.
23+ * Typical use:
24+ * The service provider registers this observer globally:
2125 *
22- * When a model using the trait is created, updated, or touched, the
23- * observer rebuilds its associated search tokens. When a model is
24- * deleted or force-deleted, the corresponding index entries are removed.
26+ * Event::listen('eloquent.*: *', SearchIndexObserver::class);
2527 *
26- * @package Ginkelsoft\EncryptedSearch\Observers
28+ * The observer then determines at runtime whether the model supports each event
29+ * before performing index updates.
2730 */
2831class SearchIndexObserver
2932{
3033 /**
31- * Handles all Eloquent events emitted through the wildcard listener .
34+ * Handle an incoming Eloquent event for models using encrypted search .
3235 *
33- * @param string $event The Eloquent event name, e.g. "eloquent.saved: App\Models\Client".
34- * @param array $payload The event payload — typically contains the Model instance at index 0 .
36+ * @param string $event The full event name, e.g. "eloquent.saved: App\Models\Client".
37+ * @param array<int, mixed> $payload The event payload, typically [ Model $model] .
3538 * @return void
3639 */
3740 public function handle (string $ event , array $ payload ): void
@@ -43,77 +46,93 @@ public function handle(string $event, array $payload): void
4346 /** @var Model $model */
4447 $ model = $ payload [0 ];
4548
46- // Only process models that use the HasEncryptedSearchIndex trait
49+ // Only handle models that use the encrypted search trait
4750 if (! $ this ->usesTrait ($ model , HasEncryptedSearchIndex::class)) {
4851 return ;
4952 }
5053
5154 $ eventLower = strtolower ($ event );
55+ $ usesSoftDeletes = $ this ->usesTrait ($ model , SoftDeletes::class);
56+
57+ /**
58+ * Safety guard:
59+ * Some Laravel versions dispatch "forceDeleted" or "restored" events
60+ * even for models that do not use SoftDeletes.
61+ * Skip these events to prevent BadMethodCallException errors.
62+ */
63+ if (
64+ ! $ usesSoftDeletes &&
65+ (str_contains ($ eventLower , 'forcedeleted ' ) || str_contains ($ eventLower , 'restored ' ))
66+ ) {
67+ return ;
68+ }
69+
70+ // Remove index entries on delete or force delete
71+ if (str_contains ($ eventLower , 'forcedeleted ' )) {
72+ $ this ->safeRemoveIndex ($ model );
73+ return ;
74+ }
5275
53- // Handle deletion events (deleted or forceDeleted)
5476 if (str_contains ($ eventLower , 'deleted ' )) {
55- $ this ->removeIndex ($ model );
77+ $ this ->safeRemoveIndex ($ model );
5678 return ;
5779 }
5880
59- // Handle write and restore events that require index rebuilding
81+ // Rebuild index on save, update, create, touch, or restore
6082 if (
61- str_contains ($ eventLower , 'saved ' ) ||
83+ str_contains ($ eventLower , 'saved ' ) ||
6284 str_contains ($ eventLower , 'updated ' ) ||
6385 str_contains ($ eventLower , 'created ' ) ||
6486 str_contains ($ eventLower , 'touched ' ) ||
6587 str_contains ($ eventLower , 'restored ' )
6688 ) {
67- $ this ->rebuildIndex ($ model );
89+ $ this ->safeRebuildIndex ($ model );
6890 }
6991 }
7092
7193 /**
72- * Determines whether the given model uses a specific trait.
94+ * Determine whether a given model uses a specific trait, recursively .
7395 *
74- * @param Model $model The model instance to inspect.
75- * @param string $traitFqcn The fully-qualified trait class name to check.
96+ * @param \Illuminate\Database\Eloquent\ Model $model
97+ * @param string $traitFqcn
7698 * @return bool
7799 */
78100 protected function usesTrait (Model $ model , string $ traitFqcn ): bool
79101 {
80- $ uses = class_uses_recursive ($ model );
81- return in_array ($ traitFqcn , $ uses , true );
102+ return in_array ($ traitFqcn , class_uses_recursive ($ model ), true );
82103 }
83104
84105 /**
85- * Rebuilds the search index for the given model.
106+ * Rebuild the search index for the given model instance, ignoring exceptions .
86107 *
87- * If the model defines the static method `updateSearchIndex`,
88- * it will be called directly. This method is typically defined
89- * in the {@see HasEncryptedSearchIndex} trait.
90- *
91- * @param Model $model The model instance to reindex.
108+ * @param \Illuminate\Database\Eloquent\Model $model
92109 * @return void
93110 */
94- protected function rebuildIndex (Model $ model ): void
111+ protected function safeRebuildIndex (Model $ model ): void
95112 {
96- if (method_exists ($ model , 'updateSearchIndex ' )) {
97- // @phpstan-ignore-next-line
98- $ model ::updateSearchIndex ();
113+ try {
114+ if (method_exists ($ model , 'updateSearchIndex ' )) {
115+ $ model ->updateSearchIndex ();
116+ }
117+ } catch (\Throwable $ e ) {
118+ logger ()->warning ('[EncryptedSearch] Failed to rebuild index for ' . get_class ($ model ) . ': ' . $ e ->getMessage ());
99119 }
100120 }
101121
102122 /**
103- * Removes all index entries for the given model.
104- *
105- * If the model defines the static method `removeSearchIndex`,
106- * it will be invoked to clear existing tokens associated with
107- * the model’s primary key.
123+ * Remove search index entries for the given model instance, ignoring exceptions.
108124 *
109- * @param Model $model The model instance to remove from the index.
125+ * @param \Illuminate\Database\Eloquent\ Model $model
110126 * @return void
111127 */
112- protected function removeIndex (Model $ model ): void
128+ protected function safeRemoveIndex (Model $ model ): void
113129 {
114- if (method_exists ($ model , 'removeSearchIndex ' )) {
115- // @phpstan-ignore-next-line
116- $ model ::removeSearchIndex ();
130+ try {
131+ if (method_exists ($ model , 'removeSearchIndex ' )) {
132+ $ model ->removeSearchIndex ();
133+ }
134+ } catch (\Throwable $ e ) {
135+ logger ()->warning ('[EncryptedSearch] Failed to remove index for ' . get_class ($ model ) . ': ' . $ e ->getMessage ());
117136 }
118137 }
119138}
0 commit comments