yamut / laravel-redacted
Resolve Laravel config values from remote secret stores (AWS SSM, Secrets Manager, Azure Key Vault, GCP Secret Manager, HashiCorp Vault, Infisical, Doppler)
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- guzzlehttp/psr7: ^2.0
- illuminate/support: ^12.0
- vlucas/phpdotenv: ^5.6.1
Requires (Dev)
- aws/aws-sdk-php: ^3.0
- csharpru/vault-php: ^4.4
- google/cloud-secret-manager: ^2.0
- larastan/larastan: ^3.10
- laravel/octane: ^2.17
- orchestra/testbench: ^10.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^4.0
Suggests
- aws/aws-sdk-php: Required to use the SSM or ASM (Secrets Manager) drivers
- csharpru/vault-php: Required to use the HashiCorp Vault driver
- google/cloud-secret-manager: Required to use the GCP Secret Manager driver
- laravel/octane: Required to clear the static cache between Octane requests
README
Pull secrets from AWS SSM, Secrets Manager, Azure Key Vault, GCP Secret Manager, HashiCorp Vault, Infisical, and Doppler directly into your Laravel config files — using a single redacted() helper that works exactly like env().
// config/database.php 'password' => redacted('asm://prod/myapp/db#password', env('DB_PASSWORD')),
That's it. No middleware, no boot listeners, no service container gymnastics. Just drop it in your config file and move on with your life.
Table of Contents
- How it works
- Requirements
- Installation
- The URI format
- Drivers
- Configuration reference
- The three-layer cache
- Production: config:cache
- Artisan commands
- Testing
- Integration testing
- Octane and long-running processes
- Custom drivers
- Fallback values
- Type coercion
- What must stay in
.env - The #fragment syntax
How it works
The package hooks into Laravel's config loading phase. When your app boots, Laravel reads every file in config/ and evaluates them. redacted() intercepts calls during this phase and resolves values from whichever secret store you've configured, then returns the plaintext string to sit in the config array just like any other value.
The clever bit is what happens when you run php artisan config:cache. Laravel executes your config files once, calls redacted() for each secret, gets back the actual values, and bakes the whole resolved config into bootstrap/cache/config.php. From that point on, until you regenerate the cache, your app reads secrets from a flat PHP file — zero network calls, zero latency, zero API credentials needed on the server. This is the recommended production setup, and it's the reason this approach scales so cleanly.
For local development and environments where you can't or don't want to run config:cache, there's a three-layer caching system that keeps things snappy after the first resolution.
Requirements
- PHP 8.2+
- Laravel 12+
Installation
composer require yamut/laravel-redacted
The package auto-discovers itself via Laravel's package discovery. No need to add anything to config/app.php.
Publish the config file:
php artisan vendor:publish --tag=redacted-config
This drops config/redacted.php into your app. Open it and configure whichever drivers you plan to use.
The URI format
Every redacted() call takes a URI as its first argument:
{scheme}://{path}[#{json_key}]
The scheme identifies the driver. The path is whatever the driver uses to locate the secret. The #fragment is optional and extracts a specific key from a JSON blob (more on that below).
Here's what each driver's URI looks like in practice:
| Driver | Example URI |
|---|---|
| AWS SSM | ssm:///prod/myapp/db_password |
| AWS Secrets Manager | asm://prod/myapp/db |
| ASM with JSON key | asm://prod/myapp/db#password |
| Azure Key Vault | akv://my-vault/stripe-key |
| GCP Secret Manager | gcp://my-secret |
| HashiCorp Vault | vault://secret/myapp/stripe#secret_key |
| Infisical | infisical://DATABASE_URL |
| Doppler | doppler://DATABASE_URL |
| Env var | env://DB_HOST |
| In-memory (tests) | array://some-key |
The triple-slash thing: SSM paths conventionally start with a / (e.g. /prod/myapp/key). Standard URIs treat ssm://host/path as host + path, so to represent a path that itself starts with /, you need ssm:///prod/myapp/key — three slashes total. The parser handles this correctly on PHP 8.2+.
Drivers
AWS SSM Parameter Store
Reads from AWS Systems Manager Parameter Store. SecureString parameters are always decrypted automatically.
Config:
'ssm' => [ 'driver' => 'ssm', 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'key' => env('AWS_ACCESS_KEY_ID'), // optional 'secret' => env('AWS_SECRET_ACCESS_KEY'), // optional ],
Omit key and secret entirely to use IAM role credentials, ECS task roles, EC2 instance profiles — whatever's in your credential chain. The SDK will figure it out. Explicit credentials are only needed if you're not running on AWS infrastructure.
Usage:
// SSM path /prod/myapp/db_password — triple-slash required for paths with leading / redacted('ssm:///prod/myapp/db_password') // Without leading slash (less common, but valid SSM paths exist): redacted('ssm://prod/myapp/db_password')
Prefetching: The redacted:cache command fetches SSM parameters in batches of 10 (the SSM API limit for GetParameters). If a parameter doesn't exist, that slot comes back as null silently — SSM doesn't throw for missing names in batch mode, which is actually quite considerate of them.
AWS Secrets Manager
Reads from AWS Secrets Manager. The killer feature here is JSON blob secrets — store a bunch of related credentials as a single JSON object and pull individual fields with the #fragment syntax.
Config:
'asm' => [ 'driver' => 'asm', 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), ],
Usage:
// A secret that contains a plain string value redacted('asm://prod/myapp/stripe-key') // A secret stored as JSON: {"host":"db.prod.internal","password":"hunter2","port":5432} // Pull individual fields: redacted('asm://prod/myapp/db#host') redacted('asm://prod/myapp/db#password') redacted('asm://prod/myapp/db#port')
Both #host and #password above resolve from the same single cached API call. See The #fragment syntax for the full explanation.
Binary secrets: If your secret is stored as SecretBinary (base64-encoded binary), it's decoded to a plain string automatically.
Prefetching: Uses BatchGetSecretValue (added to the SDK in 2023) when available, falls back to sequential GetSecretValue calls. Your secrets need not fear the upgrade.
Azure Key Vault
Reads from Azure Key Vault. One important thing to know upfront: AKV secret names can only contain alphanumerics and hyphens. No slashes, no underscores, no dots. If you're migrating from SSM where you had hierarchical paths, you'll need to flatten your naming scheme.
Config (service principal):
'akv' => [ 'driver' => 'akv', 'vault_url' => env('AZURE_KEY_VAULT_URL'), // https://myvault.vault.azure.net 'tenant_id' => env('AZURE_TENANT_ID'), 'client_id' => env('AZURE_CLIENT_ID'), 'client_secret' => env('AZURE_CLIENT_SECRET'), 'use_managed_identity'=> false, ],
Config (managed identity):
'akv' => [ 'driver' => 'akv', 'vault_url' => env('AZURE_KEY_VAULT_URL'), 'use_managed_identity'=> true, // tenant_id, client_id, client_secret not needed ],
Managed identity uses the Azure IMDS endpoint at 169.254.169.254 — standard stuff if you're running on Azure VMs, App Service, or AKS.
Usage:
// Secret named "stripe-secret-key" in your vault redacted('akv://myvault/stripe-secret-key') // The vault name in the URI is informational — the actual vault_url from config is used. // You can put anything there, or just repeat your vault name for clarity.
Important: vault_url must use https://. The driver will not enforce this for you, so double-check your config.
GCP Secret Manager
Reads from Google Cloud Secret Manager. Clean API, sensible design, Google's best infrastructure product in years.
Config:
'gcp' => [ 'driver' => 'gcp', 'project' => env('GOOGLE_CLOUD_PROJECT'), 'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'), // path to service account JSON ],
Omit credentials to use Application Default Credentials — works with gcloud auth application-default login locally, and with Workload Identity on GKE.
Usage:
// Simple secret name — resolves to latest version automatically redacted('gcp://stripe-secret-key') // Full resource name if you need a specific version redacted('gcp://projects/my-project/secrets/stripe-secret-key/versions/3')
HashiCorp Vault
Reads from HashiCorp Vault KV secrets engine. Supports both KV v1 and v2, and both token auth and AppRole.
Config (token auth, KV v2):
'vault' => [ 'driver' => 'vault', 'address' => env('VAULT_ADDR', 'https://vault.example.com'), 'token' => env('VAULT_TOKEN'), 'auth' => 'token', 'kv_version' => 2, ],
Config (AppRole auth, KV v1):
'vault' => [ 'driver' => 'vault', 'address' => env('VAULT_ADDR', 'https://vault.example.com'), 'auth' => 'approle', 'role_id' => env('VAULT_ROLE_ID'), 'secret_id' => env('VAULT_SECRET_ID'), 'approle_mount' => 'approle', // default 'kv_version' => 1, ],
Usage:
// KV v2: mount is "secret", path is "myapp/stripe" // The driver automatically injects /data/ into the path for KV v2 redacted('vault://secret/myapp/stripe') // Extract a specific key from the Vault secret (which is always a map) redacted('vault://secret/myapp/stripe#secret_key') redacted('vault://secret/myapp/stripe#public_key')
KV v2 path rewriting: Vault KV v2 requires /data/ after the mount name in API calls. The driver handles this transparently. If your mount is secret and your path is myapp/stripe, the API call goes to secret/data/myapp/stripe. You don't need to think about this.
A note on vault_url: Default is https://vault.example.com. Make absolutely sure your address starts with https:// in production. Sending Vault tokens over plaintext HTTP is an unambiguous security incident.
Infisical
Reads from Infisical, the open-source secrets management platform. Uses Universal Auth (clientId + clientSecret → JWT access token, cached per-process).
Config:
'infisical' => [ 'driver' => 'infisical', 'client_id' => env('INFISICAL_CLIENT_ID'), 'client_secret' => env('INFISICAL_CLIENT_SECRET'), 'workspace_id' => env('INFISICAL_WORKSPACE_ID'), 'environment' => env('INFISICAL_ENVIRONMENT', 'prod'), 'base_url' => env('INFISICAL_URL', 'https://us.infisical.com'), ],
For EU cloud, set base_url to https://eu.infisical.com. For self-hosted Infisical, point base_url at your instance.
Usage:
// Secret named DATABASE_URL in your Infisical workspace redacted('infisical://DATABASE_URL')
The access token is fetched once per process and refreshed automatically before expiry. Your secrets are fetched via Infisical's v3 API with the workspace ID and environment from config.
Doppler
Reads from Doppler, the secrets manager for developer teams.
Config:
'doppler' => [ 'driver' => 'doppler', 'token' => env('DOPPLER_TOKEN'), // service token, not personal token 'project' => env('DOPPLER_PROJECT'), 'config' => env('DOPPLER_CONFIG', 'prd'), ],
Use a service token, not a personal API token. Service tokens are scoped to a specific project + config and are the right credential for production use.
Usage:
// Secret named DATABASE_URL in your Doppler project/config redacted('doppler://DATABASE_URL')
Prefetching: Doppler has a bulk download endpoint (/v3/configs/config/secrets/download?format=json) that returns all secrets in one call as a flat JSON object. The redacted:cache command uses this automatically — one API call, all your secrets, done. Individual get() calls hit the per-secret endpoint.
Env driver
Wraps getenv(). Mostly useful as the default driver for local development — you keep secrets in .env and let the driver pull them through the redacted() interface, so your config files don't need to know whether you're running locally or against a real secret store.
'env' => ['driver' => 'env'],
redacted('env://DB_HOST') // equivalent to getenv('DB_HOST')
Note: an env var explicitly set to empty string is treated as not-found (returns null / fallback). A var that isn't set at all also returns null. Both are consistent with how you'd generally expect a "missing" value to behave.
Array driver
In-memory driver. Pre-loaded with whatever values you give it. Primarily for testing, but occasionally useful for seeding known values in a local/CI environment.
'array' => [ 'driver' => 'array', 'values' => [ 'some-key' => 'some-value', ], ],
redacted('array://some-key')
In tests, you'll typically use Redacted::fake() rather than configuring this directly — see the Testing section.
Configuration reference
Publish and open config/redacted.php. The full structure:
return [ // The default driver to use when the scheme in a URI doesn't match any configured driver. // In practice, you'll usually use explicit schemes everywhere, but this is the fallback. 'default' => env('REDACTED_DRIVER', 'env'), 'drivers' => [ 'ssm' => [ 'driver' => 'ssm', 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'key' => env('AWS_ACCESS_KEY_ID'), // omit to use credential chain 'secret' => env('AWS_SECRET_ACCESS_KEY'), // omit to use credential chain ], 'asm' => [ 'driver' => 'asm', 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), ], 'akv' => [ 'driver' => 'akv', 'vault_url' => env('AZURE_KEY_VAULT_URL'), // https://myvault.vault.azure.net 'tenant_id' => env('AZURE_TENANT_ID'), 'client_id' => env('AZURE_CLIENT_ID'), 'client_secret' => env('AZURE_CLIENT_SECRET'), 'use_managed_identity' => false, ], 'gcp' => [ 'driver' => 'gcp', 'project' => env('GOOGLE_CLOUD_PROJECT'), 'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'), // omit for ADC ], 'vault' => [ 'driver' => 'vault', 'address' => env('VAULT_ADDR', 'https://vault.example.com'), 'token' => env('VAULT_TOKEN'), 'auth' => 'token', // 'token' or 'approle' 'role_id' => env('VAULT_ROLE_ID'), 'secret_id' => env('VAULT_SECRET_ID'), 'approle_mount' => 'approle', 'kv_version' => 2, // 1 or 2 ], 'infisical' => [ 'driver' => 'infisical', 'client_id' => env('INFISICAL_CLIENT_ID'), 'client_secret' => env('INFISICAL_CLIENT_SECRET'), 'workspace_id' => env('INFISICAL_WORKSPACE_ID'), 'environment' => env('INFISICAL_ENVIRONMENT', 'prod'), 'base_url' => env('INFISICAL_URL', 'https://us.infisical.com'), ], 'doppler' => [ 'driver' => 'doppler', 'token' => env('DOPPLER_TOKEN'), 'project' => env('DOPPLER_PROJECT'), 'config' => env('DOPPLER_CONFIG', 'prd'), ], 'env' => ['driver' => 'env'], 'array' => ['driver' => 'array', 'values' => []], ], 'cache' => [ // Which Laravel cache store to use for Layer 2 caching. // 'file' works fine for single-server setups. // 'redis' is recommended for multi-server or Octane deployments. 'store' => env('REDACTED_CACHE_STORE', 'file'), // How long resolved values are cached in the Laravel cache store, in seconds. // Irrelevant if you're using config:cache in production (values are baked in at cache time). 'ttl' => 3600, // Prefix for all cache keys. If you have multiple apps sharing a cache store, // set a unique prefix per app to avoid collisions. 'prefix' => 'redacted:', ], // How many characters of a secret to show in redacted:list output. // The rest is replaced with asterisks. 'mask_length' => 4, ];
The three-layer cache
When config:cache isn't in play (local dev, dynamic resolution), resolved values travel through three cache layers before hitting the remote store:
Layer 1 — Static process cache
Resolver::$cache is a plain PHP static array keyed by {scheme}:{path}. It's checked first on every call, costs nothing, and persists for the lifetime of the PHP process. Once a secret is resolved, it's free to access for the rest of that request (and every subsequent request in the same worker process under FPM or Octane).
Cleared by php artisan redacted:clear --static or Resolver::clearStaticCache() in your code.
Layer 2 — Laravel cache store
Configured by cache.store and cache.ttl. Uses whatever cache store you've configured — file, Redis, Memcached, whatever. Survives worker recycling, deploy restarts (if on a shared store), and anything else that kills the static cache. The redacted:cache command bulk-populates this layer. Lazy population happens on the first driver call for a key.
Cache keys look like: {prefix}{scheme}:{path} → redacted:ssm:/prod/myapp/db_password
Cleared (for specific keys) by php artisan redacted:clear.
Layer 3 — Remote driver
The actual API call. Only reached if both caches miss. On success, the value is written back to both Layer 1 and Layer 2.
The cache key uses {scheme}:{path} without the #fragment. This is intentional: asm://prod/db#host and asm://prod/db#password share a single cached blob (one API call), with key extraction applied on every read. Efficient.
Production: config:cache
This is the recommended production workflow:
php artisan config:cache
Laravel evaluates all your config files, calls redacted() for each secret, and writes the resolved values to bootstrap/cache/config.php. After this, your app reads config from that file — no drivers, no cache stores, no network calls. The resolved values are just there.
The upside: Zero runtime overhead, zero API credentials needed on the web server, zero latency. From Laravel's perspective, there's no difference between a config value that came from env() and one that came from redacted().
The deployment workflow:
# During deployment, before going live: php artisan config:cache # Rotate a secret? Regenerate the cache: php artisan config:cache # Need to force-refresh without a full deploy: php artisan config:clear && php artisan config:cache
What redacted:cache is for: The redacted:cache Artisan command pre-warms the Laravel cache store (Layer 2). Use it if you're running without config:cache — for example, in an environment where config is dynamic, or during early bootstrapping before config:cache has run. It batch-fetches all secrets it can find by scanning your config files for redacted() calls.
php artisan redacted:cache # warm the cache php artisan redacted:cache --dry-run # see what would be fetched without fetching
Artisan commands
redacted:cache
Scans your config files for redacted() calls, batch-fetches the secrets from each driver, and writes the values to the configured cache store.
php artisan redacted:cache php artisan redacted:cache --dry-run
The --dry-run flag shows you what would be fetched — paths, drivers, current cache status — without making any API calls or writing to cache. Good for CI sanity checks.
The command groups paths by scheme and calls each driver's prefetch() method, which means batch API calls wherever the driver supports it (SSM, ASM, Doppler all do). Your quota will thank you.
redacted:clear
Clears cached values for all redacted() calls found in your config files.
php artisan redacted:clear # clears Layer 2 (Laravel cache) for known keys php artisan redacted:clear --static # also clears the in-process static cache (Layer 1)
This is a targeted clear — it scans your config files to find the exact cache keys to remove, rather than flushing your entire cache store.
redacted:list
Lists all redacted() calls found in your config files, their resolution status, and (optionally) their resolved values.
php artisan redacted:list php artisan redacted:list --reveal # show actual values (first N chars unmasked, rest ***) php artisan redacted:list --driver=ssm # filter to a specific driver
Output shows: the URI, which driver handles it, the value (masked by default), whether it's currently in cache, and which file/line it was found in.
Testing
Using fake()
The primary testing pattern is Redacted::fake(), which replaces real drivers with an in-memory map for the duration of a test.
use Yamut\Redacted\Facades\Redacted; Redacted::fake([ 'ssm:///prod/myapp/app-key' => 'test-app-key', 'asm://prod/myapp/db#host' => '127.0.0.1', 'asm://prod/myapp/db#password' => 'test-password', 'vault://secret/stripe#key' => 'sk_test_abc123', 'doppler://API_KEY' => 'test-api-key', ]);
fake() handles the #fragment grouping automatically — asm://prod/myapp/db#host and asm://prod/myapp/db#password are stored as a single JSON blob under prod/myapp/db, exactly as a real ASM driver would return them.
Important: fake() works during both phases of app booting:
- Post-boot: When
redacted()is called in application code after the service provider has registered. - Early-boot (config loading): When
redacted()is called inside config files duringLoadConfiguration, before service providers run. This is the typical use case, and it's handled by registering fake drivers in a static registry that the resolver checks before anything else.
TestCase setup
Here's the base TestCase you should use for any test that touches redacted():
use Orchestra\Testbench\TestCase as BaseTestCase; use Yamut\Redacted\RedactedServiceProvider; use Yamut\Redacted\Resolution\Resolver; abstract class TestCase extends BaseTestCase { protected function getPackageProviders($app): array { return [RedactedServiceProvider::class]; } protected function getEnvironmentSetUp($app): void { // Use the array driver and array cache store so tests never hit real infrastructure $app['config']->set('redacted.default', 'array'); $app['config']->set('redacted.cache.store', 'array'); } protected function setUp(): void { parent::setUp(); Resolver::clearStaticCache(); } protected function tearDown(): void { Resolver::clearStaticCache(); $this->app->forgetInstance('redacted'); // reset the Manager singleton between tests parent::tearDown(); } }
The clearStaticCache() calls in setUp and tearDown are load-bearing. The static cache is process-level — if one test populates it and the next test doesn't clear it, you'll get the wrong value with no warning. Don't skip them.
The forgetInstance('redacted') in tearDown resets the Manager singleton between tests, which is necessary if you're using fake() — otherwise the fake drivers from test A will bleed into test B.
Full test example
use Yamut\Redacted\Facades\Redacted; class MyFeatureTest extends TestCase { public function test_database_config_resolves_from_fake(): void { Redacted::fake([ 'asm://prod/myapp/db#host' => 'test-db.local', 'asm://prod/myapp/db#password' => 'test-password', ]); $this->assertSame('test-db.local', redacted('asm://prod/myapp/db#host')); $this->assertSame('test-password', redacted('asm://prod/myapp/db#password')); } public function test_falls_back_when_secret_is_missing(): void { Redacted::fake([]); // empty fake — nothing resolves $this->assertSame('fallback-value', redacted('ssm:///prod/missing', 'fallback-value')); } public function test_closure_fallback(): void { Redacted::fake([]); $result = redacted('ssm:///prod/missing', fn() => 'computed-fallback'); $this->assertSame('computed-fallback', $result); } }
Integration testing
The package ships with an integration test suite that runs against real infrastructure. These tests are excluded from the default composer test run — they only run when you explicitly invoke them and have the required credentials in your environment.
Running the suite
composer test:integration
Without credentials, all integration tests skip automatically (no failures, no errors). With credentials, they make real API calls.
SSM integration tests
The test uses the SDK credential chain — no explicit key/secret required. Set AWS_PROFILE for SSO or named profiles, or omit it entirely when running on AWS infrastructure with an IAM role.
# SSO / named profile AWS_PROFILE=your-sso-profile \ AWS_DEFAULT_REGION=us-east-1 \ REDACTED_TEST_SSM_PATH=//your/param/path \ composer test:integration # IAM role / instance profile (e.g. on EC2 or ECS) — no credentials needed at all AWS_DEFAULT_REGION=us-east-1 \ REDACTED_TEST_SSM_PATH=//your/param/path \ composer test:integration
REDACTED_TEST_SSM_PATH is the URI suffix for a parameter that exists in your account. Use double-slash prefix for absolute SSM paths (which becomes ssm:///your/param/path). The tests verify that an existing parameter resolves to a non-empty value, that a non-existent parameter returns null, and that a non-existent parameter with a fallback returns that fallback.
Adding integration tests for other drivers
Extend IntegrationTestCase, declare requiredEnv(), and override getEnvironmentSetUp() to configure the driver with real credentials.
class AsmDriverTest extends IntegrationTestCase { protected function requiredEnv(): array { return ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'REDACTED_TEST_ASM_SECRET']; } protected function getEnvironmentSetUp($app): void { parent::getEnvironmentSetUp($app); $app['config']->set('redacted.default', 'asm'); $app['config']->set('redacted.drivers.asm', [ 'driver' => 'asm', 'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', 'key' => getenv('AWS_ACCESS_KEY_ID'), 'secret' => getenv('AWS_SECRET_ACCESS_KEY'), ]); } #[Test] public function it_resolves_a_secret_from_asm(): void { $name = getenv('REDACTED_TEST_ASM_SECRET'); $value = redacted("asm://{$name}"); $this->assertNotNull($value); } }
IntegrationTestCase::setUp() calls markTestSkipped() for the first missing env var it finds, which prevents the test from running (and prevents getEnvironmentSetUp() from being called with null credential values).
Test suite separation
composer test runs only Unit and Feature suites — integration tests are never included in CI unless you explicitly add the credentials and call composer test:integration.
composer test # Unit + Feature only — safe for CI without cloud creds composer test:integration # Integration only — requires real credentials
The #fragment syntax
When a secret store holds a JSON blob, you can extract specific keys without multiple API calls.
Suppose your ASM secret prod/myapp/db contains:
{
"host": "db.prod.internal",
"port": "5432",
"name": "myapp_production",
"username": "myapp",
"password": "hunter2"
}
You can pull individual fields:
// config/database.php 'pgsql' => [ 'host' => redacted('asm://prod/myapp/db#host'), 'port' => redacted('asm://prod/myapp/db#port'), 'database' => redacted('asm://prod/myapp/db#name'), 'username' => redacted('asm://prod/myapp/db#username'), 'password' => redacted('asm://prod/myapp/db#password'), ],
One API call. All five redacted() calls share a single cached fetch of the blob. The fragment key is applied locally after decoding. The cache key is {scheme}:{path} — the #fragment is intentionally excluded so the blob is cached once and reused.
This works with any driver that returns JSON: ASM (naturally), HashiCorp Vault (KV secrets are always maps), and any custom driver that returns a JSON string from get().
If the raw value isn't valid JSON, or if the requested key doesn't exist in the decoded object, the fallback is returned.
Fragment values keep their JSON types as-is — they are not passed through the type coercion applied to plain values.
Fallback values
The second argument to redacted() is the fallback — returned when the secret can't be resolved for any reason (not found, driver error, network timeout, misconfiguration).
// Static fallback redacted('ssm:///prod/myapp/key', 'default-value') // Closure fallback — only called if the secret can't be resolved redacted('ssm:///prod/myapp/key', fn() => computeExpensiveDefault()) // Chain with env() for local development redacted('ssm:///prod/myapp/db-password', env('DB_PASSWORD'))
The closure form is useful when computing the fallback has side effects or is expensive — the closure is only invoked if it's actually needed.
On failures: The resolver catches all exceptions internally. A network outage, an expired credential, a malformed response — all of these fall through to the fallback silently. This is intentional: you don't want a transient API hiccup to crash your app boot. The tradeoff is that misconfiguration can be quiet. If something isn't resolving and you don't know why, redacted:list is your first debugging stop.
Type coercion
Plain (non-fragment) values pass through the same type coercion Laravel applies to env() — literally the same code path, Illuminate\Support\Env, applied to the string the driver returns:
| Stored value (case-insensitive) | Resolves to |
|---|---|
true / (true) |
true (bool) |
false / (false) |
false (bool) |
null / (null) |
null |
empty / (empty) |
'' (empty string) |
"quoted" / 'quoted' |
quoted (surrounding quotes stripped) |
| anything else | the string, unchanged |
This makes redacted() a drop-in replacement for env() in config files: a parameter stored as the string true behaves exactly like SOMETHING=true in .env would. It also covers stores like SSM Parameter Store that can't hold empty values — store the literal string empty (or null) to represent them.
Two things to be aware of:
- A secret stored as the literal string
nullresolves tonull, not to your fallback. This matchesenv(), where the default applies only when the variable is unset — not when it's set tonull. - Fragment-extracted values are never coerced. JSON is natively typed: in
{"flag": true, "label": "true"},#flagis already a real boolean, and#labelis the string"true"and stays a string. Coercion only applies to plain string values, where there's no type system to consult.
What must stay in .env
Not everything can or should go through redacted(). A handful of variables must remain as real environment variables — either in .env or set by your infrastructure — because of when and how Laravel reads them.
APP_ENV
Laravel's DetectEnvironment bootstrapper reads this directly from .env before config files are loaded. There is no way to intercept it with redacted() — the value is already fixed by the time any config file runs. Must be in .env.
APP_KEY
Can technically be provided via redacted(), but requires caution. If the remote fetch fails at any point before the static cache is warm — first deploy, credential misconfiguration, transient network issue — the resolved key will be null. Laravel will then throw RuntimeException: No application encryption key has been specified. the first time anything touches encryption (sessions, cookies, encrypted models).
Always chain env('APP_KEY') as a fallback:
// config/app.php 'key' => redacted('ssm:///prod/myapp/app-key', env('APP_KEY')),
This way .env covers the bootstrap case and the remote store is the authoritative source once the cache is warm.
Driver credentials
The variables that authenticate your secret store — AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, VAULT_TOKEN, DOPPLER_TOKEN, INFISICAL_CLIENT_SECRET, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, AZURE_CLIENT_ID, GOOGLE_CLOUD_PROJECT — must be in .env. The driver needs them to construct itself. If they were behind redacted(), the driver couldn't be bootstrapped to fetch them. Hard circular dependency.
Cache store credentials
If you set REDACTED_CACHE_STORE=redis, the Redis credentials (REDIS_PASSWORD, REDIS_URL) fall under the same rule as driver credentials above: keep them in .env. If they're themselves managed via redacted(), the package can't authenticate to write the cache, so every request falls through to the remote store. The resolver catches the error so it won't break, but it's a silent performance regression — every request hits your secret store API instead of cache.
The fix is to move those variables back to .env. If you'd rather isolate the redacted cache from your app's Redis entirely, use a dedicated Redis instance or ACL user for it that requires no password.
Switching the redacted cache to file also sidesteps the auth error, but think about it before reaching for it as a fix on its own: Laravel's file cache driver writes values unencrypted to storage/framework/cache/data. That's the same trust boundary as .env and bootstrap/cache/config.php (both already contain your secrets in plaintext on disk), so it's not a new exposure for this package — but it is a second on-disk plaintext copy of every cached secret, on top of whatever config:cache already produces, so don't pick it as a workaround for a credential that should just be in .env in the first place.
Octane and long-running processes
If you're running Laravel Octane (Swoole, RoadRunner, FrankenPHP), be aware of how the static cache behaves.
The in-process static cache (Resolver::$cache) persists across requests within the same worker. This is intentional and generally desirable — you don't want to re-hit your secret store on every request. But it means:
Secret rotation doesn't take effect immediately. If you rotate a credential in Vault or SSM, the in-process cached value stays stale until the worker is recycled. Under FPM this is fine since workers are short-lived; under Octane they can run for hours.
Mitigations:
Option 1: Use config:cache in production. The static cache becomes irrelevant because redacted() is never called at runtime.
Option 2: Use a short TTL in the Laravel cache store and a shared store (Redis), and arrange for workers to be recycled periodically. Workers that restart will miss the static cache and fall through to Layer 2.
Option 3: Register a listener to clear the static cache on each request:
// In a service provider's boot() method use Illuminate\Foundation\Http\Events\RequestHandled; use Yamut\Redacted\Resolution\Resolver; $this->app['events']->listen(RequestHandled::class, function () { Resolver::clearStaticCache(); });
This trades the performance benefit of the static cache for freshness. Fine for low-traffic apps; think twice for high-throughput ones.
Multi-server deployments: Use cache.store: redis (or any shared cache store) for Layer 2. With a file cache, each server has its own cache and you can't warm them all with one redacted:cache command.
Custom drivers
You can add your own driver by implementing DriverInterface and registering it via the extend() method on the Manager.
The interface:
namespace Yamut\Redacted\Contracts; interface DriverInterface { public function get(string $path): ?string; public function prefetch(array $paths): array; // path => value|null public function flush(): void; }
Implementation:
use Yamut\Redacted\Drivers\AbstractDriver; class MyVaultDriver extends AbstractDriver { public function get(string $path): ?string { // $this->config contains your driver's config block from redacted.php $apiKey = $this->config['api_key'] ?? throw new \RuntimeException('api_key required'); // ... fetch the secret ... return $value; // null if not found } // prefetch() defaults to N sequential get() calls from AbstractDriver. // Override it if your store has a batch endpoint. // flush() defaults to a no-op. Override to close connections, clear tokens, etc. }
Registration:
// In a service provider use Yamut\Redacted\Facades\Redacted; Redacted::extend('myvault', function ($app) { $config = $app['config']->get('redacted.drivers.myvault', []); return new MyVaultDriver($config); });
// config/redacted.php 'drivers' => [ // ... other drivers ... 'myvault' => [ 'driver' => 'myvault', 'api_key' => env('MYVAULT_API_KEY'), 'url' => env('MYVAULT_URL'), ], ],
Then use myvault://path/to/secret in your config files.
Early-boot note: Custom drivers registered via extend() are only available after the service provider has registered. If redacted() is called during config loading (before service providers run), custom drivers won't be available and the resolver will fall back to the default driver from config. This is the same behavior as the built-in drivers — nothing special to worry about unless you're doing something unusual.
License
MIT