Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 034 / 100

Laravel configuration and .env: best practices explained

Configuration without secrets in git.

Expona Admin6 min read

Introduction

Configuration without secrets in git is one of the most critical lessons in Laravel development, yet teams still hardcode database passwords, API keys, and third-party credentials directly into their codebase. This article walks through Laravel configuration and .env: best practices explained, showing you how to structure configuration so your team stays secure while keeping the setup process simple and repeatable across environments.

We've seen production incidents stemming from misconfigured environments, leaked credentials in public repositories, and deployment chaos when developers guessed at which values belonged where. The patterns we'll cover here—read env() only in config files, use config() everywhere else, cache configuration in production, and maintain separate .env files per environment—come from years of building systems at scale.

Read env() only in config files

The first rule of Laravel configuration is isolation: call env() exclusively within your config/ directory files. Never call env() directly in your routes, controllers, or service classes. This pattern serves two purposes: it centralizes environment variable management and makes configuration testable.

When env() is scattered throughout your application, you lose track of which features depend on which environment variables. You also can't easily mock or override configuration during tests. By funneling all environment variable reads through your config files, you create a single source of truth.

Here's the correct approach:

php
// config/database.php
return [
    'default' => env('DB_CONNECTION', 'mysql'),
    
    'connections' => [
        'mysql' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', 3306),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
        ],
    ],
];

Now your controller or service never touches env(). It reaches for configuration through the config() helper instead (covered in the next section). If you need to swap a database connection during testing, you modify the config returned by your test setup, not buried env() calls scattered across your codebase.

This separation also helps onboarding. A new team member can look at config/ and immediately understand every environment variable the application requires and what it controls.

Use config() everywhere else

Once you've centralized env() calls in your config files, the rest of your application should retrieve configuration using the config() helper function or dependency injection. This gives you flexibility: you can change how configuration is retrieved (from database, from a remote service, from cache) without touching your business logic.

In your controllers and services, write code like this:

php
// app/Services/PaymentGateway.php
namespace App\Services;

class PaymentGateway
{
    public function charge($amount)
    {
        $apiKey = config('services.stripe.secret');
        $webhookSecret = config('services.stripe.webhook_secret');
        
        // Charge logic using $apiKey and $webhookSecret
    }
}

And in config/services.php:

php
// config/services.php
return [
    'stripe' => [
        'secret' => env('STRIPE_SECRET_KEY'),
        'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    ],
];

Why this pattern? Because testing becomes trivial. In your test, you override the config without touching environment variables:

php
// tests/Unit/PaymentGatewayTest.php
public function test_stripe_charge()
{
    config(['services.stripe.secret' => 'test_secret_key']);
    
    $gateway = new PaymentGateway();
    // ... assertions
}

The config() helper also lets you provide dot-notation access to deeply nested arrays, making your code more readable and less error-prone than chasing through arrays manually.

php artisan config:cache in production

In production environments, running php artisan config:cache is non-negotiable. This command reads all your config files and compiles them into a single serialized PHP file, eliminating the overhead of parsing YAML or merging array files on each request.

But there's a critical side effect: once you've cached the configuration, env() calls outside of your config files will not work. Laravel will not read your .env file when a config cache exists. This is actually a feature—it forces you to follow the pattern of reading env() only in config files during development, and then caching that output for production.

Your deployment script should look something like:

php
#!/bin/bash
# deploy.sh

php artisan config:cache
php artisan route:cache
php artisan view:cache

# or all at once (Laravel 9+)
# php artisan optimize

When you cache configuration, you also gain a performance win: fewer file I/O operations per request and no array merging overhead. In high-traffic systems, this compounds into measurable latency improvements.

One gotcha: if your config files read environment variables that don't exist in production, config:cache will still succeed, but your application may behave unexpectedly. Always validate that all required environment variables are present in production before caching.

Separate .env per environment

A common pattern is to maintain multiple environment files: .env.local for development, .env.testing for your test suite, and separate server-specific files for staging and production. Laravel's default .env loading respects this hierarchy.

Your typical setup looks like:

  • .env – checked into git with sensible defaults and dummy values
  • .env.local – local machine overrides (gitignored)
  • .env.testing – test environment configuration (can be checked in)
  • .env.production – production values set on the server (gitignored, not checked in)

Laravel loads these in order, with later files overriding earlier ones. So if your .env has DB_HOST=localhost and .env.local has DB_HOST=192.168.1.5, the local value wins.

In your git repository, commit .env as a template:

php
// .env (committed to git)
APP_NAME=MyApp
APP_ENV=local
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp_local
DB_USERNAME=root
DB_PASSWORD=

STRIPE_SECRET_KEY=sk_test_
REDIS_HOST=127.0.0.1

Your .gitignore excludes the sensitive overrides:

php
// .gitignore
.env.local
.env.*.local
.env.production
.env.production.local

On your production server, you either set environment variables directly in your web server configuration (Nginx, Apache) or you create a .env.production file that's deployed via your CI/CD pipeline (not checked into git, but passed as a secret or artifact). Many teams use AWS Secrets Manager, HashiCorp Vault, or similar tools to inject secrets at runtime.

This approach keeps credentials out of version control while making it easy for new developers to get started: they clone the repo, copy .env to .env.local, run composer install and php artisan migrate`, and they're working locally in minutes.

Common mistakes

We see these errors repeatedly in code reviews:

  • Calling env() in controllers: A controller calls env('STRIPE_KEY') directly instead of config('services.stripe.key'). This makes testing harder and hides dependencies. Refactor into a config file immediately.
  • Forgetting to run config:cache in production: Code works fine locally but mysteriously fails on the server because a developer added a new env() call outside of config files. Always run config:cache as part of your deployment checklist.
  • Hardcoding secrets as defaults: Using env('API_KEY', 'hardcoded_secret_value') defeats the purpose. Defaults should be dummy values or empty strings that trigger an error if not properly configured.
  • Checking .env files into git: Even for "local development only," checking .env with real credentials into git is a security liability. Use .env.example instead, clearly marked, with dummy values.
  • Not validating configuration at startup: If a required environment variable is missing, you should fail loudly at application startup, not deep in a request handler. Use the AppServiceProvider to validate.

Here's a simple validation pattern:

php
// app/Providers/AppServiceProvider.php
public function boot()
{
    if (app()->isProduction()) {
        $required = ['DB_HOST', 'STRIPE_SECRET_KEY', 'REDIS_HOST'];
        
        foreach ($required as $var) {
            if (! env($var)) {
                throw new Exception("Missing required environment variable: {$var}");
            }
        }
    }
}

Key takeaways

  • Call env() only inside config/ files; use config() everywhere else in your application.
  • Never commit .env files containing real secrets to git; use .gitignore and provide .env.example as a template.
  • Run php artisan config:cache in production to improve performance and enforce the config-only pattern.
  • Maintain separate environment files (.env.local, .env.testing, .env.production) and leverage Laravel's auto-loading to override values per environment.
  • Validate required configuration at application startup so missing secrets fail fast, not silently mid-request.
  • Keep your .env file in git as a reference; team members should copy and customize it locally without committing changes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles