Due to AWS Lambda limitations, Vapor environment variables are capped at ~2,000 characters. Vapor's built-in solution — encrypted environment files — works but has significant DX tradeoffs. This document evaluates alternatives with specific attention to how they integrate with Laravel's .env → config() pipeline.
Understanding the boot sequence is critical to evaluating alternatives:
.envis loaded very early — before service providers register. Values become available viaenv().config/*.phpfiles referenceenv()— e.g.,'key' => env('GOTO_CLIENT_ID')inconfig/services.php.config:cachebakes everything into a single file — after this runs,env()returnsnulleverywhere except insideconfig/*.php. Vapor runsconfig:cacheduring deployment.- Service providers boot after config is resolved — by the time
AppServiceProvider::boot()runs, allconfig()values from step 2 are already set.
This means any alternative to .env must inject values at the right point in the boot sequence, or they'll silently be null in production.
The .env.{environment}.encrypted file is decrypted at deploy time and loaded in place of .env, so it fits perfectly into the boot sequence above. But:
- No diffing — it's an encrypted blob in git history
- Every change requires a commit + deploy — even a single API key rotation
- Lag between "I need to change a value" and it being live
- Secrets live in version control (encrypted, but still)
- Team friction — only developers with the encryption key can view/edit values
Skip encrypted env files entirely. Use a hybrid of three layers, each matched to when the value is needed in Laravel's boot sequence:
| Category | Where | Example Values |
|---|---|---|
| Bootstrap config (~10 values) | Vapor env vars (plain, fits in 2KB) | DB_HOST, REDIS_HOST, AWS_ACCESS_KEY_ID, APP_KEY |
| Secrets & API keys | AWS SSM Parameter Store | GOTO_CLIENT_SECRET, OPENAI_API_KEY, SLACK_BOT_USER_OAUTH_TOKEN |
| Runtime settings | Database (optional) | Feature flags, thresholds, notification preferences |
Why this works: Bootstrap values fit within Lambda's 2KB limit on their own — the limit only hurts when you cram everything in. Secrets load from SSM via a service provider that writes to Laravel's config() at runtime, which works even after config:cache. Database settings load last, for values that non-engineers need to edit via Filament.
Performance tradeoff: SSM adds ~100-300ms to Lambda cold starts only (an API call to fetch parameters). Warm invocations are unaffected. Encrypted env files have zero runtime cost since they load at deploy time. This is the one genuine advantage they have over SSM — whether it matters depends on cold start frequency and latency sensitivity. Mitigations like Provisioned Concurrency or caching to Lambda's /tmp can reduce this further.
Cost: SSM Parameter Store is free. The integration is ~20 lines of PHP. No new infrastructure beyond what's already in the AWS account.
The alternatives below explain each layer in detail — how it integrates with Laravel's boot sequence, what it can and can't hold, and the tradeoffs.
Key-value store built into AWS. Supports SecureString (encrypted) and hierarchical paths like /app/production/GOTO_CLIENT_SECRET.
Parameter Store values are fetched via API call, so they can only be injected after the app has enough config to make that call (AWS region + credentials). This means:
- Bootstrap config (DB host, Redis, queue driver, AWS credentials) cannot live here — they're needed before you can reach SSM.
- Everything else (API keys, tokens, feature flags) can be loaded in a service provider and written to
config(), which works even afterconfig:cachebecause you're writing to the runtime config repository, not readingenv().
```php // AppServiceProvider::register() if (! $this->app->environment('local')) { $ssm = new SsmClient([ 'region' => config('services.ses.region'), 'version' => 'latest', ]);
\$result = \$ssm->getParametersByPath([
'Path' => '/care-management/' . app()->environment() . '/',
'WithDecryption' => true,
]);
foreach (\$result['Parameters'] as \$param) {
\$key = str(\$param['Name'])->afterLast('/')->toString();
// Maps SSM path to config key, e.g.:
// /care-management/production/services.goto.client_id → config('services.goto.client_id')
config([\$key => \$param['Value']]);
}
} ```
Key detail: This writes to Laravel's config repository at runtime. It works with config:cache because we're calling config([\$key => \$value]) (the setter), not env(). Code that reads config('services.goto.client_id') will get the SSM value.
For values managed by SSM, you have two options:
Option A — Keep env() as fallback (good for local dev):
```php
'goto' => [
'client_id' => env('GOTO_CLIENT_ID'), // null in prod (config:cache), overwritten by SSM
'client_secret' => env('GOTO_CLIENT_SECRET'), // null in prod, overwritten by SSM
],
```
SSM overwrites these at boot. Locally, .env still works. The only risk: if SSM fails to load in production, these are silently null.
Option B — No env(), SSM is the sole source:
```php
'goto' => [
'client_id' => null, // populated by SSM at boot
'client_secret' => null, // populated by SSM at boot
],
```
Makes the dependency explicit. Pair with a health check that verifies SSM values loaded.
- Cold starts: Each
getParametersByPathcall adds ~50-200ms. SSM returns max 10 parameters per page, so 25 parameters = 3 sequential API calls. Structure your SSM hierarchy to minimize round trips. - Warm invocations: Zero impact — config is already in memory from the cold start.
- Availability risk: If SSM has a regional outage or is throttled, your app cannot boot. This is a hard dependency on the critical path.
- Lambda Provisioned Concurrency — keeps instances warm so SSM calls only happen on scale-up, not routine requests. Adds cost.
- Cache to
/tmp— Lambda's/tmpdirectory persists across warm invocations within the same instance. Fetch from SSM on cold start, write to/tmp, read from/tmpon subsequent warm starts to avoid redundant calls if the instance partially recycles. - Batch efficiently — use a single path prefix (
/care-management/production/) to fetch all params in as few API calls as possible.
- Real-time updates — change a value, next Lambda invocation picks it up (no deploy)
- Full audit trail via CloudTrail
- Diffable — current values and history visible in AWS console
- Free tier covers standard parameters (no cost for < 10,000)
- Granular IAM — restrict who can read/write specific paths
- Cold-start penalty — ~100-300ms of API calls on every Lambda cold start
- External dependency — if SSM is down, your app can't boot
- Requires IAM configuration on the Vapor/Lambda role
- Cannot hold bootstrap config (DB, Redis, queue, AWS creds themselves)
Purpose-built for secrets with automatic rotation. Same boot-sequence constraints as Parameter Store.
Same as SSM — fetched via API in a service provider, written to config(). The only difference is the SDK client (SecretsManagerClient) and the response format. Secrets Manager stores JSON blobs rather than individual key-value pairs, so one secret can hold a group of related values.
```php $secret = json_decode( $client->getSecretValue(['SecretId' => 'care-management/production'])['SecretString'], true );
foreach ($secret as $key => $value) { config([$key => $value]); } ```
- Same cold-start penalty as SSM (~50-200ms per API call), but potentially fewer round trips if you store all values in a single JSON secret rather than individual parameters.
- Same availability risk — hard dependency at boot.
- Built-in automatic rotation (DB passwords, API keys on a schedule)
- Cross-account and cross-region replication
- Designed specifically for secrets
- Fewer API calls than SSM if grouping values into one secret
- $0.40/secret/month + $0.05 per 10K API calls — cost adds up
- Same cold-start penalty and bootstrap limitation as SSM
- Overkill for non-secret config
Store config in a `settings` table, loaded at boot and cached. `spatie/laravel-settings` provides a clean interface, and Filament has native settings page support.
This has the most restrictive boot-sequence constraint. Database settings require:
- A working database connection (needs DB host, user, password already in config)
- A booted application (migrations run, table exists)
This rules out any config needed before or during the database connection setup.
- Adds a DB query on every request, not just cold starts. Mitigated by caching to Redis or file — when cached, the overhead is negligible.
- If the cache is cold or Redis is slow, it's an extra round trip per request.
- Unlike SSM, this cost is per-request rather than per-cold-start.
- Feature flags, thresholds, toggles
- Values non-engineers need to edit via a Filament panel
- Config that changes frequently without deploys
- Any credential or connection string — DB, Redis, queue, API keys
- Anything needed during the boot sequence
- Editable via admin UI (Filament settings pages)
- No AWS-specific tooling
- Non-technical team members can update values
- Cannot hold bootstrap config, credentials, or anything sensitive
- Adds a DB query per request (mitigated by caching)
- Another migration/model/UI to maintain
Combine approaches based on what each value is and when it's needed:
| Category | Where | Why | Example Values |
|---|---|---|---|
| Bootstrap config | Vapor env vars (the 2KB) | Needed before anything else loads | `DB_HOST`, `REDIS_HOST`, `AWS_ACCESS_KEY_ID`, `AWS_DEFAULT_REGION`, `APP_KEY` |
| Secrets & API keys | SSM Parameter Store | Auditable, updatable without deploy, free | `GOTO_CLIENT_SECRET`, `OPENAI_API_KEY`, `SLACK_BOT_USER_OAUTH_TOKEN`, `STEDI_API_KEY` |
| Runtime settings | Database (`spatie/laravel-settings`) | Editable via Filament, no deploy or AWS console | Feature flags, thresholds, notification preferences |
The 2KB Lambda env var limit is only a problem when you try to put everything in it. By offloading secrets to SSM and runtime config to the database, the Lambda env vars only need to hold bootstrap values — which comfortably fit within 2KB.
No encrypted env file needed. Plain Vapor env vars handle bootstrap, SSM handles secrets, database handles runtime config.
| Approach | Cold Start Cost | Per-Request Cost | Availability Risk |
|---|---|---|---|
| Encrypted env files | None (loaded at deploy) | None | None |
| SSM Parameter Store | ~100-300ms (API calls) | None | SSM outage = app can't boot |
| Secrets Manager | ~50-200ms (fewer calls if batched) | None | Same as SSM |
| Database settings | None (loaded after boot) | 1 DB query (cacheable) | DB outage (already a dependency) |
The cold-start cost of SSM is the primary tradeoff vs. encrypted env files. For most workloads this is acceptable — cold starts are infrequent relative to warm invocations. If cold-start latency is critical, Provisioned Concurrency or /tmp caching can reduce the impact.
- Audit current `.env` — categorize each value as bootstrap, secret, or runtime config
- Set up SSM paths — e.g., `/care-management/staging/`, `/care-management/production/`
- Add the SSM service provider logic — ~20 lines of code
- Move secrets to SSM — starting with the most frequently rotated
- Remove those values from Vapor env vars — they'll now load from SSM at boot
- Optionally add database settings for values that non-engineers need to control