Jak nie używać pliku .env w kodzie aplikacji Laravel

Plik .env jest jednym z pierwszych elementów, z którymi spotyka się każdy developer rozpoczynający pracę z Laravelem.

Znajdziemy w nim konfigurację bazy danych, cache, kolejek, mailera, zewnętrznych API czy ustawienia środowiska aplikacji.

Problem zaczyna się wtedy, gdy .env zaczynamy traktować jak globalny magazyn konfiguracji dostępny bezpośrednio z dowolnego miejsca aplikacji.

Na początku takie podejście wydaje się wygodne:

$apiKey = env('PAYMENTS_API_KEY');

Działa lokalnie.

Jest krótkie.

Jest czytelne.

Więc dlaczego miałoby być problemem?

Ponieważ w poprawnie skonfigurowanej aplikacji Laravel kod biznesowy nie powinien bezpośrednio korzystać z env().

Lepsza architektura wygląda następująco:

Environment Variables
        ↓
config/*.php
        ↓
config()
        ↓
kod aplikacji

Zobaczmy dlaczego.


.env nie jest konfiguracją aplikacji

Najważniejsze rozróżnienie jest następujące:

.env przechowuje wartości środowiskowe.

Natomiast katalog:

config/

definiuje konfigurację aplikacji.

To subtelna, ale bardzo ważna różnica.

Załóżmy, że integrujemy aplikację z zewnętrznym systemem płatności.

Możemy mieć:

PAYMENTS_API_URL=https://api.example.com
PAYMENTS_API_KEY=secret
PAYMENTS_TIMEOUT=10

Najgorszym rozwiązaniem byłoby później korzystanie z tych wartości bezpośrednio w serwisie:

class PaymentService
{
    public function charge()
    {
        $url = env('PAYMENTS_API_URL');
        $key = env('PAYMENTS_API_KEY');

        // ...
    }
}

Technicznie może to działać.

Architektonicznie jednak kod aplikacji został właśnie połączony bezpośrednio z mechanizmem zmiennych środowiskowych.

Lepszym rozwiązaniem jest utworzenie dedykowanego pliku konfiguracji.

// config/payments.php

return [
    'url' => env('PAYMENTS_API_URL'),

    'api_key' => env('PAYMENTS_API_KEY'),

    'timeout' => (int) env('PAYMENTS_TIMEOUT', 10),
];

A następnie korzystanie z niego:

class PaymentService
{
    public function charge()
    {
        $url = config('payments.url');
        $key = config('payments.api_key');

        // ...
    }
}

To właśnie config() powinno być API konfiguracji dla reszty aplikacji.


Dlaczego nie powinieneś używać env() w kontrolerach i serwisach?

Najważniejszym powodem jest mechanizm cache'owania konfiguracji Laravela.

W środowisku produkcyjnym jednym ze standardowych kroków deploymentu jest:

php artisan config:cache

Laravel generuje wtedy skompilowaną konfigurację aplikacji.

Dzięki temu framework nie musi przy każdym żądaniu ponownie odczytywać wszystkich plików konfiguracyjnych.

Po wykonaniu cache konfiguracji plik .env nie jest normalnie ładowany przez aplikację.

Dlatego kod:

env('SOME_VARIABLE');

wywołany poza konfiguracją może zachowywać się inaczej niż podczas lokalnego developmentu.

To jeden z tych błędów, które często wyglądają tak:

lokalnie działa
↓
deployment
↓
config:cache
↓
produkcja przestaje działać

Dlatego bardzo dobra zasada brzmi:

env() używamy w config/*.php. W pozostałej części aplikacji używamy config().


Złe i dobre przykłady

Controller

Źle

class CheckoutController extends Controller
{
    public function store()
    {
        $currency = env('PAYMENTS_CURRENCY');

        // ...
    }
}

Dobrze

class CheckoutController extends Controller
{
    public function store()
    {
        $currency = config('payments.currency');

        // ...
    }
}

Service

Źle

class SmsService
{
    public function send(string $phone, string $message)
    {
        $apiKey = env('SMS_API_KEY');

        // ...
    }
}

Dobrze

class SmsService
{
    public function send(string $phone, string $message)
    {
        $apiKey = config('services.sms.api_key');

        // ...
    }
}

Konfiguracja:

// config/services.php

return [

    'sms' => [
        'api_key' => env('SMS_API_KEY'),
    ],

];

Job

Ten sam problem może pojawić się w kolejkach.

Źle

class SendInvoice implements ShouldQueue
{
    public function handle()
    {
        $endpoint = env('INVOICE_API_URL');

        // ...
    }
}

Dobrze

class SendInvoice implements ShouldQueue
{
    public function handle()
    {
        $endpoint = config('services.invoice.url');

        // ...
    }
}

Nie używaj .env jako miejsca na feature flags

Kolejny częsty błąd:

NEW_CHECKOUT_ENABLED=true

A następnie:

if (env('NEW_CHECKOUT_ENABLED')) {
    // ...
}

Jeżeli ustawienie rzeczywiście zależy od środowiska, może znaleźć się w konfiguracji:

// config/features.php

return [
    'new_checkout' => env('FEATURE_NEW_CHECKOUT', false),
];

A w aplikacji:

if (config('features.new_checkout')) {
    // ...
}

Jednak w większym systemie warto zadać sobie jeszcze jedno pytanie:

Czy ta wartość rzeczywiście jest konfiguracją środowiska?

Jeżeli feature flag ma być zmieniany:

  • przez administratora,

  • bez deploymentu,

  • osobno dla użytkowników,

  • osobno dla organizacji,

  • dynamicznie podczas testów A/B,

to .env jest prawdopodobnie złym miejscem.

Takie dane powinny trafić do:

database
Redis
feature flag service
dedykowanego systemu konfiguracji

Nie umieszczaj konfiguracji biznesowej w .env

To jeden z częstszych problemów w rozwijających się projektach.

Na początku pojawiają się zmienne:

FREE_TRIAL_DAYS=14
MAX_PROJECTS=5
DEFAULT_DISCOUNT=10
INVOICE_PAYMENT_DAYS=30

Po kilku miesiącach .env zaczyna przypominać bazę danych.

Problem polega na tym, że nie każda wartość zmienna jest zmienną środowiskową.

Dobrym pytaniem jest:

Czy ta wartość zmienia się dlatego, że aplikacja działa na innym środowisku?

Przykładowo:

DB_HOST=
REDIS_HOST=
MAIL_HOST=
AWS_REGION=

mają sens jako konfiguracja środowiska.

Natomiast:

PREMIUM_PLAN_PRICE=
MAX_FREE_PROJECTS=
DEFAULT_COMMISSION=

mogą być częścią logiki biznesowej.

W zależności od projektu powinny więc znajdować się w:

config/
database
dedykowanym module ustawień

.env nie powinien trafiać do repozytorium

Plik .env często zawiera dane takie jak:

DB_PASSWORD=
AWS_SECRET_ACCESS_KEY=
MAIL_PASSWORD=
STRIPE_SECRET=

Dlatego właściwy plik .env nie powinien być wersjonowany razem z kodem aplikacji.

Do repozytorium można natomiast dodać:

.env.example

Przykład:

APP_NAME=
APP_ENV=
APP_KEY=
APP_DEBUG=
APP_URL=

DB_CONNECTION=
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=

REDIS_HOST=
REDIS_PORT=

PAYMENTS_API_KEY=

Nie powinno być tam prawdziwych sekretów.

.env.example pełni rolę dokumentacji wymaganej konfiguracji projektu.

Laravel również oficjalnie zaleca, aby właściwy plik .env nie był commitowany do systemu kontroli wersji.


.env nie jest też jedynym sposobem przekazywania zmiennych środowiskowych

To ważne szczególnie przy bardziej zaawansowanych deploymentach.

Nazwa .env może sugerować, że Laravel wymaga fizycznego pliku .env.

Nie jest to jednak pełny obraz.

W produkcji wartości środowiskowe mogą być dostarczane przez infrastrukturę.

Przykładowo:

Docker
Kubernetes Secrets
AWS
Laravel Cloud
CI/CD
systemd
platformę hostingową
secret manager

Aplikacja może więc otrzymać:

APP_ENV
DB_HOST
REDIS_HOST
STRIPE_SECRET

bez przechowywania klasycznego pliku .env na serwerze.

Z punktu widzenia kodu aplikacji nic nie powinno się jednak zmienić.

Nadal:

environment
    ↓
config
    ↓
application

Typowanie konfiguracji

.env przechowuje wartości tekstowe.

Dlatego warto zwracać uwagę na typy.

Przykładowo:

PAYMENTS_TIMEOUT=10

Konfiguracja:

return [
    'timeout' => (int) env('PAYMENTS_TIMEOUT', 10),
];

Dzięki temu reszta aplikacji może oczekiwać konkretnego typu:

$timeout = config('payments.timeout');

Możemy też ustawić rozsądne wartości domyślne.

return [
    'timeout' => (int) env('PAYMENTS_TIMEOUT', 10),

    'retry_attempts' => (int) env('PAYMENTS_RETRY_ATTEMPTS', 3),

    'currency' => env('PAYMENTS_CURRENCY', 'PLN'),
];

Kod biznesowy nie musi już wiedzieć, skąd te wartości pochodzą.


Jeszcze lepiej: Configuration Object

W większej aplikacji nawet ciągłe wywoływanie:

config('payments.api_url');
config('payments.api_key');
config('payments.timeout');
config('payments.currency');

może zacząć rozlewać szczegóły konfiguracji po całym kodzie.

Możemy więc zrobić krok dalej.

final readonly class PaymentConfig
{
    public function __construct(
        public string $apiUrl,
        public string $apiKey,
        public int $timeout,
        public string $currency,
    ) {}
}

Następnie zarejestrować obiekt:

use App\Config\PaymentConfig;

$this->app->singleton(PaymentConfig::class, function () {
    return new PaymentConfig(
        apiUrl: config('payments.api_url'),
        apiKey: config('payments.api_key'),
        timeout: config('payments.timeout'),
        currency: config('payments.currency'),
    );
});

Serwis wygląda wtedy tak:

final class PaymentService
{
    public function __construct(
        private PaymentConfig $config,
    ) {}

    public function charge(): void
    {
        $url = $this->config->apiUrl;

        // ...
    }
}

Daje nam to:

.env
 ↓
config/payments.php
 ↓
PaymentConfig
 ↓
PaymentService

Kod biznesowy nie wie już nawet o istnieniu helpera config().

W dużych, modularnych aplikacjach jest to bardzo wygodne podejście.


Konfiguracja a testowanie

Unikanie bezpośredniego env() ułatwia również testowanie.

Jeżeli mamy:

class CurrencyService
{
    public function currency(): string
    {
        return env('DEFAULT_CURRENCY');
    }
}

test zaczyna zależeć od środowiska procesu PHP.

Natomiast:

class CurrencyService
{
    public function currency(): string
    {
        return config('app.currency');
    }
}

możemy łatwo przetestować:

public function test_it_returns_configured_currency(): void
{
    config([
        'app.currency' => 'PLN',
    ]);

    $service = new CurrencyService();

    $this->assertSame(
        'PLN',
        $service->currency(),
    );
}

Nie musimy modyfikować .env.

Nie musimy restartować procesu.

Nie musimy manipulować globalnym środowiskiem.


Co właściwie powinno znajdować się w .env?

Dobra heurystyka wygląda tak:

.env powinien zawierać wartości zależne od środowiska uruchomieniowego.

Na przykład:

APP_ENV=production

DB_HOST=database

REDIS_HOST=redis

MAIL_HOST=smtp.example.com

AWS_ACCESS_KEY_ID=...

AWS_SECRET_ACCESS_KEY=...

Natomiast aplikacja powinna korzystać z nich poprzez konfigurację:

config('database.default');

config('cache.default');

config('mail.default');

config('filesystems.disks.s3');

Prosty test architektury

Przy code review można zastosować bardzo prostą zasadę.

Jeżeli zobaczysz:

env(

w katalogach:

app/
routes/
resources/
database/

warto sprawdzić, dlaczego się tam znajduje.

W większości przypadków powinno być zastąpione przez:

config(

A samo env() powinno pozostać przede wszystkim w:

config/

Przykładowa struktura profesjonalnego projektu

Dla integracji płatności możemy mieć:

.env
│
├── PAYMENTS_API_URL
├── PAYMENTS_API_KEY
└── PAYMENTS_TIMEOUT

config/
└── payments.php

app/
├── Config/
│   └── PaymentConfig.php
│
├── Services/
│   └── PaymentService.php
│
└── Providers/
    └── AppServiceProvider.php

Przepływ konfiguracji:

Environment
     ↓
config/payments.php
     ↓
PaymentConfig
     ↓
PaymentService

Dzięki temu odpowiedzialności są jasno rozdzielone.


Produkcyjny deployment

Podczas deploymentu aplikacji warto cache'ować konfigurację:

php artisan config:cache

Laravel udostępnia również:

php artisan optimize

które może przygotować kilka cache'y wykorzystywanych przez aplikację produkcyjną.

To kolejny argument za tym, aby traktować konfigurację jako osobną warstwę aplikacji, zamiast odczytywać wartości środowiskowe bezpośrednio z kodu biznesowego.


Podsumowanie

Plik .env nie jest problemem.

Problemem jest sposób, w jaki go używamy.

Najważniejsza zasada wygląda tak:

.env
 ↓
config/*.php
 ↓
config()
 ↓
application

Zamiast:

env('PAYMENTS_API_KEY');

w serwisie:

config('payments.api_key');

A w większych projektach możemy pójść jeszcze dalej:

Environment
 ↓
Laravel Config
 ↓
Typed Configuration Object
 ↓
Service

Takie podejście daje nam:

  • przewidywalne działanie config:cache,

  • łatwiejsze testowanie,

  • lepsze rozdzielenie odpowiedzialności,

  • prostszy deployment,

  • mniejsze sprzężenie kodu z infrastrukturą,

  • czytelniejszą konfigurację dużych aplikacji.

Najważniejsze jest więc nie to, żeby całkowicie pozbyć się .env.

Chodzi o to, aby nie pozwolić, żeby .env stał się częścią logiki aplikacji.

Środowisko powinno dostarczać wartości.

Konfiguracja powinna je interpretować.

A kod biznesowy powinien korzystać już tylko z gotowej konfiguracji.