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
Laravel uses a .env file in the project root to store environment-specific settings. This file should never be committed to version control, as it contains sensitive information unique to each environment.
Here's a typical .env file structure. You'll notice how settings are grouped logically, making it easy to find and modify related configuration.
# .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
Notice how related settings are grouped together. This organization makes it easier to find and modify settings, especially when onboarding new team members.
Accessing Configuration
There are several ways to access configuration values in Laravel. While you can call env() directly, using the config() helper is preferred because it works correctly with configuration caching.
// 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']);
The key takeaway here is that env() should only be called from within config files, never directly in your application code. This ensures your application works correctly when configuration is cached.
Config Files
Config files act as the bridge between environment variables and your application code. They live in the config/ directory and pull values from the environment while providing sensible defaults.
This example shows how to organize third-party service credentials. Each service gets its own array, keeping related settings together.
// 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'),
],
];
By centralizing third-party service configuration here, you create a single source of truth that makes it easy to audit what external services your application depends on.
Type Casting
Laravel's env() Helper
Environment variables are always strings at the system level, but Laravel's env() helper provides automatic type casting for common values. Understanding these casting rules helps you avoid subtle bugs.
// 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) ""
Pay special attention to the difference between null (the string) and (null) (actual null). This is a common source of confusion when debugging configuration issues.
Explicit Casting in Config
When you need guaranteed type safety, explicitly cast values in your config files. This makes your intentions clear and prevents type-related bugs.
// 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
Laravel supports multiple environment files, allowing you to maintain different configurations for different contexts. The base .env file loads first, followed by environment-specific overrides.
.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
You can check the current environment in both PHP and Blade templates. This is useful for conditionally enabling features like debug toolbars or analytics scripts.
// 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
The Blade directives provide a clean way to include environment-specific content without cluttering your templates with PHP conditionals.
Validation
Required Variables
Failing fast when required configuration is missing saves debugging time. You can implement this check either in config files or in a service provider during application boot.
// 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}");
}
}
}
This approach surfaces configuration problems immediately at startup rather than during a user request, making deployment issues easier to diagnose.
Schema Validation
For complex applications, you might want to validate not just the presence of variables but also their values. A custom validator class can enforce rules like allowed values and correct data types.
// 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);
}
}
}
This validation layer catches misconfiguration early, which is especially valuable when deploying to new environments or onboarding team members.
Secrets Management
Don't Commit Secrets
Your .gitignore file should prevent environment files from being committed. The exception is .env.example, which serves as documentation for required variables.
# .gitignore
.env
.env.*
!.env.example
.env.example
The example file documents all required environment variables with placeholder values. Keep this file updated whenever you add new configuration options.
# .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
Notice the comments explaining how to generate values like APP_KEY. This helps developers set up their environments without needing to ask questions.
Production Secrets
For production deployments, use a proper secrets management service rather than storing secrets in files. AWS Parameter Store is a popular choice that provides encryption and access control.
# 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'];
The SecureString type ensures your secrets are encrypted at rest, and IAM policies control which services can access them.
HashiCorp Vault
For organizations with more complex secrets management needs, HashiCorp Vault provides enterprise-grade features like secret rotation, audit logging, and dynamic credentials.
// 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']]);
This pattern allows you to fetch secrets at runtime, enabling features like automatic secret rotation without application restarts.
Docker Configuration
Docker Compose
When running Laravel in Docker, you can pass environment variables directly in your compose file or reference an external env file. This keeps your containerized environment configuration clean and maintainable.
# 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}
Using variable interpolation (the ${VAR} syntax) lets you reference values from your host's environment or a .env file in the same directory as your compose file.
Docker Secrets
For production Docker deployments, especially in Swarm mode, Docker secrets provide better security than environment variables. Secrets are mounted as files rather than exposed in the process environment.
# 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
Your application needs to be aware of this pattern and read from the secret file when available. Here's a helper function that checks for the file-based secret pattern.
// 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');
This helper function checks for a _FILE suffix version of the variable first, falling back to the regular environment variable. This pattern is common in containerized applications.
Kubernetes Configuration
ConfigMaps
Kubernetes ConfigMaps store non-sensitive configuration data that can be injected into pods as environment variables or mounted as files.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: production
APP_DEBUG: "false"
CACHE_DRIVER: redis
Note that all values must be strings in ConfigMaps, which is why "false" is quoted.
Secrets
Kubernetes Secrets work similarly to ConfigMaps but are designed for sensitive data. They provide base64 encoding (not encryption by default, though you can enable encryption at rest).
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DB_PASSWORD: supersecret
STRIPE_SECRET: sk_live_xxx
Using stringData instead of data lets you specify values in plain text, and Kubernetes handles the base64 encoding automatically.
Pod Configuration
When configuring your pods, you can reference both ConfigMaps and Secrets. The envFrom directive injects all keys from a source, while env lets you pick specific values.
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
The envFrom approach is cleaner for applications with many configuration values, while explicit env entries give you more control over naming and selective inclusion.
Configuration Caching
Laravel Config Cache
In production, caching your configuration dramatically improves performance by combining all config files into a single cached file that loads faster.
# 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!
This is a critical gotcha that trips up many developers. Once you cache configuration, the .env file is no longer read during requests. The following example illustrates the problem and solution.
// 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!
}
}
Always access configuration through config() in your application code. Reserve env() calls exclusively for config files.
Testing Configuration
PHPUnit Configuration
PHPUnit uses its own configuration to override environment variables during testing. This ensures tests run consistently regardless of your local development settings.
<!-- 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>
Using in-memory SQLite and array drivers makes tests faster and eliminates external dependencies.
Test-Specific Config
Sometimes individual tests need custom configuration. Laravel's config() helper accepts an array to set values at runtime, and these changes are automatically reset between tests.
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
}
}
This isolation ensures tests don't interfere with each other, even when they require different configuration values.
Best Practices
Naming Conventions
Consistent naming makes configuration easier to manage. Follow these conventions across all your projects for a predictable experience.
# 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=...
The prefix grouping also makes it easier to see all related configuration at a glance when reviewing your .env file.
Default Values
Provide defaults for configuration that has sensible fallbacks, but avoid defaults for sensitive values that should be explicitly configured.
// 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
Well-documented environment files save time for everyone. Include comments explaining what each variable does and what values are acceptable.
# .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)
These comments serve as inline documentation and help prevent configuration mistakes.
Troubleshooting
Common Issues
When configuration isn't working as expected, these are the most common culprits. Check each one systematically before diving deeper.
// 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)
The BOM (Byte Order Mark) issue is particularly sneaky - some editors add invisible characters at the start of files that can break parsing.
Debugging Configuration
When you need to inspect what configuration values your application is actually using, these debugging techniques help pinpoint the issue.
// 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')),
]);
Comparing the env() and config() values side by side often reveals caching issues or type casting problems.
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.