Environment Variables and Configuration Management

Reverend Philip Dec 16, 2025 7 min read

Manage application configuration across environments securely. Learn twelve-factor app principles, secrets management, and config validation.

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.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.