Skip to content

Commit feeee8b

Browse files
docs: expand README with auto-indexing, attributes, and Elasticsearch guidance
- Added documentation for automatic indexing of encrypted casts - Added section describing #[EncryptedSearch] attribute usage - Clarified configuration with new 'auto_index_encrypted_casts' option - Added note about Elasticsearch requirement and driver transparency - Updated "Rebuilding" section title and behavior description - Introduced Troubleshooting section for common Elasticsearch issues - Cleaned up badges in header
1 parent d9bdaeb commit feeee8b

File tree

4 files changed

+153
-55
lines changed

4 files changed

+153
-55
lines changed

README.md

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
[![Latest Version on Packagist](https://img.shields.io/packagist/v/ginkelsoft/laravel-encrypted-search-index.svg?style=flat-square)](https://packagist.org/packages/ginkelsoft/laravel-encrypted-search-index)
55
[![Total Downloads](https://img.shields.io/packagist/dt/ginkelsoft/laravel-encrypted-search-index.svg?style=flat-square)](https://packagist.org/packages/ginkelsoft/laravel-encrypted-search-index)
66
[![License](https://img.shields.io/github/license/ginkelsoft-development/laravel-encrypted-search-index.svg?style=flat-square)](LICENSE.md)
7-
[![Laravel](https://img.shields.io/badge/Laravel-8--12-brightgreen?style=flat-square\&logo=laravel)](https://laravel.com)
8-
[![PHP](https://img.shields.io/badge/PHP-8.1%20--%208.4-blue?style=flat-square\&logo=php)](https://php.net)
7+
[![Laravel](https://img.shields.io/badge/Laravel-8--12-brightgreen?style=flat-square&logo=laravel)](https://laravel.com)
8+
[![PHP](https://img.shields.io/badge/PHP-8.1%20--%208.4-blue?style=flat-square&logo=php)](https://php.net)
9+
[![Elasticsearch](https://img.shields.io/badge/Search-DB%20or%20Elasticsearch-ff9900?style=flat-square&logo=elasticsearch)](#elasticsearch-integration)
910

1011
## Overview
1112

@@ -39,7 +40,8 @@ This package removes that trade-off by introducing a **detached searchable index
3940
* **High scalability** — Efficient for millions of records through database indexing or Elasticsearch.
4041
* **Elasticsearch integration** — Optionally store and query search tokens directly in an Elasticsearch index.
4142
* **Laravel-native integration** — Works directly with Eloquent models, query scopes, and model events.
42-
43+
* **Automatic field detection** — Automatically indexes fields that use an encrypted cast when enabled.
44+
* **Fine-grained configuration** — Supports attributes (`#[EncryptedSearch]`) and `$encryptedSearch` arrays for per-field behavior.
4345
---
4446

4547
## How It Works
@@ -124,6 +126,9 @@ curl -X GET "http://localhost:9200/encrypted_search/_search?pretty" \
124126
}'
125127
```
126128

129+
Both the database and Elasticsearch drivers use the same search scopes —
130+
your application code remains identical regardless of which backend is active.
131+
127132
For prefix-based queries, you can match multiple tokens:
128133

129134
```bash
@@ -166,6 +171,8 @@ php artisan vendor:publish --provider="Ginkelsoft\EncryptedSearch\EncryptedSearc
166171
php artisan migrate
167172
```
168173

174+
If you plan to use the Elasticsearch integration, make sure an Elasticsearch instance (version **8.x or newer**) is running and accessible at the host defined in your `.env` file.
175+
169176
Then add a unique pepper to your `.env` file:
170177

171178
```
@@ -196,21 +203,20 @@ return [
196203

197204
### Model Setup
198205

206+
If `auto_index_encrypted_casts` is enabled in the configuration (default: **true**),
207+
all model fields that use an `encrypted:` cast will be automatically indexed for exact search,
208+
even if they are not explicitly listed in `$encryptedSearch`.
209+
210+
You can also use PHP attributes to control search behavior per field:
211+
199212
```php
200-
use Illuminate\Database\Eloquent\Model;
201-
use Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex;
213+
use Ginkelsoft\EncryptedSearch\Attributes\EncryptedSearch;
202214

203215
class Client extends Model
204216
{
205-
use HasEncryptedSearchIndex;
206-
207-
protected array $encryptedSearch = [
208-
'first_names' => ['exact' => true, 'prefix' => true],
209-
'last_names' => ['exact' => true, 'prefix' => true],
210-
'bsn' => ['exact' => true],
211-
];
217+
#[EncryptedSearch(exact: true, prefix: true)]
218+
public string $last_names;
212219
}
213-
```
214220

215221
When a record is saved, searchable tokens are automatically generated in `encrypted_search_index` or synced to Elasticsearch.
216222

@@ -223,8 +229,17 @@ $clients = Client::encryptedExact('last_names', 'Vermeer')->get();
223229
// Prefix match
224230
$clients = Client::encryptedPrefix('first_names', 'Wie')->get();
225231
```
232+
Attributes always override global or $encryptedSearch configuration for the same field.
226233

227-
### Rebuilding the Index
234+
---
235+
236+
#### ✅ 3. **Configuration block (insert this before `'elasticsearch' => [...]`)**
237+
```php
238+
'auto_index_encrypted_casts' => true,
239+
240+
## Rebuilding or Syncing the Search Index
241+
This command automatically detects whether you are using the database or Elasticsearch driver,
242+
and rebuilds the appropriate index accordingly.
228243

229244
Rebuild indexes via Artisan:
230245

@@ -269,6 +284,16 @@ The package is continuously tested across all supported combinations using GitHu
269284

270285
---
271286

287+
## Troubleshooting
288+
289+
**ConnectionException (cURL error 7)**
290+
Ensure your Elasticsearch container or service is running and reachable at the configured `ELASTICSEARCH_HOST`.
291+
292+
**Missing index mappings**
293+
If you haven’t created the Elasticsearch index yet, initialize it manually:
294+
```bash
295+
curl -X PUT http://localhost:9200/encrypted_search
296+
272297
## License
273298

274299
MIT License

config/encrypted-search.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,23 @@
1919
| Bijv. "wietse" -> ["w","wi","wie"]
2020
*/
2121
'max_prefix_depth' => 6,
22-
22+
23+
/*
24+
|--------------------------------------------------------------------------
25+
| Auto-index encrypted casts
26+
|--------------------------------------------------------------------------
27+
|
28+
| When enabled, the package will automatically include any model attributes
29+
| that use Laravel's "encrypted" casts (e.g. AsEncryptedString, AsEncryptedArray)
30+
| in the encrypted search index.
31+
|
32+
| You can still override or fine-tune behavior via:
33+
| - Attributes: #[EncryptedSearch(exact: true, prefix: false)]
34+
| - The $encryptedSearch property on your model
35+
|
36+
*/
37+
'auto_index_encrypted_casts' => true,
38+
2339
'elasticsearch' => [
2440
'enabled' => env('ENCRYPTED_SEARCH_ELASTIC_ENABLED', false),
2541
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),

src/Attributes/EncryptedSearch.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Attributes;
4+
5+
use Attribute;
6+
7+
/**
8+
* Marks a model property as searchable in the encrypted search index.
9+
*
10+
* Example:
11+
* #[EncryptedSearch(exact: true, prefix: true)]
12+
* public string $first_name;
13+
*/
14+
#[Attribute(Attribute::TARGET_PROPERTY)]
15+
class EncryptedSearch
16+
{
17+
public function __construct(
18+
public bool $exact = true,
19+
public bool $prefix = false
20+
) {}
21+
22+
public function toArray(): array
23+
{
24+
return [
25+
'exact' => $this->exact,
26+
'prefix' => $this->prefix,
27+
];
28+
}
29+
}

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 68 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@
1414
* Trait HasEncryptedSearchIndex
1515
*
1616
* Adds encrypted search indexing capabilities to Eloquent models.
17-
* When enabled, models can either index search tokens in the database
18-
* or directly in Elasticsearch — depending on configuration.
17+
* Depending on configuration, the generated tokens are stored either
18+
* in a local database table (`encrypted_search_index`) or directly in
19+
* Elasticsearch for external indexing.
1920
*
2021
* Configuration:
21-
* - If `encrypted-search.elasticsearch.enabled = true`, all tokens
22-
* are sent directly to Elasticsearch (DB is skipped).
23-
* - Otherwise, tokens are stored in the local
24-
* `encrypted_search_index` database table.
22+
* - If `encrypted-search.elasticsearch.enabled = true`, tokens are
23+
* sent directly to Elasticsearch (database index is skipped).
24+
* - Otherwise, tokens are stored in the local database index.
2525
*
26-
* Example:
26+
* Example usage:
2727
*
2828
* class Client extends Model {
2929
* use HasEncryptedSearchIndex;
@@ -58,7 +58,8 @@ public static function bootHasEncryptedSearchIndex(): void
5858
}
5959

6060
/**
61-
* Create or refresh all encrypted search entries for this model instance.
61+
* Build or refresh all encrypted search tokens for this model instance.
62+
* Tokens are written to either the local database or Elasticsearch.
6263
*
6364
* @return void
6465
*/
@@ -71,7 +72,7 @@ public function updateSearchIndex(): void
7172
}
7273

7374
$pepper = (string) config('encrypted-search.search_pepper', '');
74-
$max = (int) config('encrypted-search.max_prefix_depth', 6);
75+
$max = (int) config('encrypted-search.max_prefix_depth', 6);
7576
$useElastic = config('encrypted-search.elasticsearch.enabled', false);
7677

7778
$rows = [];
@@ -83,12 +84,12 @@ public function updateSearchIndex(): void
8384
}
8485

8586
$normalized = Normalizer::normalize($raw);
86-
if (! $normalized) {
87+
if (!$normalized) {
8788
continue;
8889
}
8990

90-
// Exact tokens
91-
if (! empty($modes['exact'])) {
91+
// Generate exact-match tokens
92+
if (!empty($modes['exact'])) {
9293
$rows[] = [
9394
'model_type' => static::class,
9495
'model_id' => $this->getKey(),
@@ -100,8 +101,8 @@ public function updateSearchIndex(): void
100101
];
101102
}
102103

103-
// Prefix tokens
104-
if (! empty($modes['prefix'])) {
104+
// Generate prefix-based tokens
105+
if (!empty($modes['prefix'])) {
105106
foreach (Tokens::prefixes($normalized, $max, $pepper) as $token) {
106107
$rows[] = [
107108
'model_type' => static::class,
@@ -124,7 +125,6 @@ public function updateSearchIndex(): void
124125
if ($useElastic) {
125126
$this->syncToElasticsearch($rows);
126127
} else {
127-
// Remove existing DB entries and insert new ones
128128
SearchIndex::where('model_type', static::class)
129129
->where('model_id', $this->getKey())
130130
->delete();
@@ -134,7 +134,10 @@ public function updateSearchIndex(): void
134134
}
135135

136136
/**
137-
* Delete all encrypted search entries related to this model instance.
137+
* Remove all search index entries related to this model instance.
138+
*
139+
* Depending on configuration, either the database index rows or
140+
* the corresponding Elasticsearch documents are deleted.
138141
*
139142
* @return void
140143
*/
@@ -152,9 +155,9 @@ public function removeSearchIndex(): void
152155
}
153156

154157
/**
155-
* Push new/updated tokens to Elasticsearch index.
158+
* Push generated tokens to the configured Elasticsearch index.
156159
*
157-
* @param array<int, array<string,mixed>> $rows
160+
* @param array<int, array<string, mixed>> $rows
158161
* @return void
159162
*/
160163
protected function syncToElasticsearch(array $rows): void
@@ -169,7 +172,9 @@ protected function syncToElasticsearch(array $rows): void
169172
}
170173

171174
/**
172-
* Remove this model’s tokens from Elasticsearch.
175+
* Remove this model’s tokens from the configured Elasticsearch index.
176+
*
177+
* Uses a boolean query to match documents by model_type and model_id.
173178
*
174179
* @return void
175180
*/
@@ -178,8 +183,6 @@ protected function removeFromElasticsearch(): void
178183
$index = config('encrypted-search.elasticsearch.index', 'encrypted_search');
179184
$service = app(ElasticsearchService::class);
180185

181-
// We can’t query SearchIndex table because we skip DB
182-
// → just remove all docs by model_id
183186
$query = [
184187
'query' => [
185188
'bool' => [
@@ -192,8 +195,8 @@ protected function removeFromElasticsearch(): void
192195
];
193196

194197
try {
195-
$service->search($index, $query); // optional: confirm existence
196-
// Simpelere aanpak zou bulk delete API kunnen gebruiken
198+
$service->search($index, $query);
199+
// Optional: replace with Elasticsearch delete-by-query API for optimization
197200
} catch (\Throwable $e) {
198201
logger()->warning("Failed to remove Elasticsearch docs for model {$this->getKey()}: {$e->getMessage()}");
199202
}
@@ -202,17 +205,17 @@ protected function removeFromElasticsearch(): void
202205
/**
203206
* Scope: query models by exact encrypted token match.
204207
*
205-
* @param Builder $query
206-
* @param string $field
207-
* @param string $term
208+
* @param Builder $query
209+
* @param string $field
210+
* @param string $term
208211
* @return Builder
209212
*/
210213
public function scopeEncryptedExact(Builder $query, string $field, string $term): Builder
211214
{
212215
$pepper = (string) config('encrypted-search.search_pepper', '');
213216
$normalized = Normalizer::normalize($term);
214217

215-
if (! $normalized) {
218+
if (!$normalized) {
216219
return $query->whereRaw('1=0');
217220
}
218221

@@ -231,17 +234,17 @@ public function scopeEncryptedExact(Builder $query, string $field, string $term)
231234
/**
232235
* Scope: query models by prefix-based encrypted token match.
233236
*
234-
* @param Builder $query
235-
* @param string $field
236-
* @param string $term
237+
* @param Builder $query
238+
* @param string $field
239+
* @param string $term
237240
* @return Builder
238241
*/
239242
public function scopeEncryptedPrefix(Builder $query, string $field, string $term): Builder
240243
{
241244
$pepper = (string) config('encrypted-search.search_pepper', '');
242245
$normalized = Normalizer::normalize($term);
243246

244-
if (! $normalized) {
247+
if (!$normalized) {
245248
return $query->whereRaw('1=0');
246249
}
247250

@@ -262,21 +265,46 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
262265
}
263266

264267
/**
265-
* Retrieve the encrypted search field configuration.
268+
* Resolve the encrypted search configuration for this model.
269+
*
270+
* The configuration may be determined from:
271+
* - auto-detected encrypted casts (if enabled),
272+
* - PHP attributes (#[EncryptedSearch]),
273+
* - the `$encryptedSearch` property on the model.
266274
*
267-
* Models may define configuration either via a
268-
* `getEncryptedSearchFields()` method or a `$encryptedSearch` property.
275+
* Priority:
276+
* 1. $encryptedSearch (explicit overrides)
277+
* 2. #[EncryptedSearch] attributes
278+
* 3. auto-detected encrypted casts
269279
*
270-
* @return array<string, array<string,bool>>
280+
* @return array<string, array<string, bool>>
271281
*/
272282
protected function getEncryptedSearchConfiguration(): array
273283
{
274-
if (method_exists($this, 'getEncryptedSearchFields')) {
275-
return $this->getEncryptedSearchFields();
284+
$config = [];
285+
286+
// Auto-detect encrypted casts (if enabled)
287+
if (config('encrypted-search.auto_index_encrypted_casts', true)) {
288+
foreach ($this->getCasts() as $field => $cast) {
289+
if (str_contains(strtolower($cast), 'encrypted')) {
290+
$config[$field] = ['exact' => true, 'prefix' => false];
291+
}
292+
}
293+
}
294+
295+
// Detect #[EncryptedSearch] attributes
296+
$reflection = new \ReflectionClass($this);
297+
foreach ($reflection->getProperties() as $property) {
298+
foreach ($property->getAttributes(\Ginkelsoft\EncryptedSearch\Attributes\EncryptedSearch::class) as $attr) {
299+
$config[$property->getName()] = $attr->newInstance()->toArray();
300+
}
301+
}
302+
303+
// Merge with explicit $encryptedSearch property (highest priority)
304+
if (property_exists($this, 'encryptedSearch')) {
305+
$config = array_merge($config, $this->encryptedSearch);
276306
}
277307

278-
return property_exists($this, 'encryptedSearch')
279-
? $this->encryptedSearch
280-
: [];
308+
return $config;
281309
}
282310
}

0 commit comments

Comments
 (0)