A simple, extensible PHP package for redacting sensitive data from strings and revealing them later.
$cloaked = cloak('Email me at john@example.com');
// "Email me at {{EMAIL_x7k2m9_1}}"
$original = uncloak($cloaked);
// "Email me at john@example.com"composer require dynamik-dev/cloak-php- PHP 8.2+
- ext-mbstring (required by libphonenumber)
$text = 'Contact: john@example.com, Phone: 555-123-4567';
$cloaked = cloak($text);
// "Contact: {{EMAIL_x7k2m9_1}}, Phone: {{PHONE_x7k2m9_1}}"
$original = uncloak($cloaked);
// "Contact: john@example.com, Phone: 555-123-4567"use DynamikDev\Cloak\Detector;
// Only detect emails
$cloaked = cloak($text, [Detector::email()]);
// Multiple detectors
$cloaked = cloak($text, [
Detector::email(),
Detector::phone('US'),
Detector::ssn(),
]);For more control, use the Cloak class directly:
use DynamikDev\Cloak\Cloak;
$cloak = Cloak::make();
$cloaked = $cloak->cloak($text);
$original = $cloak->uncloak($cloaked);use DynamikDev\Cloak\Cloak;
use DynamikDev\Cloak\Detector;
use DynamikDev\Cloak\Encryptors\OpenSslEncryptor;
$cloak = Cloak::make()
->withDetectors([Detector::email()])
->encrypt(OpenSslEncryptor::generateKey());
$cloaked = $cloak->cloak('Sensitive: john@example.com');Cloak provides several built-in detectors for common sensitive data types:
use DynamikDev\Cloak\Detector;
Detector::email(); // Email addresses
Detector::phone('US'); // Phone numbers (specify region code)
Detector::ssn(); // Social Security Numbers (XXX-XX-XXXX)
Detector::creditCard(); // Credit card numbers
Detector::all(); // All built-in detectors (uses US for phone)Phone detection uses libphonenumber-for-php for robust international phone number validation with intelligent false positive prevention.
Features:
- International format support for all countries
- Validates actual phone numbers (not just digit patterns)
- Filters out order IDs, timestamps, serial numbers, etc.
- Handles various formats:
(212) 456-7890,212-456-7890,+44 117 496 0123
Examples:
// US numbers
Detector::phone('US')->detect('Call 212-456-7890');
// UK numbers
Detector::phone('GB')->detect('Ring 0117 496 0123');
// International format
Detector::phone()->detect('Call +44 117 496 0123');
// Filters false positives
Detector::phone('US')->detect('Order #123456789012'); // Not detectedCloak supports two approaches for custom detectors: factory methods (concise) and direct instantiation (explicit).
use DynamikDev\Cloak\Detector;
use DynamikDev\Cloak\Detectors\Pattern;
// Factory method (concise)
$detector = Detector::pattern('/\b[A-Z]{2}\d{6}\b/', 'passport');
// Direct instantiation (explicit)
$detector = new Pattern('/\b[A-Z]{2}\d{6}\b/', 'passport');
$cloaked = $cloak->cloak('Passport: AB123456', [$detector]);
// "Passport: {{PASSPORT_x7k2m9_1}}"use DynamikDev\Cloak\Detector;
use DynamikDev\Cloak\Detectors\Words;
// Factory method
$detector = Detector::words(['password', 'secret'], 'sensitive');
// Direct instantiation
$detector = new Words(['password', 'secret'], 'sensitive');
$cloaked = $cloak->cloak('The password is secret123', [$detector]);
// "The {{SENSITIVE_x7k2m9_1}} is {{SENSITIVE_x7k2m9_2}}123"use DynamikDev\Cloak\Detector;
use DynamikDev\Cloak\Detectors\Callback;
// Factory method
$detector = Detector::using(function (string $text): array {
$matches = [];
if (preg_match_all('/\bAPI_KEY_\w+\b/', $text, $found)) {
foreach ($found[0] as $match) {
$matches[] = ['match' => $match, 'type' => 'api_key'];
}
}
return $matches;
});
// Direct instantiation
$detector = new Callback(function (string $text): array {
// ... same logic
});You can mix both patterns freely:
use DynamikDev\Cloak\Detector;
use DynamikDev\Cloak\Detectors\{Email, Pattern, Words};
$cloak->cloak($text, [
new Email(), // Direct
Detector::phone('US'), // Factory
new Pattern('/\b[A-Z]{2}\d{6}\b/', 'passport'), // Direct
Detector::words(['secret'], 'sensitive'), // Factory
]);For full control, implement the DetectorInterface:
use DynamikDev\Cloak\Contracts\DetectorInterface;
class IpAddressDetector implements DetectorInterface
{
public function detect(string $text): array
{
$matches = [];
$pattern = '/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/';
if (preg_match_all($pattern, $text, $found)) {
foreach ($found[0] as $match) {
$matches[] = ['match' => $match, 'type' => 'ip_address'];
}
}
return $matches;
}
}
$cloaked = $cloak->cloak('Server: 192.168.1.1', [new IpAddressDetector()]);
// "Server: {{IP_ADDRESS_x7k2m9_1}}"Use filters to exclude certain detections from being cloaked:
// Exclude test emails
$cloak = Cloak::make()
->filter(fn ($detection) => !str_ends_with($detection['match'], '@test.local'));
$text = 'prod@company.com and test@test.local';
$cloaked = $cloak->cloak($text, [Detector::email()]);
// "{{EMAIL_x7k2m9_1}} and test@test.local"Filters are applied in sequence, and all must return true for a detection to be included:
$cloak = Cloak::make()
->filter(fn ($d) => $d['type'] === 'email')
->filter(fn ($d) => !str_contains($d['match'], 'noreply'));Hook into the cloaking/uncloaking process:
$cloak = Cloak::make()
->beforeCloak(function (string $text) {
// Normalize whitespace before processing
return preg_replace('/\s+/', ' ', $text);
})
->afterCloak(function (string $original, string $cloaked) {
// Log the cloaking operation
logger()->info('Cloaked text', ['original_length' => strlen($original)]);
})
->beforeUncloak(function (string $text) {
// Validate before uncloaking
return $text;
})
->afterUncloak(function (string $text) {
// Post-process after uncloaking
return trim($text);
});Encrypt sensitive values at rest using the convenient encrypt() method:
use DynamikDev\Cloak\Encryptors\OpenSslEncryptor;
$cloak = Cloak::make()
->encrypt(OpenSslEncryptor::generateKey());
$cloaked = $cloak->cloak('Secret: john@example.com', [Detector::email()]);
// Values are encrypted in storage, but placeholders remain the sameEnvironment Variable Support:
// Reads from CLOAK_PRIVATE_KEY environment variable
$cloak = Cloak::make()->encrypt();
// Or specify a custom environment variable
$encryptor = new OpenSslEncryptor(null, 'MY_ENCRYPTION_KEY');
$cloak = Cloak::make()->withEncryptor($encryptor);Custom Encryptors:
For full control, use withEncryptor() with a custom implementation:
$customEncryptor = new MyEncryptor($key);
$cloak = Cloak::make()->withEncryptor($customEncryptor);By default, Cloak uses ArrayStore for in-memory storage. This is perfect for:
- Testing
- Single-request scenarios
- Simple use cases without persistence
use DynamikDev\Cloak\Stores\ArrayStore;
$store = new ArrayStore();
$cloak = Cloak::using($store);For persistent storage across requests, implement StoreInterface:
use DynamikDev\Cloak\Contracts\StoreInterface;
class RedisStore implements StoreInterface
{
public function __construct(
private Redis $redis,
private int $ttl = 3600
) {}
public function put(string $key, array $map): void
{
$this->redis->setex($key, $this->ttl, json_encode($map));
}
public function get(string $key): ?array
{
$data = $this->redis->get($key);
return $data ? json_decode($data, true) : null;
}
public function forget(string $key): void
{
$this->redis->del($key);
}
}
// Configure TTL via constructor
$cloak = Cloak::using(new RedisStore($redis, ttl: 7200));use DynamikDev\Cloak\Contracts\StoreInterface;
use Illuminate\Support\Facades\Cache;
class LaravelCacheStore implements StoreInterface
{
public function __construct(private int $ttl = 3600) {}
public function put(string $key, array $map): void
{
Cache::put($key, $map, $this->ttl);
}
public function get(string $key): ?array
{
return Cache::get($key);
}
public function forget(string $key): void
{
Cache::forget($key);
}
}For framework adapters (like Laravel, Symfony, etc.), you can override how Cloak::make() resolves instances using resolveUsing():
use DynamikDev\Cloak\Cloak;
// Set a custom resolver (typically in a service provider)
Cloak::resolveUsing(fn() => app(Cloak::class));
// Now Cloak::make() resolves from your container
$cloak = Cloak::make(); // Uses your container bindingThis allows framework packages to:
- Integrate with dependency injection containers
- Use framework-specific storage drivers
- Apply framework configuration automatically
- Let developers extend and customize via container bindings
Example Laravel Service Provider:
use DynamikDev\Cloak\Cloak;
use Illuminate\Support\ServiceProvider;
class CloakServiceProvider extends ServiceProvider
{
public function register()
{
// Bind Cloak to the container with your configuration
$this->app->bind(Cloak::class, function ($app) {
return Cloak::using($app->make(CacheStore::class))
->withDetectors(config('cloak.detectors', Detector::all()));
});
// Make helpers use container resolution
Cloak::resolveUsing(fn() => app(Cloak::class));
}
}Now developers can customize behavior through container bindings:
// In AppServiceProvider
$this->app->bind(Cloak::class, function ($app) {
return CustomCloak::using($app->make(CacheStore::class));
});Clearing the Resolver:
Cloak::clearResolver(); // Reverts to default behaviorCloak follows a compositional architecture, making it easy to extend with custom implementations.
Create custom placeholder formats by implementing PlaceholderGeneratorInterface:
use DynamikDev\Cloak\Contracts\PlaceholderGeneratorInterface;
use Ramsey\Uuid\Uuid;
class UuidPlaceholderGenerator implements PlaceholderGeneratorInterface
{
public function generate(array $detections): array
{
$key = Uuid::uuid4()->toString();
$map = [];
foreach ($detections as $detection) {
$uuid = Uuid::uuid4()->toString();
$placeholder = "[{$detection['type']}:{$uuid}]";
$map[$placeholder] = $detection['match'];
}
return ['key' => $key, 'map' => $map];
}
public function replace(string $text, array $map): string
{
foreach ($map as $placeholder => $original) {
$text = str_replace($original, $placeholder, $text);
}
return $text;
}
public function parse(string $text): array
{
// Extract [TYPE:UUID] placeholders and group by key
// Implementation details...
return [];
}
}
$cloak = Cloak::make()
->withPlaceholderGenerator(new UuidPlaceholderGenerator());Implement EncryptorInterface for custom encryption strategies:
use DynamikDev\Cloak\Contracts\EncryptorInterface;
class SodiumEncryptor implements EncryptorInterface
{
public function __construct(private string $key) {}
public function encrypt(string $value): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = sodium_crypto_secretbox($value, $nonce, $this->key);
return base64_encode($nonce . $encrypted);
}
public function decrypt(string $encrypted): string
{
$decoded = base64_decode($encrypted);
$nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
if ($decrypted === false) {
throw new \RuntimeException('Decryption failed');
}
return $decrypted;
}
}
$cloak = Cloak::make()
->withEncryptor(new SodiumEncryptor($key));Placeholders follow the format {{TYPE_KEY_INDEX}}:
TYPE: Uppercase detector type (EMAIL, PHONE, SSN, CREDIT_CARD)KEY: 6-character alphanumeric unique keyINDEX: Integer counter per type, starting at 1
Example: {{EMAIL_x7k2m9_1}}
The default format can be customized by implementing a custom PlaceholderGeneratorInterface.
Cloak::make(?StoreInterface $store = null): self
Cloak::using(StoreInterface $store): self->withDetectors(array $detectors): self
->filter(callable $callback): self
->withPlaceholderGenerator(PlaceholderGeneratorInterface $generator): self
->withEncryptor(EncryptorInterface $encryptor): self
->encrypt(?string $key = null): self // Convenience method for OpenSslEncryptor->beforeCloak(callable $callback): self
->afterCloak(callable $callback): self
->beforeUncloak(callable $callback): self
->afterUncloak(callable $callback): self->cloak(string $text, ?array $detectors = null): string
->uncloak(string $text): string- Same value appears multiple times: Reuses the same placeholder
- No detections found: Returns original text unchanged
- Missing cache on uncloak: Leaves placeholder in place
- Empty input: Returns empty string
- Overlapping patterns: All patterns are processed independently
./vendor/bin/pest./vendor/bin/phpstan analyseMIT
