Skip to content

Commit 0ebf2e7

Browse files
Merge pull request #4 from ginkelsoft-development/develop
Develop
2 parents 623fe8a + f52405b commit 0ebf2e7

File tree

7 files changed

+228
-216
lines changed

7 files changed

+228
-216
lines changed

.github/workflows/tests.yml

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
name: Tests
22

3-
on:
4-
push:
5-
pull_request:
3+
on: [push, pull_request]
64

75
jobs:
86
test:
@@ -11,47 +9,41 @@ jobs:
119
fail-fast: false
1210
matrix:
1311
include:
14-
# Laravel 8–10 werkt nog op PHP 8.1
15-
- php: 8.1
16-
laravel: 8.*
12+
# Laravel 9 – PHP 8.1
1713
- php: 8.1
1814
laravel: 9.*
19-
- php: 8.1
20-
laravel: 10.*
15+
testbench: ^7.0
2116

22-
# Laravel 11 vereist minimaal PHP 8.2
17+
# Laravel 10 – PHP 8.2
2318
- php: 8.2
24-
laravel: 11.*
19+
laravel: 10.*
20+
testbench: ^8.0
2521

26-
# Laravel 12 werkt optimaal met PHP 8.3
22+
# Laravel 11 – PHP 8.3
2723
- php: 8.3
28-
laravel: 12.*
24+
laravel: 11.*
25+
testbench: ^9.0
2926

30-
name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }}
27+
# Laravel 12 – PHP 8.4 (nog geen Testbench release, gebruik voorlopig 10.x-dev)
28+
- php: 8.4
29+
laravel: 12.*
30+
testbench: 10.x-dev
3131

3232
steps:
33-
- name: Checkout code
34-
uses: actions/checkout@v4
33+
- uses: actions/checkout@v4
3534

36-
- name: Setup PHP
37-
uses: shivammathur/setup-php@v2
35+
- uses: shivammathur/setup-php@v2
3836
with:
3937
php-version: ${{ matrix.php }}
4038
extensions: mbstring, pdo, sqlite, bcmath, intl
4139
coverage: none
4240

43-
- name: Cache Composer dependencies
44-
uses: actions/cache@v4
45-
with:
46-
path: vendor
47-
key: composer-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('composer.lock') }}
48-
restore-keys: composer-
49-
50-
- name: Configure Laravel version
51-
run: composer require "illuminate/support:${{ matrix.laravel }}" "illuminate/database:${{ matrix.laravel }}" --no-update
52-
5341
- name: Install dependencies
54-
run: composer update --prefer-dist --no-interaction
42+
run: |
43+
composer require "illuminate/support:${{ matrix.laravel }}" \
44+
"illuminate/database:${{ matrix.laravel }}" \
45+
"orchestra/testbench:${{ matrix.testbench }}" --no-update
46+
composer update --prefer-dist --no-interaction
5547
56-
- name: Run PHPUnit tests
48+
- name: Run tests
5749
run: vendor/bin/phpunit --testdox --colors=always

LICENSE.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# MIT License
2+
3+
Copyright (c) 2025 Ginkelsoft
4+
https://ginkelsoft.com
5+
All rights reserved.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in
15+
all copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
THE SOFTWARE.

composer.json

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
11
{
22
"name": "ginkelsoft/laravel-encrypted-search-index",
3-
"description": "Encrypted and searchable index for Laravel models with deterministic hashing and prefix tokens.",
4-
"keywords": ["laravel", "encryption", "search", "privacy", "gdpr", "secure-index"],
5-
"type": "library",
3+
"description": "A lightweight Laravel package that provides privacy-preserving, encrypted fulltext and prefix search support for Eloquent models.",
4+
"keywords": [
5+
"laravel",
6+
"encrypted search",
7+
"eloquent",
8+
"privacy",
9+
"indexing",
10+
"ginkelsoft"
11+
],
612
"license": "MIT",
13+
"authors": [
14+
{
15+
"name": "Wietse van Ginkel",
16+
"email": "info@ginkelsoft.com",
17+
"homepage": "https://ginkelsoft.com",
18+
"role": "Developer"
19+
}
20+
],
21+
"require": {
22+
"php": "^8.1 || ^8.2 || ^8.3 || ^8.4",
23+
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0"
24+
},
25+
"require-dev": {
26+
"phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0",
27+
"orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0"
28+
},
729
"autoload": {
830
"psr-4": {
931
"Ginkelsoft\\EncryptedSearch\\": "src/"
1032
}
1133
},
34+
"autoload-dev": {
35+
"psr-4": {
36+
"Ginkelsoft\\EncryptedSearch\\Tests\\": "tests/"
37+
}
38+
},
1239
"extra": {
1340
"laravel": {
1441
"providers": [
1542
"Ginkelsoft\\EncryptedSearch\\EncryptedSearchServiceProvider"
1643
]
1744
}
1845
},
19-
"require": {
20-
"php": "^8.1",
21-
"illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0"
22-
},
23-
"require-dev": {
24-
"orchestra/testbench": "^7.0|^8.0|^9.0",
25-
"phpunit/phpunit": "^10.0|^11.0"
26-
},
2746
"minimum-stability": "stable",
2847
"prefer-stable": true
2948
}

src/Observers/SearchIndexObserver.php

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,38 @@
33
namespace Ginkelsoft\EncryptedSearch\Observers;
44

55
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\SoftDeletes;
67
use 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
*/
2831
class 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 payloadtypically 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

Comments
 (0)