Environment variables separate configuration from code, enabling the same application to run differently across environments. This guide covers best practices for managing configuration in PHP applications, from local development to production deployment.
Why Environment Variables?
The Twelve-Factor App
The twelve-factor methodology recommends storing config in the environment:
Development → staging → production
Same code, different config
Benefits:
- Security: Secrets stay out of code repositories
- Flexibility: Change behavior without code changes
- Portability: Deploy anywhere with different configs
What Belongs in Environment Variables?
Yes:
- Database credentials
- API keys and secrets
- Service URLs
- Feature flags
- Environment-specific settings
No:
- Application logic
- Constants that never change
- Internal configuration (routes, middleware)
Laravel Configuration
The .env File
# .env
APP_NAME="My Application"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=root
DB_PASSWORD=secret
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=587
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Accessing Configuration
// Direct env access (avoid in application code)
$debug = env('APP_DEBUG');
// Preferred: Through config files
$debug = config('app.debug');
// With default
$timeout = config('services.api.timeout', 30);
// Runtime configuration
config(['app.timezone' => 'America/New_York']);
Config Files
// config/services.php
return [
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
],
];
Type Casting
Laravel's env() Helper
// Boolean casting
APP_DEBUG=true // (bool) true
APP_DEBUG=false // (bool) false
APP_DEBUG=(true) // (bool) true
APP_DEBUG=(false) // (bool) false
// Null
DB_PASSWORD=null // (string) "null"
DB_PASSWORD=(null) // null
DB_PASSWORD= // (string) ""
// Empty string
EMPTY_VALUE="" // (string) ""
Explicit Casting in Config
// config/app.php
return [
'debug' => (bool) env('APP_DEBUG', false),
'port' => (int) env('APP_PORT', 8000),
'rate_limit' => (float) env('RATE_LIMIT', 1.5),
];
Environment-Specific Configuration
Multiple .env Files
.env # Default (loaded always)
.env.local # Local overrides
.env.testing # Testing environment
.env.production # Production (don't commit!)
// Load order in Laravel
// 1. .env
// 2. .env.{APP_ENV} if exists
Environment Detection
// Check environment
if (app()->environment('production')) {
// Production-only code
}
if (app()->environment(['staging', 'production'])) {
// Staging or production
}
// In Blade
@production
<script src="analytics.js"></script>
@endproduction
@env('local')
<div class="debug-bar">...</div>
@endenv
Validation
Required Variables
// config/app.php - Fail fast on missing config
return [
'stripe_key' => env('STRIPE_KEY') ?? throw new RuntimeException(
'STRIPE_KEY environment variable is required'
),
];
// Or in a service provider
public function boot(): void
{
$required = ['STRIPE_KEY', 'STRIPE_SECRET', 'APP_KEY'];
foreach ($required as $var) {
if (empty(env($var))) {
throw new RuntimeException("Missing required env var: {$var}");
}
}
}
Schema Validation
// Using a validation package or custom validator
class EnvironmentValidator
{
public function validate(): void
{
$rules = [
'APP_ENV' => ['required', 'in:local,staging,production'],
'APP_DEBUG' => ['required', 'boolean'],
'DB_CONNECTION' => ['required', 'in:mysql,pgsql,sqlite'],
'CACHE_DRIVER' => ['required', 'in:file,redis,memcached'],
];
foreach ($rules as $key => $constraints) {
$this->validateKey($key, env($key), $constraints);
}
}
}
Secrets Management
Don't Commit Secrets
# .gitignore
.env
.env.*
!.env.example
.env.example
# .env.example - Commit this with placeholder values
APP_NAME="My Application"
APP_ENV=local
APP_DEBUG=true
# Generate with: php artisan key:generate
APP_KEY=
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=myapp
DB_USERNAME=
DB_PASSWORD=
# Third-party services
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
Production Secrets
# AWS Parameter Store
aws ssm put-parameter \
--name "/myapp/production/DB_PASSWORD" \
--value "supersecret" \
--type "SecureString"
# In application (using AWS SDK)
$client = new SsmClient(['region' => 'us-east-1']);
$result = $client->getParameter([
'Name' => '/myapp/production/DB_PASSWORD',
'WithDecryption' => true,
]);
$password = $result['Parameter']['Value'];
HashiCorp Vault
// Using Vault for secrets
class VaultSecrets
{
public function get(string $path): array
{
$response = Http::withToken(env('VAULT_TOKEN'))
->get(env('VAULT_ADDR') . "/v1/secret/data/{$path}");
return $response->json('data.data');
}
}
// Usage
$secrets = $vault->get('myapp/database');
config(['database.connections.mysql.password' => $secrets['password']]);
Docker Configuration
Docker Compose
# docker-compose.yml
services:
app:
build: .
environment:
- APP_ENV=local
- APP_DEBUG=true
- DB_HOST=db
env_file:
- .env.docker
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
Docker Secrets
# docker-compose.yml with secrets
services:
app:
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
// Read from secret file
function getSecretFromFile(string $envVar): ?string
{
$file = env($envVar . '_FILE');
if ($file && file_exists($file)) {
return trim(file_get_contents($file));
}
return env($envVar);
}
$dbPassword = getSecretFromFile('DB_PASSWORD');
Kubernetes Configuration
ConfigMaps
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: production
APP_DEBUG: "false"
CACHE_DRIVER: redis
Secrets
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DB_PASSWORD: supersecret
STRIPE_SECRET: sk_live_xxx
Pod Configuration
spec:
containers:
- name: app
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: DB_PASSWORD
Configuration Caching
Laravel Config Cache
# Cache configuration for production
php artisan config:cache
# Clear cache
php artisan config:clear
Important: After caching, env() calls outside config files won't work!
// Bad: Won't work with config:cache
class PaymentService
{
public function __construct()
{
$this->key = env('STRIPE_KEY'); // Returns null!
}
}
// Good: Works with config:cache
class PaymentService
{
public function __construct()
{
$this->key = config('services.stripe.key'); // Works!
}
}
Testing Configuration
PHPUnit Configuration
<!-- phpunit.xml -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
Test-Specific Config
class PaymentTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['services.stripe.key' => 'pk_test_xxx']);
config(['services.stripe.secret' => 'sk_test_xxx']);
}
public function test_payment_processing(): void
{
// Config is isolated to this test
}
}
Best Practices
Naming Conventions
# Use SCREAMING_SNAKE_CASE
DATABASE_URL=... # Good
databaseUrl=... # Bad
# Group related variables with prefixes
DB_HOST=...
DB_PORT=...
DB_DATABASE=...
REDIS_HOST=...
REDIS_PORT=...
STRIPE_KEY=...
STRIPE_SECRET=...
Default Values
// Provide sensible defaults
'timeout' => env('HTTP_TIMEOUT', 30),
'retries' => env('HTTP_RETRIES', 3),
// But not for secrets!
'api_key' => env('API_KEY'), // No default - should fail if missing
Documentation
# .env.example with descriptions
# Application
APP_NAME="My App" # Display name
APP_ENV=local # local|staging|production
APP_DEBUG=true # Enable debug mode (false in production!)
# Database
DB_CONNECTION=mysql # mysql|pgsql|sqlite
DB_HOST=127.0.0.1 # Database host
DB_PORT=3306 # Database port (3306 for MySQL, 5432 for PostgreSQL)
Troubleshooting
Common Issues
// Issue: env() returns null after config:cache
// Solution: Use config() instead of env() in application code
// Issue: Boolean values not working
APP_DEBUG=true // This is a string "true"
// Solution: Use (true) or cast in config file
APP_DEBUG=(true)
// Issue: Config not updating
// Solution: Clear config cache
php artisan config:clear
// Issue: .env not loading
// Check file permissions and location
// Ensure no BOM in file (save as UTF-8 without BOM)
Debugging Configuration
// Dump all config
dd(config()->all());
// Check specific value
dd([
'env' => env('APP_DEBUG'),
'config' => config('app.debug'),
'type_env' => gettype(env('APP_DEBUG')),
'type_config' => gettype(config('app.debug')),
]);
Conclusion
Environment variables are fundamental to modern application deployment. Use .env files for local development, proper secrets management for production, and always access configuration through Laravel's config system rather than direct env() calls. Validate required variables early, provide sensible defaults where appropriate, and remember to cache configuration in production for performance.