webqamdev / encryptable-fields
Laravel Eloquent Fields Encryptable
Requires
- php: ^8.3
- ext-openssl: *
- laravel/framework: ^13.0
Requires (Dev)
- larastan/larastan: ^3.14
- laravel/pint: ^1.32
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0
- rector/rector: ^2.8
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 4.1.0
- 4.0.0
- 3.2.0
- 3.1.1
- 3.1.0
- 3.0.0
- 2.4.1
- 2.4.0
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.3
- 2.1.2
- 2.1.1
- 2.1.0
- 2.0.0
- 1.6.1
- 1.6.0
- 1.5.0
- 1.4
- 1.3.0
- 1.2.0
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-feat/interface
- dev-feat/pertinent-scopes
- dev-release/2.x
- dev-fix/mysql-compatibility
- dev-feat/laravel8-support
- dev-7-allow-laravel-7-and-laravel-8
This package is auto-updated.
Last update: 2026-11-10 07:39:47 UTC
README
Encrypt Eloquent model attributes at rest, and optionally store a hash of each value so you can still query it.
Tip
Upgrading from v3? Read the upgrade guide.
Requirements
- PHP 8.3+
- Laravel 13
- For searchable encrypted values: MySQL 8.0.21+ or MariaDB 10.2.3+ (see cipher choice)
- Optional: Filament 5, Backpack
Summary
[[TOC]]
Installation
Install the package via Composer:
composer require webqamdev/encryptable-fields
Publish the configuration file:
php artisan vendor:publish --provider="Webqamdev\EncryptableFields\EncryptableFieldsServiceProvider"
Hashed attributes are HMAC-SHA256 hashes. Add the following variable to your .env file:
APP_DB_HASH_KEY=
then generate the hash key:
php artisan encryptable-fields:key-generate --hash
Warning
Changing this key invalidates every stored hash. Keep it secret and backed up, like APP_KEY.
When a key is already set, encryptable-fields:key-generate asks for confirmation before overwriting it (the answer
defaults to no in non-interactive mode). Use --force to skip the confirmation, or --show to display a key without
writing it.
Usage
Use the EncryptableFields trait in your model and list encrypted attributes in the $encryptable property. The trait
registers an Eloquent cast for each of them.
To also store a hash of the plain value (useful for fast exact matches), use an associative entry: the encrypted attribute as key, its hash attribute as value.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Webqamdev\EncryptableFields\Models\Concerns\EncryptableFields; class User extends Model { use EncryptableFields; /** * The attributes that should be encrypted in database. * * @var array<int|string, string> */ protected array $encryptable = [ 'name', 'email' => 'email_hash', ]; }
Hash columns store a 64-character hexadecimal string:
$table->text('email')->nullable(); $table->char('email_hash', 64)->nullable()->index();
Create models as usual:
User::query()->create([ 'name' => 'John WATSON', 'email' => 'john.watson@example.com', ]);
nulland empty strings are stored asnull, in both encrypted and hash columns. Any other value, including'0', is encrypted and hashed.- Accessors defined on encrypted attributes (
firstname(): AttributeorgetFirstnameAttribute()) receive the decrypted value.
Find a model from a hashed value:
User::query()->where('email_hash', dbHashValue('john.watson@example.com'))->first();
or with the model's local scopes:
// Exact match on the hash column, throws NotHashedFieldException if the attribute has no hash column User::query()->whereHashed('name', 'John WATSON')->first(); // Exact match on the decrypted value with DatabaseEncryption, falls back to whereHashed() otherwise User::query()->whereEncrypted('name', 'John WATSON')->first();
With DatabaseEncryption, whereEncrypted() decrypts every row: prefer whereHashed() on large tables, hash columns
can be indexed.
Authentication
The package registers an eloquent-hashed auth provider, which authenticates users on a hashed attribute such as an
email. Use it in config/auth.php:
'providers' => [ 'users' => [ 'driver' => 'eloquent-hashed', 'model' => App\Models\User::class, ], ],
Searchable encrypted values
MySQL
and MariaDB both provide an aes_decrypt() function, which decrypts values
directly in SQL. It then becomes possible to filter or sort on encrypted values.
Values are then encrypted with a dedicated key and a cipher supported by your database server.
Cipher choice
APP_DB_ENCRYPTION_CIPHER |
Key size | Requires | Identical values |
|---|---|---|---|
aes-128-ecb (default) |
128 bits | MySQL 8.0.21+ / MariaDB 10.2.3+ | ⚠️ give identical encrypted values |
aes-256-cbc |
256 bits | MySQL 8.0.21+ / MariaDB 11.2+ | give different encrypted values |
Warning
With aes-128-ecb, anyone reading the database can tell which rows share the same value (and how many), without
decrypting them. Use aes-256-cbc as soon as your database server supports it. Changing the cipher requires a new
key and re-encrypting existing values.
Setup
-
Add the following variables to your
.envfile:APP_DB_ENCRYPTION_CIPHER=aes-128-ecb APP_DB_ENCRYPTION_KEY=
-
Generate a database encryption key matching the cipher:
php artisan encryptable-fields:key-generate
Don't generate this key on your own: its size depends on the cipher.
-
Register DatabaseEncryptionServiceProvider in
bootstrap/providers.php. It registers a DatabaseEncrypter singleton, which replaces Laravel's default encrypter for encrypted attributes:return [ App\Providers\AppServiceProvider::class, Webqamdev\EncryptableFields\Providers\DatabaseEncryptionServiceProvider::class, ];
-
Set the encryption service in
config/encryptable-fields.php:// Must implement EncryptionInterface 'encryption' => Webqamdev\EncryptableFields\Services\DatabaseEncryption::class,
The key is never written in SQL statements: before the first query using it, the package stores it in a MySQL session
variable (@encryptable_fields_key) with a prepared statement, outside of Laravel's query log, events and exceptions.
Note
The database general query log, when enabled, still records it.
Important
DatabaseEncrypter doesn't serialize values, so that exact values can be queried (= instead of LIKE). It can't
handle objects or arrays.
Querying decrypted values
Use the clauses returned by Webqamdev\EncryptableFields\Support\DB:
use Webqamdev\EncryptableFields\Support\DB; User::query()->whereRaw(DB::getClause('firstname').' = ?', ['watson'])->get(); User::query()->orderBy(DB::getRawClause('firstname'))->get();
Caution
A plain LIKE on an encrypted column silently returns nothing: the database only holds ciphertext. Hash columns
(e.g. firstname_hash) only allow exact matches.
searchEncrypted scope
Any model using EncryptableFields gets a searchEncrypted() scope: a LIKE on the decrypted value of one or
several columns (OR).
User::query()->searchEncrypted('wat', 'firstname')->get(); User::query()->searchEncrypted('wat', ['firstname', 'lastname'])->get();
- LIKE wildcards (
%,_,\) typed by the user are escaped. - Conditions are grouped, so they never leak into other
ORconditions of the query. - Columns not listed in
$encryptableare matched as is, so encrypted and plain columns can be mixed. - Case sensitivity follows the database collation.
Filament
Once searchable encrypted values are set up, Filament's table search, filters and
global search must decrypt columns in SQL: by default, they run a LIKE on the raw column.
Table search and sort
Override the query of searchable() and sortable():
use Filament\Tables\Columns\TextColumn; use Illuminate\Database\Eloquent\Builder; use Webqamdev\EncryptableFields\Support\DB; TextColumn::make('firstname') ->searchable(query: fn (Builder $query, string $search): Builder => $query->searchEncrypted($search, 'firstname')) ->sortable(query: fn (Builder $query, string $direction): Builder => $query->orderBy(DB::getRawClause('firstname'), $direction)),
Filament wraps each search closure in its own where/orWhere group, so the closure only adds its condition.
Custom filter
Search several encrypted columns from one input:
use Filament\Forms\Components\TextInput; use Filament\Tables\Filters\Filter; Filter::make('search') ->schema([ TextInput::make('search')->label('Search (first or last name)'), ]) ->query(fn (Builder $query, array $data): Builder => filled($data['search'] ?? null) ? $query->searchEncrypted($data['search'], ['firstname', 'lastname']) : $query) ->indicateUsing(fn (array $data): ?string => filled($data['search'] ?? null) ? 'Search: '.$data['search'] : null),
Global search
Add the SearchesEncryptedAttributes trait to the resource and declare the searched attributes:
use Filament\Resources\Resource; use Illuminate\Database\Eloquent\Model; use Webqamdev\EncryptableFields\Filament\Concerns\SearchesEncryptedAttributes; class UserResource extends Resource { use SearchesEncryptedAttributes; // Result title, decrypted by the model cast protected static ?string $recordTitleAttribute = 'firstname'; /** * @return string[] */ public static function getGloballySearchableAttributes(): array { return ['firstname', 'lastname']; } /** * @return array<string, string> */ public static function getGlobalSearchResultDetails(Model $record): array { return ['Last name' => $record->lastname]; } }
Without getGloballySearchableAttributes(), only the record title attribute is searched.
The trait replaces Filament's applyGlobalSearchAttributeConstraints() with searchEncrypted().
modifyGlobalSearchQuery() can't be used instead: it runs after Filament's LIKE on raw columns, with an AND,
which would still exclude every row.
Differences with Filament's default global search:
- Attributes are flat: relationship attributes (
relation.column) aren't supported. - The search term is not split into words:
jack watsonis matched as a whole. - Case sensitivity follows the database collation,
isGlobalSearchForcedCaseInsensitive()is ignored.
Backpack
For Laravel Backpack, the
EncryptedSearchTrait trait provides search and order
logics. encryptedSearchLogic() relies on the searchEncrypted scope, so the model must use
EncryptableFields.
use Illuminate\Database\Eloquent\Builder; CRUD::addColumn([ // ... 'searchLogic' => function (Builder $query, array $column, string $searchTerm): void { $this->encryptedSearchLogic($query, $column['name'], $searchTerm); }, 'orderLogic' => function (Builder $query, array $column, string $columnDirection): void { $this->encryptedOrderLogic($query, $column['name'], $columnDirection); }, ]);
Validation
Rules validate existence and uniqueness of a hashed or encrypted attribute. They extend
Illuminate\Validation\Rules\Exists and Illuminate\Validation\Rules\Unique.
| Rule | Checks |
|---|---|
Webqamdev\EncryptableFields\Rules\Exists\Hashed |
a row exists with the same hash |
Webqamdev\EncryptableFields\Rules\Unique\Hashed |
no row has the same hash |
Webqamdev\EncryptableFields\Rules\Exists\Encrypted |
a row exists with the same decrypted value |
Webqamdev\EncryptableFields\Rules\Unique\Encrypted |
no row has the same decrypted value |
Each rule takes a model class or a table name, then the attribute:
use Webqamdev\EncryptableFields\Rules\Unique\Hashed; public function rules(): array { return [ 'email' => [ new Hashed(User::class, 'email'), // or new Hashed('users', 'email'), ], ]; }
Activity log
With spatie/laravel-activitylog
or webqamdev/activity-logger-for-laravel, add the
HasEncryptableFieldsLog trait to each logged model: logs then contain encrypted values instead of decrypted ones.
Encrypting plain text values
If values were stored in plain text (encryption added to an existing column, misconfigured model, ...), encrypt and hash them once the model is configured:
php artisan encryptable-fields:migrate-legacy "App\Models\User" --from-plain
Values that are already encrypted are left untouched, so the command can be run several times.
Testing
composer test
Code is modernized with Rector, formatted with Laravel Pint and analysed with Larastan:
composer refactor composer format composer analyse
Searchable encrypted values tests only run against MySQL or MariaDB:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_DATABASE=testing DB_USERNAME=root DB_PASSWORD=secret \
APP_DB_ENCRYPTION_CIPHER=aes-256-cbc composer test
Changelog
See CHANGELOG for recent changes.
Contributing
See CONTRIBUTING for details.
Credits
License
The MIT License (MIT). See License File for more information.