Skip to content

Commit f7c195a

Browse files
feat: add global SearchIndexObserver to automatically maintain encrypted search indexes
This commit introduces a new global observer (`SearchIndexObserver`) that listens to all Eloquent model events via `Event::listen('eloquent.*: *', ...)`. The observer automatically rebuilds or removes search index entries for models using the `HasEncryptedSearchIndex` trait. Key changes: - Added `Ginkelsoft\EncryptedSearch\Observers\SearchIndexObserver` class - Handles `created`, `updated`, `saved`, `touched`, and `restored` events → reindex - Handles `deleted` and `forceDeleted` events → remove index - Added support for recursive trait detection via `class_uses_recursive` - Requires trait methods `updateSearchIndex()` and `removeSearchIndex()` to be public This ensures consistent and automatic synchronization between model lifecycle events and the encrypted search index, including `touch()` and soft delete operations.
1 parent 5fb4f12 commit f7c195a

File tree

4 files changed

+140
-7
lines changed

4 files changed

+140
-7
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ This design follows a **defense-in-depth** model: encrypted data stays secure, w
9393

9494
```bash
9595
composer require ginkelsoft/laravel-encrypted-search-index
96-
php artisan vendor:publish --tag=config
96+
php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=config
97+
php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=migrations
9798
php artisan migrate
9899
```
99100

src/EncryptedSearchServiceProvider.php

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

33
namespace Ginkelsoft\EncryptedSearch;
44

5+
use Illuminate\Support\Facades\Event;
6+
use Ginkelsoft\EncryptedSearch\Observers\SearchIndexObserver;
57
use Illuminate\Support\ServiceProvider;
68

79
/**
@@ -79,5 +81,8 @@ public function boot(): void
7981
Console\RebuildIndex::class,
8082
]);
8183
}
84+
85+
// 🔹 Register the observer for all Eloquent events
86+
Event::listen('eloquent.*: *', SearchIndexObserver::class);
8287
}
8388
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Observers;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex;
7+
8+
/**
9+
* Class SearchIndexObserver
10+
*
11+
* A global Eloquent event listener that automatically maintains
12+
* encrypted search indexes for models using the
13+
* {@see HasEncryptedSearchIndex} trait.
14+
*
15+
* This observer listens to all Eloquent model events via the wildcard pattern:
16+
*
17+
* Event::listen('eloquent.*: *', SearchIndexObserver::class);
18+
*
19+
* It reacts to model lifecycle events such as created, updated, saved,
20+
* touched, restored, deleted, and forceDeleted.
21+
*
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.
25+
*
26+
* @package Ginkelsoft\EncryptedSearch\Observers
27+
*/
28+
class SearchIndexObserver
29+
{
30+
/**
31+
* Handles all Eloquent events emitted through the wildcard listener.
32+
*
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.
35+
* @return void
36+
*/
37+
public function handle(string $event, array $payload): void
38+
{
39+
if (empty($payload[0]) || ! $payload[0] instanceof Model) {
40+
return;
41+
}
42+
43+
/** @var Model $model */
44+
$model = $payload[0];
45+
46+
// Only process models that use the HasEncryptedSearchIndex trait
47+
if (! $this->usesTrait($model, HasEncryptedSearchIndex::class)) {
48+
return;
49+
}
50+
51+
$eventLower = strtolower($event);
52+
53+
// Handle deletion events (deleted or forceDeleted)
54+
if (str_contains($eventLower, 'deleted')) {
55+
$this->removeIndex($model);
56+
return;
57+
}
58+
59+
// Handle write and restore events that require index rebuilding
60+
if (
61+
str_contains($eventLower, 'saved') ||
62+
str_contains($eventLower, 'updated') ||
63+
str_contains($eventLower, 'created') ||
64+
str_contains($eventLower, 'touched') ||
65+
str_contains($eventLower, 'restored')
66+
) {
67+
$this->rebuildIndex($model);
68+
}
69+
}
70+
71+
/**
72+
* Determines whether the given model uses a specific trait.
73+
*
74+
* @param Model $model The model instance to inspect.
75+
* @param string $traitFqcn The fully-qualified trait class name to check.
76+
* @return bool
77+
*/
78+
protected function usesTrait(Model $model, string $traitFqcn): bool
79+
{
80+
$uses = class_uses_recursive($model);
81+
return in_array($traitFqcn, $uses, true);
82+
}
83+
84+
/**
85+
* Rebuilds the search index for the given model.
86+
*
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.
92+
* @return void
93+
*/
94+
protected function rebuildIndex(Model $model): void
95+
{
96+
if (method_exists($model, 'updateSearchIndex')) {
97+
// @phpstan-ignore-next-line
98+
$model::updateSearchIndex($model);
99+
}
100+
}
101+
102+
/**
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.
108+
*
109+
* @param Model $model The model instance to remove from the index.
110+
* @return void
111+
*/
112+
protected function removeIndex(Model $model): void
113+
{
114+
if (method_exists($model, 'removeSearchIndex')) {
115+
// @phpstan-ignore-next-line
116+
$model::removeSearchIndex($model);
117+
}
118+
}
119+
}

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,21 @@ trait HasEncryptedSearchIndex
7575
*/
7676
public static function bootHasEncryptedSearchIndex(): void
7777
{
78-
static::saved(function (Model $model): void {
79-
static::updateSearchIndex($model);
80-
});
78+
// Rebuild index when model is created, updated or saved.
79+
foreach (['created', 'updated', 'saved'] as $event) {
80+
static::$event(function (Model $model) {
81+
static::updateSearchIndex($model);
82+
});
83+
}
8184

82-
static::deleted(function (Model $model): void {
83-
static::removeSearchIndex($model);
84-
});
85+
// Remove tokens when model is deleted or force-deleted
86+
static::deleted(fn(Model $m) => static::removeSearchIndex($m));
87+
static::forceDeleted(fn(Model $m) => static::removeSearchIndex($m));
88+
89+
// Optional: if SoftDeletes is used, re-index on restore
90+
if (method_exists(static::class, 'restored')) {
91+
static::restored(fn(Model $m) => static::updateSearchIndex($m));
92+
}
8593
}
8694

8795
/**

0 commit comments

Comments
 (0)