secretstack / laravel-vault
Centralized HashiCorp Vault secret management for Laravel services.
Requires
- php: ^8.2
- ext-json: *
- guzzlehttp/guzzle: ^7.0
- illuminate/console: ^9.0 || ^10.0 || ^11.0
- illuminate/contracts: ^9.0 || ^10.0 || ^11.0
- illuminate/encryption: ^9.0 || ^10.0 || ^11.0
- illuminate/support: ^9.0 || ^10.0 || ^11.0
- psr/log: ^3.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0 || ^9.0
- phpunit/phpunit: ^10.0 || ^11.0
README
Centralized HashiCorp Vault secret management for Laravel. Fetches a service's secrets from Vault once at boot and injects them into the environment so your existing
env()/config()calls keep working unchanged — zero per-request cost, Octane-safe, fail-closed in production.
Table of contents
- Why this exists
- Features
- How it works
- Requirements
- Installation
- Configuration
- Usage
- Artisan commands
- Production deployment
- Laravel Octane & long-running workers
- Caching
- Security model
- Extending: custom secret providers
- Stability & SemVer — the frozen consumer surface
- Architecture overview
- Development & testing
- Documentation & ADRs
- Contributing
- License
Why this exists
Most Laravel deployments ship secrets as a plaintext .env baked into the Docker image. That
means no rotation without a rebuild, no central audit, and a blast radius of "everything" the
moment an image leaks.
laravel-vault moves those secrets into HashiCorp Vault and fetches them at boot. Because it
injects the values into the environment before Laravel loads its configuration, every existing
env('DB_PASSWORD') and config('database.connections.mysql.password') call resolves the Vault
value with no application code change. Adopting the package is a one-line edit to
bootstrap/app.php — and even that is automated by an artisan command.
It was built to manage secrets uniformly across a large fleet of services, so the production posture is deliberately conservative: fail-closed by default, stale-while-revalidate grace on transient outages, and zero secret traffic on the request hot path.
Features
- Transparent injection — secrets land in
$_ENV/$_SERVER/putenv()at boot, beforeconfig()is built. Your code doesn't change. - Runtime accessor —
Vault::get('KEY')facade (and an injectableSecretStore) for explicit lookups. - AppRole auth + KV-v2 — the v1 Vault driver, with
X-Vault-Namespacesupport (Vault Enterprise). - Encrypted per-pod cache — AES-256-CBC via your
APP_KEY, on a0600file, so boots are fast and survive blips. - Fail-closed in production — a pod that can't obtain secrets exits non-zero so your orchestrator keeps the old pods serving.
- Stale-while-revalidate grace — a transient Vault blip during a worker recycle serves last-known-good instead of crashing a healthy pod.
- Octane / Swoole / FrankenPHP / RoadRunner safe — immutable-per-worker, no mutable static state, no per-request I/O.
- Resilient HTTP — bounded retries, exponential backoff with jitter, per-request timeout, and a total deadline.
- Hard deny-list —
APP_KEY,APP_ENV, and anyVAULT_*key are never injected from Vault. - Safe observability — a dedicated
vaultlog channel that records event names, key names, and counts — never secret values. - Pluggable — a one-method
SecretProvidercontract you can implement for other backends.
How it works
The single fact that shapes the whole design is Laravel's boot order:
1. LoadEnvironmentVariables ← .env parsed into $_ENV
2. LoadConfiguration ← config/*.php evaluated; env() is frozen INTO config here
3. RegisterProviders ← normal package auto-discovery happens HERE (too late)
4. BootProviders
For injection to be transparent, the secrets must be in the environment between steps 1 and 2. Service-provider auto-discovery runs at step 3 — after config is already frozen. So the package has two entry points into one codebase (ADR-0002):
- The Loader (
VaultBootstrap::inject()) — facade-free, hooked onafterBootstrapping(LoadEnvironmentVariables). It runs beforeconfig()and facades exist and performs the injection. This is the one linevault:installadds tobootstrap/app.php. - The ServiceProvider — auto-discovered at step 3. It binds the runtime
Vault::get()accessor, the artisan commands, and the optionalconfig:cachebackstop.
Both paths share the same client / auth / cache / provider classes — only the wiring differs.
Cold-boot lifecycle (happy path):
bootstrap/app.php → afterBootstrapping(LoadEnvironmentVariables)
└─ VaultBootstrap::inject($app)
read VAULT_* / APP_KEY (facade-free)
→ cache MISS (fresh pod)
→ SecretProvider::fetch()
→ AppRoleAuth.authenticate() → VaultToken
→ VaultClient.readKvV2(path) → VaultSecret
→ cache put (encrypted)
→ EnvInjector::inject() (deny-list enforced)
→ LoadConfiguration (config now reads the injected values ✓)
→ RegisterProviders → VaultServiceProvider
Requirements
- PHP
^8.2 - Laravel
9,10, or11— Lumen is not supported (ADR-0001) - A HashiCorp Vault server with a KV-v2 secrets mount and AppRole auth enabled
ext-json
The package depends on individual illuminate/* components (never laravel/framework directly),
guzzlehttp/guzzle ^7, and psr/log ^3.
Installation
composer require secretstack/laravel-vault:^1.0
Wire the boot hook into bootstrap/app.php:
php artisan vault:install
vault:install is idempotent and patches the Laravel 9/10 skeleton automatically. For the
Laravel 11 slim skeleton (which has no return $app; line), the command cannot safely
auto-edit the file — it prints manual instructions and the exact snippet to paste:
$app->afterBootstrapping( \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class, fn ($app) => \Vaultenv\Vault\Bootstrap\VaultBootstrap::inject($app) );
Publishing the config file is optional (it is auto-merged at runtime), but available:
php artisan vendor:publish --tag=vault-config
Local development: set
VAULT_ENABLED=falseto make the package a complete no-op.
Configuration
Every option is read from an environment variable with a safe default, so config:cache stays
deterministic. The published file is config/vault.php.
| Env var | Default | Purpose |
|---|---|---|
VAULT_ENABLED |
false |
Master switch. false = no-op (no Vault calls). |
VAULT_ADDR |
http://127.0.0.1:8200 |
Vault server address. |
VAULT_NAMESPACE |
(empty) | Vault Enterprise namespace (sent as X-Vault-Namespace). |
VAULT_AUTH_MOUNT |
approle |
AppRole auth mount path. |
VAULT_ROLE_ID |
(empty) | AppRole role id. |
VAULT_SECRET_ID |
(empty) | AppRole secret id — your bootstrap credential (see Security). |
VAULT_SECRET_PATH |
(empty) | KV-v2 path, e.g. secret/data/my-app/production. |
VAULT_FAIL_OPEN |
false |
Keep false (fail-closed) in production. true is dev-only and falls back to cache. |
VAULT_CACHE_ENABLED |
true |
Enable the encrypted file cache. |
VAULT_CACHE_TTL |
300 |
Cache trust window, in seconds. |
VAULT_HTTP_TIMEOUT |
5 |
Per-request HTTP timeout (seconds). |
VAULT_HTTP_RETRIES |
3 |
Bounded retry attempts. |
VAULT_TLS_VERIFY |
true |
TLS certificate verification. Never disable in production. |
APP_KEY |
(Laravel's) | Encrypts the local cache. Deny-listed — never sourced from Vault. |
A minimal .env:
VAULT_ENABLED=true VAULT_ADDR=https://vault.example.com VAULT_AUTH_MOUNT=approle VAULT_ROLE_ID=... VAULT_SECRET_ID=... VAULT_SECRET_PATH=secret/data/my-app/production VAULT_FAIL_OPEN=false VAULT_CACHE_TTL=300 APP_KEY=base64:... # already set by `php artisan key:generate`
All of your other secrets (DB password, API keys, …) live in Vault and resolve through
env() / config() unchanged.
Precedence & the deny-list: when a key exists in both .env and Vault, Vault wins
(ADR-0005). The deny-list is absolute: the Loader will
never inject APP_KEY, APP_ENV, or any VAULT_* key, even if present in the Vault payload — it
logs a warning instead. These are bootstrap-tier keys that must exist before Vault can be reached.
key_map (optional backstop): if you cache config, prefer running config:cache at container
startup after injection (see Production deployment). As an alternative,
key_map maps Vault keys to config paths so they survive a cached config:
'key_map' => [ 'DB_PASSWORD' => 'database.connections.mysql.password', ],
Usage
Transparent (the default)
Nothing changes. Your existing code keeps reading secrets the way it always has:
config('database.connections.mysql.password'); // resolves the Vault value env('STRIPE_SECRET'); // resolves the Vault value
Runtime accessor
For explicit lookups, use the Vault facade:
use Vaultenv\Vault\Facades\Vault; Vault::get('STRIPE_SECRET'); // string|null Vault::get('FEATURE_FLAG', 'default'); // with a default Vault::all(); // array<string, string> Vault::refresh(); // reload in-process (dev / cache-warm; NOT a prod hot-reload)
Or resolve the store via the container:
public function __construct(private \Vaultenv\Vault\Secrets\SecretStore $secrets) {} // $this->secrets->get('STRIPE_SECRET');
Error handling
A fetch failure throws Vaultenv\Vault\Exceptions\SecretProviderException. The Vault driver's
VaultException extends it, so catching the parent covers both:
use Vaultenv\Vault\Exceptions\SecretProviderException; try { $value = Vault::get('STRIPE_SECRET'); } catch (SecretProviderException $e) { // log/handle — the message never contains a secret value }
Artisan commands
| Command | Purpose |
|---|---|
vault:install [--path=] |
Wire the boot hook into bootstrap/app.php (idempotent). |
vault:check [--gate] |
Diagnose connectivity and list secret keys (values masked). With --gate, exit code mirrors the Loader's success — for deploy scripts. |
vault:refresh |
Bust the cache and re-fetch. Dev / cache-warm only — not a production hot-reload. |
vault:check without --gate is a human diagnostic that always exits 0 and prints each step
(config, masked secret keys, cache status). vault:check --gate is boot-equivalent: it exits
non-zero only if the Loader would fail — i.e. no secrets are obtainable by any path (fresh
fetch, valid cache, or stale grace) — which makes it a correct deploy gate
(ADR-0010).
Production deployment
Run the gate first, then cache config — and only cache config after secrets are injected at
container startup, never at image-build time (ADR-0005).
A representative container run.sh:
set -e php artisan vault:check --gate # halts a bad rollout; old pods keep serving php artisan config:cache # freezes config WITH the injected Vault values php artisan route:cache php artisan view:cache # start your server (php-fpm / octane / supervisord / …)
Failure posture:
- Cold start — no secrets obtainable from memory or cache → fail-closed: the process throws and exits non-zero. Your orchestrator keeps the previous healthy pods running and the rollout halts (ADR-0003).
- Refresh blip — a worker recycles and a re-fetch fails, but a usable (even expired) cache exists → stale-while-revalidate grace: serve last-known-good, log loudly, keep serving (ADR-0004).
Rotation is a deployment action, never a runtime one — update the secret in Vault, then do a rolling restart so each fresh worker picks it up (ADR-0011).
Observability (v1): the v1 observability surface is the dedicated
vaultlog channel (auto-registered tostorage/logs/vault.logif you don't define one). It logs event names, key names, and counts — never values. Laravel events for secret lifecycle are intentionally deferred (ADR-0007), because the only fetch happens on the facade-free boot path where the event dispatcher doesn't exist yet.
Laravel Octane & long-running workers
The model is immutable-per-worker: resolve secrets once at worker boot, inject, freeze, and never mutate again in that process. There is no mutable static state anywhere, so nothing leaks or bleeds between workers (ADR-0011).
Do
- Let secrets resolve once per worker boot and treat them as read-only for the worker's lifetime.
- Rotate by rolling restart (
kubectl rollout restart, a new deploy, etc.). - Allow
putenv()to run once at boot, before any coroutine spawns.
Don't
- Don't add a per-request Octane reset/refresh listener — it would force a Vault round-trip per request.
- Don't mutate
$_ENV/config()/ already-built singletons mid-request. - Don't call
putenv()per-request inside Swoole coroutines (not coroutine-safe). - Don't TTL-refresh inside a live worker.
Audited safe under both PHP-FPM and Octane. Per-request cost is zero — no Vault traffic and no file I/O on the hot path.
Caching
- Where:
storage/framework/vault/secrets.cache(directory0700, file0600). On Kubernetes, back this with a memoryemptyDir(tmpfs) so it never touches node disk. - Encryption: AES-256-CBC via your
APP_KEY. This is defense-in-depth against accidental exposure — not a defense against a fully-compromised pod. - TTL is a trust window, not a refresh timer. A running worker never re-reads it; the cache TTL just bounds how long a value is trusted before the next boot tries a refresh.
- Why a file and not Redis? Your Redis credentials may themselves live in Vault — a Redis-backed
secret cache would be a chicken-and-egg problem. The file cache needs only
APP_KEY, which exists before Vault is ever contacted. - Set
VAULT_CACHE_ENABLED=falseto swap in a no-opNullCache.
Security model
| Threat | Control |
|---|---|
| Secret values leaking into logs/traces | Event/key names only; DTOs are readonly; values are never logged or stringified. |
APP_KEY / VAULT_* overwritten from Vault → boot loop |
Hard deny-list in the injector. |
Stale .env shadowing a real secret |
Vault-wins precedence. |
| Cache file accidentally exposed | Encrypted + 0600 + tmpfs. |
| Vault outage during a deploy | Fail-closed gate halts the rollout; old pods keep serving. |
| Transient blip during a worker recycle | Stale-while-revalidate grace. |
The bootstrap credential ("secret-zero"). Your VAULT_SECRET_ID is the credential that, with
the role id, mints a Vault token. Scope its AppRole policy to least privilege (only this service's
paths), enable a Vault audit device with alerting, and rotate on a regular cadence and on any
suspected compromise.
Honest limits. Injecting secrets into the application process means a fully-compromised pod
(an attacker with both APP_KEY and filesystem access) can decrypt the cache and read the injected
environment. This is inherent to in-app injection. The package materially shrinks the attack
surface and enables rotation, audit, and least-privilege — but for the strongest posture, move the
bootstrap credential to runtime injection (e.g. Kubernetes secrets, or a Workload-Identity flow
that eliminates secret-zero entirely). See
ADR-0008 and
ADR-0009.
Extending: custom secret providers
The public extensibility seam is a single-method contract (ADR-0006):
namespace Vaultenv\Vault\Contracts; interface SecretProvider { /** * @return array<string, string> * @throws \Vaultenv\Vault\Exceptions\SecretProviderException */ public function fetch(): array; }
It returns a flat key/value map and deliberately leaks no backend specifics (leases, KV
versions, auth methods). v1 ships exactly one implementation, VaultSecretProvider, bound behind
the secrets.provider config key. To use your own, implement the contract and bind it:
$this->app->bind(\Vaultenv\Vault\Contracts\SecretProvider::class, MyProvider::class);
Stability & SemVer — the frozen consumer surface
SemVer is measured against a small, frozen consumer surface only:
- The single
bootstrap/app.phpboot line. - The
VAULT_*/APP_KEYenvironment keys. Vault::get()and theSecretProvidercontract.- The published
config/vault.phpkeys. - The artisan commands (
vault:install,vault:check,vault:refresh).
Everything else (the Guzzle client, cache internals, retry logic) is an implementation detail and
may change in a minor/patch release. Pin ^1.0 — never dev-* or *. A behavior slated for
removal is deprecated (with a logged warning) in a minor release and removed only in the next major.
Architecture overview
All classes live under the Vaultenv\Vault\ namespace (src/).
| Component | Path | Responsibility |
|---|---|---|
VaultServiceProvider |
src/VaultServiceProvider.php |
Auto-discovered; runtime wiring, commands, key_map backstop. |
VaultBootstrap (the Loader) |
src/Bootstrap/VaultBootstrap.php |
Facade-free boot injection. |
VaultConfig |
src/Config/VaultConfig.php |
Typed, validated projection of config (from env at boot, from config() at runtime). |
VaultFactory |
src/Factory/VaultFactory.php |
Assembles client → auth → cache → provider → store. |
SecretProvider / VaultSecretProvider |
src/Contracts, src/Provider |
Public contract + the v1 Vault driver. |
VaultClient / GuzzleVaultClient |
src/Contracts, src/Http |
HTTP seam + Guzzle impl (retries, backoff, jitter, timeout). |
AuthMethod / AppRoleAuth |
src/Contracts, src/Auth |
Auth seam + AppRole impl. |
SecretCache / EncryptedFileCache / NullCache |
src/Contracts, src/Cache |
Cache seam + encrypted-file and no-op impls. |
SecretStore |
src/Secrets/SecretStore.php |
Per-worker, write-once, read-only holder behind the facade. |
EnvInjector |
src/Secrets/EnvInjector.php |
Writes $_ENV/$_SERVER/putenv; enforces the deny-list. |
VaultToken / VaultSecret |
src/DTO |
Immutable readonly value objects. |
SecretProviderException / VaultException |
src/Exceptions |
Contract-level + Vault-specific failures. |
FileLogger |
src/Support/FileLogger.php |
Minimal PSR-3 logger for the facade-free boot path. |
Development & testing
This is a TDD-first codebase (red → green → refactor), with coverage scoped to src/
(target ≥ 80%; currently 66 tests green, ~87% line coverage).
composer install
vendor/bin/phpunit # full suite
vendor/bin/phpunit --testsuite=Unit
vendor/bin/phpunit --testsuite=Feature
Unit tests run with no network (Guzzle MockHandler); Feature tests boot a kernel via Orchestra
Testbench. The two test suites are Unit (tests/Unit) and Feature (tests/Feature).
Documentation & ADRs
Deeper reading for maintainers:
- DESIGN.md — the full build blueprint (architecture, lifecycle, invariants).
- CONTEXT.md — the project glossary (secret-zero, the Loader, grace, the gate, …).
- docs/adr/ — the architecture decision records:
| ADR | Decision |
|---|---|
| 0001 | Target Laravel 9+/PHP 8.2; no Lumen |
| 0002 | Hybrid bootstrap; no zero-touch |
| 0003 | Fail-closed by default in production |
| 0004 | Stale-while-revalidate grace on refresh |
| 0005 | Secret injection rules (Vault-wins, deny-list, config:cache timing) |
| 0006 | SecretProvider interface, single Vault driver |
| 0007 | Operational hardening (no breaker, observability, cache) |
| 0008 | Accepted risk: secret-zero handling |
| 0009 | Secret id per-environment, not per-service |
| 0010 | vault:check gate, boot-equivalent |
| 0011 | Octane worker lifetime: no reset, no scrubbing |
Contributing
Contributions are welcome. Please:
- Follow TDD — write the failing test first, then implement, then refactor.
- Keep coverage at ≥ 80% on
src/. - Use conventional commit messages (
feat:,fix:,refactor:,docs:,test:,chore:). - Don't contradict an existing ADR without recording a superseding one.
License
Released under the MIT License.