Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ This design follows a **defense-in-depth** model: encrypted data stays secure, w

```bash
composer require ginkelsoft/laravel-encrypted-search-index
php artisan vendor:publish --tag=config
php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=config
php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearchServiceProvider" --tag=migrations
php artisan migrate
```

Expand Down
32 changes: 10 additions & 22 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,41 +1,29 @@
{
"name": "ginkelsoft/laravel-encrypted-search-index",
"description": "Encrypted and privacy-preserving search indexing for Laravel models.",
"description": "Encrypted and searchable index for Laravel models with deterministic hashing and prefix tokens.",
"keywords": ["laravel", "encryption", "search", "privacy", "gdpr", "secure-index"],
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Wietse van Ginkel",
"email": "info@ginkelsoft.com"
}
],
"require": {
"php": "^7.4 || ^8.0 || ^8.1 || ^8.2 || ^8.3",
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
"illuminate/database": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0"
},
"require-dev": {
"orchestra/testbench": "^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0",
"phpunit/phpunit": "^9.6 || ^10.0 || ^11.0"
},
"autoload": {
"psr-4": {
"Ginkelsoft\\EncryptedSearch\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Ginkelsoft\\EncryptedSearch\\Tests\\": "tests/",
"Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"Ginkelsoft\\EncryptedSearch\\EncryptedSearchServiceProvider"
]
}
},
"require": {
"php": "^8.1",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0"
},
"require-dev": {
"orchestra/testbench": "^7.0|^8.0|^9.0",
"phpunit/phpunit": "^10.0|^11.0"
},
"minimum-stability": "stable",
"prefer-stable": true
}
5 changes: 5 additions & 0 deletions src/EncryptedSearchServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Ginkelsoft\EncryptedSearch;

use Illuminate\Support\Facades\Event;
use Ginkelsoft\EncryptedSearch\Observers\SearchIndexObserver;
use Illuminate\Support\ServiceProvider;

/**
Expand Down Expand Up @@ -79,5 +81,8 @@ public function boot(): void
Console\RebuildIndex::class,
]);
}

// 🔹 Register the observer for all Eloquent events
Event::listen('eloquent.*: *', SearchIndexObserver::class);
}
}
119 changes: 119 additions & 0 deletions src/Observers/SearchIndexObserver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Ginkelsoft\EncryptedSearch\Observers;

use Illuminate\Database\Eloquent\Model;
use Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex;

/**
* Class SearchIndexObserver
*
* A global Eloquent event listener that automatically maintains
* encrypted search indexes for models using the
* {@see HasEncryptedSearchIndex} trait.
*
* This observer listens to all Eloquent model events via the wildcard pattern:
*
* Event::listen('eloquent.*: *', SearchIndexObserver::class);
*
* It reacts to model lifecycle events such as created, updated, saved,
* touched, restored, deleted, and forceDeleted.
*
* When a model using the trait is created, updated, or touched, the
* observer rebuilds its associated search tokens. When a model is
* deleted or force-deleted, the corresponding index entries are removed.
*
* @package Ginkelsoft\EncryptedSearch\Observers
*/
class SearchIndexObserver
{
/**
* Handles all Eloquent events emitted through the wildcard listener.
*
* @param string $event The Eloquent event name, e.g. "eloquent.saved: App\Models\Client".
* @param array $payload The event payload — typically contains the Model instance at index 0.
* @return void
*/
public function handle(string $event, array $payload): void
{
if (empty($payload[0]) || ! $payload[0] instanceof Model) {
return;
}

/** @var Model $model */
$model = $payload[0];

// Only process models that use the HasEncryptedSearchIndex trait
if (! $this->usesTrait($model, HasEncryptedSearchIndex::class)) {
return;
}

$eventLower = strtolower($event);

// Handle deletion events (deleted or forceDeleted)
if (str_contains($eventLower, 'deleted')) {
$this->removeIndex($model);
return;
}

// Handle write and restore events that require index rebuilding
if (
str_contains($eventLower, 'saved') ||
str_contains($eventLower, 'updated') ||
str_contains($eventLower, 'created') ||
str_contains($eventLower, 'touched') ||
str_contains($eventLower, 'restored')
) {
$this->rebuildIndex($model);
}
}

/**
* Determines whether the given model uses a specific trait.
*
* @param Model $model The model instance to inspect.
* @param string $traitFqcn The fully-qualified trait class name to check.
* @return bool
*/
protected function usesTrait(Model $model, string $traitFqcn): bool
{
$uses = class_uses_recursive($model);
return in_array($traitFqcn, $uses, true);
}

/**
* Rebuilds the search index for the given model.
*
* If the model defines the static method `updateSearchIndex`,
* it will be called directly. This method is typically defined
* in the {@see HasEncryptedSearchIndex} trait.
*
* @param Model $model The model instance to reindex.
* @return void
*/
protected function rebuildIndex(Model $model): void
{
if (method_exists($model, 'updateSearchIndex')) {
// @phpstan-ignore-next-line
$model::updateSearchIndex($model);
}
}

/**
* Removes all index entries for the given model.
*
* If the model defines the static method `removeSearchIndex`,
* it will be invoked to clear existing tokens associated with
* the model’s primary key.
*
* @param Model $model The model instance to remove from the index.
* @return void
*/
protected function removeIndex(Model $model): void
{
if (method_exists($model, 'removeSearchIndex')) {
// @phpstan-ignore-next-line
$model::removeSearchIndex($model);
}
}
}
20 changes: 14 additions & 6 deletions src/Traits/HasEncryptedSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,21 @@ trait HasEncryptedSearchIndex
*/
public static function bootHasEncryptedSearchIndex(): void
{
static::saved(function (Model $model): void {
static::updateSearchIndex($model);
});
// Rebuild index when model is created, updated or saved.
foreach (['created', 'updated', 'saved'] as $event) {
static::$event(function (Model $model) {
static::updateSearchIndex($model);
});
}

static::deleted(function (Model $model): void {
static::removeSearchIndex($model);
});
// Remove tokens when model is deleted or force-deleted
static::deleted(fn(Model $m) => static::removeSearchIndex($m));
static::forceDeleted(fn(Model $m) => static::removeSearchIndex($m));

// Optional: if SoftDeletes is used, re-index on restore
if (method_exists(static::class, 'restored')) {
static::restored(fn(Model $m) => static::updateSearchIndex($m));
}
}

/**
Expand Down
Loading