Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Secure Fields Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/secure-fields.php — key names, cipher (AES-256-GCM), hash algorithm and output length.
  2. The model-facing surface — a $secureFields property and/or a cast class. Both work; pick one and stay consistent.
  3. SecureFieldsException — it names the misconfiguration precisely; read the class, not the message.
  4. 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.

  1. Widen the column first. Ciphertext is base64 of an AEAD payload — realistically 2–4× plaintext. Use TEXT, or a generously sized VARCHAR. VARCHAR(255) will truncate long JSON payloads, and you'll discover it during rotation, months later.
  2. Add the hash column in the same migration. phone_hash, fixed length (typically 64 hex chars), indexed — and UNIQUE where the field is semantically unique.
  3. Declare the field on the model.
  4. Verify both directions immediately:
$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.

Implementation Patterns

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:

  • API resources: emit a masked value — 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.
  • Logs: log presence or a hash prefix. Log::info('phone updated', ['user' => $user->id]) — never the value or the whole model.
  • Queues: serialising a model into a job writes plaintext into the jobs table. Pass IDs and re-fetch in the handler, or use SerializesModels intentionally after verifying what lands in the payload.
  • Seeders/importers: build records through models (or 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:

  1. Deploy app code that can decrypt both the current and next key version — every web node, queue worker, scheduled task, and read replica.
  2. Run the rotation throttled, off-peak, in batches. Never as one giant transaction.
  3. Watch the unreadable count. Non-zero means a retired key, a truncated column, or a plaintext row from a bypassed write path — investigate before proceeding.
  4. Confirm all rows are re-encrypted, then retire the old key. Keep it available far longer than feels necessary: restored backups and stale replicas still need it.

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.

Gotchas and Tips

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:

  1. Was the row written through Eloquent?
  2. Is the column wide enough? (check for truncation against information_schema)
  3. Is the key version that encrypted it still present in config?
  4. Is config:cache stale?
  5. Is this a raw query, a replica, or a 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums