vimatech/laravel-secure-fields
Laravel package that adds transparent encryption for sensitive database fields. Protect PII, tokens, and secrets with automatic encrypt/decrypt through Eloquent casts, no schema changes or query rewrites, plus configurable keys, key rotation, and searchable blind indexes.
Install and publish the config, then immediately confront the key requirement.
composer require vimatech/laravel-secure-fields
php artisan vendor:publish --tag="secure-fields-config"
The first thing you'll hit is SecureFieldsException::missingEncryptionKey() or missingHashKey(). This is intentional — the package refuses to silently fall back to APP_KEY. It wants a dedicated encryption key and a separate blind-index hash key. Generate two independent 32-byte secrets, store them in your secret manager, reference them through env vars in config/secure-fields.php, and add both to .env.example as empty strings so a fresh clone fails loudly rather than quietly.
After any env change, php artisan config:clear. The single most common "it still says missing key" report is a stale cached config — check that before anything else.
Where to read first, in order:
config/secure-fields.php — key names, cipher (AES-256-GCM), hash algorithm and output length.$secureFields property and/or a cast class. Both work; pick one and stay consistent.SecureFieldsException — it names the misconfiguration precisely; read the class, not the message.php artisan secure-fields:rotate --help — batch size, throttle, dry-run flags.First use case: encrypt one PII column you still need to look up. Don't start with the hardest field. Take users.phone or accounts.external_ref, because the canonical day-to-day scenario is encrypted at rest + exact-match lookup, and you want to feel both halves on day one.
TEXT, or a generously sized VARCHAR. VARCHAR(255) will truncate long JSON payloads, and you'll discover it during rotation, months later.phone_hash, fixed length (typically 64 hex chars), indexed — and UNIQUE where the field is semantically unique.$user = User::create(['phone' => '+15551234567']);
$user->phone; // '+15551234567'
DB::table('users')->value('phone'); // base64 ciphertext blob
DB::table('users')->value('phone_hash'); // 64-char hex
If the second DB line returns plaintext, the write path bypassed Eloquent. See the gotchas below before you ship anything.
Declare secure fields one way, everywhere. The package advertises a model property and a cast. Both work, but mixing them means grep stops being a reliable way to find encrypted attributes — which matters most during an incident or an audit. Use the property for a clean allowlist, or the cast when you already have a dense $casts array. Then add "declared secure fields?" to your PR checklist.
Writes stay invisible — but only at the model layer. create(), update(), mass assignment, and accessors all flow through the model lifecycle, so controllers and form requests don't change:
$user = User::create(['phone' => '+15551234567']); // fresh nonce
$user->update(['phone' => '+15559876543']); // re-encrypted, new nonce, new hash
That's the mental model to internalise: encryption is a model-layer concern; everything below the model sees raw bytes.
Blind-index search is the pattern you'll write weekly. Ciphertext is non-deterministic, so where('phone', $value) will never match. Persist a deterministic hash beside the ciphertext and query it:
User::where('phone_hash', SecureFields::hash('+15551234567'))->first();
Don't scatter that call across controllers. Bake it into the model so the hash key never leaks upward:
public static function findByPhone(string $phone): ?self
{
return static::where('phone_hash', SecureFields::hash($phone))->first();
}
public static function findOrCreateByPhone(string $phone): self
{
return static::findByPhone($phone) ?? static::create(['phone' => $phone]);
}
Never use firstOrCreate / updateOrCreate on a secure attribute. The lookup clause compiles to where phone = ? with plaintext, which never matches ciphertext — so every call inserts a new row. It fails silently and quietly produces duplicate accounts. This is the highest-value rule in this document; put it in your PR template.
Wrap the package behind one internal seam. Even in everyday code, route through a small adapter:
final class SecureSearch
{
public static function hash(string $field, string $value): string
{
return SecureFields::hash($value);
}
}
Now dozens of models and call sites depend on App\Support\SecureSearch instead of a zero-star package. When you pin, fork, upgrade, or migrate to native encrypted casts, you touch one file.
Boundary discipline is the part that actually gets you breached. Decrypted values sit on the attribute, which means they flow into toArray(), toJson(), API resources, queue payloads, log context, Telescope, and exception traces for free. Make raw access a deliberate act:
Str::mask($this->phone, '*', 3) — never the raw attribute.$hidden: your cheapest guardrail. Keep secure fields hidden by default and un-hide per resource only where genuinely required.Log::info('phone updated', ['user' => $user->id]) — never the value or the whole model.jobs table. Pass IDs and re-fetch in the handler, or use SerializesModels intentionally after verifying what lands in the payload.factory()->create()), never via raw inserts.Backfill hash columns with a chunked command, not SQL. No SQL expression produces the package's hash. Write an artisan command that chunks by ID, reads the decrypted attribute, and writes the hash:
User::whereNull('phone_hash')->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->forceFill(['phone_hash' => SecureSearch::hash('phone', $user->phone)])
->saveQuietly();
}
});
Run it after the encryption backfill, throttled, with a progress bar and an unreadable counter.
Key rotation should be a rehearsed runbook, not a hotfix. php artisan secure-fields:rotate processes in batches and reports rows it couldn't decrypt. The sequence that survives contact with production:
Test the storage layer, not just the property. Every feature test touching a secure field should assert three things:
$this->assertSame('+15551234567', $user->fresh()->phone);
$this->assertNotSame('+15551234567', DB::table('users')->value('phone'));
$this->assertDatabaseHas('users', ['phone_hash' => SecureSearch::hash('phone', '+15551234567')]);
Add one negative test per resource: hit the JSON endpoint and assert the plaintext does not appear in the body. That single test catches more real incidents than any encryption unit test.
Missing keys are not missing APP_KEY. missingEncryptionKey() / missingHashKey() fire because the dedicated secrets aren't set — deliberately, with no framework fallback. If they fire in CI or a preview environment, you forgot the secret, not the framework key. Both belong in your deployment checklist and CI secret store.
Mass updates write plaintext silently. User::query()->where(...)->update(['phone' => $plain]) does not run model casts. Neither do DB::table() writes, raw inserts, seeders, importers, upsert(), or insertOrIgnore(). All of these put plaintext into a column everyone downstream assumes is ciphertext. Grep for ->update( and DB:: against secured tables, and add a static-analysis or code-review rule. If you truly need a bulk path, decrypt-compute-re-encrypt through models in chunks.
Raw queries, replicas, BI tools, and dashboards all see ciphertext. That's correct behaviour but it breaks the usual toolbox: LIKE, ORDER BY on the field, SUM/AVG, joins on the column, whereBetween ranges. Encrypted fields are searchable only by exact match on the hash column — no prefix, no fuzzy, no range, no sorting. Design reporting around that, or export decrypted data to a purpose-built analytics store with its own access controls.
Unique constraints belong on the hash column. A UNIQUE index on the encrypted column is worthless — GCM output differs per row, so it will never fire. Put UNIQUE on phone_hash and enforce the invariant in the model as well, not the schema alone.
getOriginal() / isDirty() deserve a 10-minute test. Assert in your installed version whether getOriginal('phone') returns plaintext or ciphertext. Audit trails and observers that diff old-vs-new behave surprisingly when one side is ciphertext. Write the test before the observer.
No-op saves may still re-encrypt. If the field is decrypted on read, saving with an unchanged value can still generate new ciphertext (new nonce) and rewrite the hash. Guard observers with isDirty(), and know that "nothing changed" may still produce a write.
Blind indexes leak equality — by construction. Two users with the same phone produce the same hash. That's the accepted tradeoff. Two consequences: never derive the hash key from the encryption key (keep them independent), and note that a leaked hash key enables dictionary attacks against low-entropy fields like phone numbers and national IDs. For high-value low-entropy fields, consider a keyed HMAC over a normalised + salted representation, or accept the risk explicitly in writing.
Nested JSON: encrypt values, keep keys stable. When a column is also cast to array/json, the package encrypts the designated values inside the structure. You can still rely on the shape of the document, but you cannot filter or sort on an encrypted nested value — index anything queryable into its own top-level hash column.
Assert exit codes and exception classes, not message strings. v2.0.1 changed only the punctuation of missingEncryptionKey() / missingHashKey() messages and two lines of secure-fields:rotate output. Behaviour, exception classes, and exit codes were untouched — yet any script or health check grepping the text broke on a patch bump. Use instanceof, catch blocks, and $process->exitCode().
"Unreadable values" during rotation is a signal, not noise. Each one is a row you cannot decrypt: retired key, truncated column, or a plaintext row from a bypassed write path. Log the IDs, investigate, and re-run — don't declare victory because the batch finished. Also budget for restoring backups under an older key set long after rotation "completes."
Debugging checklist when a read returns a blob or null:
information_schema)config:cache stale?DB::table() call?Those five questions cover nearly every report.
Treat it as a small, security-critical dependency and design your exit. Zero stars, zero dependents, single maintainer, sensitive surface. Pin an exact version in composer.json ("vimatech/laravel-secure-fields": "2.0.1"), keep the internal adapter so you're never hard-coupled, read the diff of every patch release before upgrading — v2.0.1 is proof that patch releases can touch output your scripts depend on — and run the package's own test suite against your Laravel/PHP/DB matrix (MySQL strict mode, PostgreSQL TEXT defaults, SQLite in tests) before committing regulated data to it.
How can I help you explore Laravel packages today?