Skip to content

DotEnv

Configuration that thinks ahead, typed, modular and secure.

Introduction

Most .env libraries deliver strings. DB_PORT=3306 remains "3306", DEBUG=true remains "true", and the code is full of endless (int) casts and === 'true' comparisons. Configuration files grow into a single, unwieldy file, and secrets end up in plain text next to the hostname.

jardissupport/dotenv solves this fundamentally differently. The package treats .env files not as flat key-value lists, but as a typed, modular configuration source:

  • Automatic type castingtrue becomes bool, 42 becomes int, [a,b,c] becomes array. No more manual casts in application code.
  • Variable interpolationDATABASE_URL=mysql://${DB_HOST}/${DB_NAME} is resolved at load time. One source of truth, no duplicates.
  • Modular includesload(.env.database) splits configuration into domain-specific units. No more 200-line .env files.
  • Environment cascading.env.env.local.env.staging.env.staging.local. Automatic, based on APP_ENV.
  • Secure secrets_FILE pattern for Docker Secrets and Vault mounts. Encrypted values with jardissupport/secret. Plain-text passwords in .env are a thing of the past.
  • Extensible pipeline — register custom cast handlers, remove existing ones. The processing adapts to the project, not the other way around.

Installation

bash
composer require jardissupport/dotenv

GitHub: jardisSupport/dotenv

Optional dependency for encrypted values:

bash
composer require jardissupport/secret

Basic Usage

Two lines of code, and the entire configuration is available typed.

Public load — write to global state

php
use JardisSupport\DotEnv\DotEnv;

$dotEnv = new DotEnv();
$dotEnv->loadPublic(__DIR__);

The values are then available in $_ENV, $_SERVER and via getenv():

php
$debug = $_ENV['APP_DEBUG'];   // bool: true
$port  = $_ENV['DB_PORT'];     // int: 3306
$host  = getenv('DB_HOST');    // string: "localhost" (putenv always stores strings)

putenv() vs. $_ENV

putenv() / getenv() always store strings (POSIX constraint). $_ENV and $_SERVER receive the typed values (bool, int, float). Arrays only end up in $_ENV, as they cannot be POSIX-serialized.

Private load — isolated array

php
$config = $dotEnv->loadPrivate(__DIR__);

// $config = [
//     'APP_ENV' => 'production',
//     'DB_HOST' => 'localhost',
//     'DB_PORT' => 3306,
//     'DEBUG'   => false,
// ]

No global state is modified. Ideal for tests or multi-tenant scenarios.

File Loading Order

A common problem: the same .env file should work in development, staging and production, with different values. DotEnv solves this with an intelligent two-stage loading. Later files override earlier values, without any logic in the code.

Stage 1 — Base files (always)

FilePurpose
.envDefault values for all environments
.env.localLocal overrides (not in VCS)

Stage 2 — Environment-specific (when APP_ENV is set)

FilePurpose
.env.{APP_ENV}Environment-specific values (e.g. .env.staging)
.env.{APP_ENV}.localLocal overrides for this environment

APP_ENV is read from stage 1 or from the OS environment.

Example with APP_ENV=staging:

.env                    ← Base
.env.local              ← Local overrides
.env.staging            ← Staging-specific
.env.staging.local      ← Local staging overrides

Non-existent files are silently skipped.

Type Casting

The heart of the package. Instead of writing (int) $_ENV['PORT'] or $_ENV['DEBUG'] === 'true' in application code, DotEnv delivers the correct PHP types directly. A pipeline of specialized handlers processes each value sequentially, as soon as a handler returns a non-string type, the pipeline stops.

Cast Order

#HandlerInputOutput
1CastStringToValue${DB_HOST}resolved string
2CastUserHome~/logs/home/user/logs
3CastStringToNumeric"3306"3306 (int)
4CastStringToBool"true"true (bool)
5CastStringToJson'{"a":1}'['a' => 1] (array)
6CastStringToArray[a=>1,b=>2]['a' => 1, 'b' => 2] (array)

Variable Interpolation

Already loaded variables can be referenced with ${VAR}:

ini
DB_HOST=localhost
DB_PORT=3306
DB_NAME=myapp
DATABASE_URL=mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}

Result: DATABASE_URL = "mysql://localhost:3306/myapp"

Unresolved references remain as ${VAR} in the string.

Home Directory

~/ at the beginning of a value is expanded to the home directory:

ini
LOG_PATH=~/logs
CACHE_DIR=~/cache/myapp

Works on Unix ($HOME) and Windows (HOMEDRIVE + HOMEPATH). A HOME=/custom/path defined in .env is respected.

Numeric Values

ini
PORT=8080           # int: 8080
RATE=0.75           # float: 0.75
VERSION=2           # int: 2
NOT_NUMERIC=v2.1    # string: "v2.1"

Boolean Values

Recognized (case-insensitive): true, false, yes, no, on, off, 1, 0

ini
DEBUG=true          # bool: true
CACHE_ENABLED=yes   # bool: true
VERBOSE=off         # bool: false

JSON Values

Values that start with { or [ and are valid JSON:

ini
ALLOWED_ORIGINS=["https://app.example.com","https://api.example.com"]
SMTP_CONFIG={"host":"mail.example.com","port":587,"tls":true}

String values within the JSON are recursively sent through the cast pipeline.

Array Syntax

Jardis brings its own compact syntax for structured values, more readable than JSON and more natural in .env files:

ini
# Simple list
CACHE_LAYERS=[memory,redis]

# Associative array
DB_OPTIONS=[charset=>utf8mb4,collation=>utf8mb4_unicode_ci]

# Mixed with type casting
MIXED=[a=>1,2,b=>true,4.1,test=>[1,2,3]]

The last example results in:

php
[
    'a'    => 1,        // int (cast)
    0      => 2,        // int
    'b'    => true,     // bool (cast)
    1      => 4.1,      // float
    'test' => [1, 2, 3] // nested array
]

Include Directives

Above a certain project size, a single .env file becomes unwieldy. Database credentials, cache configuration, API keys, feature flags, all in one file. DotEnv provides load() directives that split configuration into domain-specific modules:

ini
APP_NAME=MyApp
APP_ENV=production

load(.env.database)
load(.env.logger)
load?(.env.optional)

APP_DEBUG=false

load() vs. load?()

DirectiveBehavior when file is missing
load(.env.database)Throws EnvFileNotFoundException
load?(.env.optional)Silently skipped

Include Cascade

Each load() directive automatically triggers a cascade, analogous to the two-stage loading:

ini
load(.env.database)

Loads (if they exist):

  1. .env.database
  2. .env.database.local
  3. .env.database.{APP_ENV}, if APP_ENV is known
  4. .env.database.{APP_ENV}.local

APP_ENV must be defined before load()

The cascade uses APP_ENV from the variable registry. Define APP_ENV before the first load() directive so that environment-specific include variants are loaded.

Path Resolution

  • Relative paths are resolved relative to the directory of the including file
  • Absolute paths are used directly
  • Quoted paths are allowed: load("config/my service.env")

Circular Includes

Circular include chains are detected and throw a CircularEnvIncludeException:

Circular include detected: .env → .env.database → .env → (abort)

File Secrets (_FILE Pattern)

In containerized environments, secrets are often mounted as files, Docker Secrets, Kubernetes Volumes, HashiCorp Vault. DotEnv supports this pattern natively:

ini
DB_PASSWORD_FILE=secrets/db_password
REDIS_TOKEN_FILE=secrets/redis_token

Behavior:

  • DB_PASSWORD_FILE is resolved: the contents of secrets/db_password are read and trimmed
  • The key in the result is DB_PASSWORD (without _FILE)
  • DB_PASSWORD_FILE itself does not appear in the result
  • The read value passes through the entire cast pipeline (including secret decryption)

Custom Handlers

The cast pipeline is open for project-specific extensions. Does the project need enum mapping, Base64 decoding or a custom DSL? A handler is an invokable with the signature __invoke(?string $value): mixed:

php
use JardisSupport\DotEnv\DotEnv;

// Handler as class
final class CastStringToEnum
{
    public function __invoke(?string $value = null): mixed
    {
        if ($value === null) {
            return null;
        }

        return match ($value) {
            'low'    => Priority::Low,
            'medium' => Priority::Medium,
            'high'   => Priority::High,
            default  => $value, // Not recognized → return string
        };
    }
}

$dotEnv = new DotEnv();
$dotEnv->addHandler(new CastStringToEnum());

Handler Position

php
// Append to end of pipeline (default)
$dotEnv->addHandler(new MyHandler());

// Prepend to beginning of pipeline (before all others)
$dotEnv->addHandler(new MyHandler(), prepend: true);

prepend: true is important for handlers that must run before type casting, e.g. secret decryption.

Removing Handlers

php
$dotEnv->removeHandler(CastStringToBool::class);

Integration with Secret

Passwords and API keys in plain text in .env files (even if they don't belong in VCS) are a security risk. With jardissupport/secret, values can be encrypted in the .env and are only decrypted at load time:

ini
DB_PASSWORD=secret(aes:base64encodedEncryptedValue)
API_KEY=secret(sodium:base64encodedEncryptedValue)
php
use JardisSupport\DotEnv\DotEnv;
use JardisSupport\Secret\Handler\SecretHandler;
use JardisSupport\Secret\Provider\FileKeyProvider;

$dotEnv = new DotEnv();
$dotEnv->addHandler(
    new SecretHandler(new FileKeyProvider('path/to/secret.key')),
    prepend: true  // Must run BEFORE type casting
);

$dotEnv->loadPublic(__DIR__);

Combinable with the _FILE pattern: the secret file can contain a secret(...) string.

Error Handling

ExceptionCause
EnvFileNotFoundExceptionload() references a non-existent file
EnvFileNotReadableExceptionFile exists but is not readable
CircularEnvIncludeExceptionCircular include chain detected

All exceptions inherit from DotEnvException.

php
use JardisSupport\DotEnv\Exception\EnvFileNotFoundException;
use JardisSupport\DotEnv\Exception\CircularEnvIncludeException;

try {
    $dotEnv->loadPublic(__DIR__);
} catch (EnvFileNotFoundException $e) {
    echo "File not found: " . $e->getFilePath();
} catch (CircularEnvIncludeException $e) {
    echo "Circular include: " . implode(' → ', $e->getIncludeStack());
}

Architecture

Under the hood, DotEnv follows the Closure-Orchestrator-Pattern, the architectural principle of all Jardis packages. Each processing stage is an independent, testable class with __invoke() as its only entry point:

DotEnv                          ← Orchestrator (public API)
├── LoadFilesFromPath           ← Resolve filenames
├── LoadValuesFromFiles         ← Read + process files
│   ├── ParseLoadDirective      ← Parse load() directives
│   └── CastTypeHandler         ← Orchestrate cast pipeline
│       ├── VariableRegistry    ← Store ${VAR} values
│       ├── CastStringToValue   ← ${VAR} interpolation
│       ├── CastUserHome        ← ~/ expansion
│       ├── CastStringToNumeric ← int/float
│       ├── CastStringToBool    ← bool
│       ├── CastStringToJson    ← JSON → array
│       └── CastStringToArray   ← [key=>val] → array

Directory Structure

src/
├── DotEnv.php                          ← Orchestrator
├── Reader/
│   ├── LoadFilesFromPath.php           ← Handler
│   ├── LoadValuesFromFiles.php         ← Handler
│   └── ParseLoadDirective.php          ← Handler
├── Handler/
│   ├── CastTypeHandler.php             ← Pipeline orchestrator
│   ├── VariableRegistry.php            ← Registry
│   ├── CastStringToValue.php           ← Handler
│   ├── CastUserHome.php                ← Handler
│   ├── CastStringToNumeric.php         ← Handler
│   ├── CastStringToBool.php            ← Handler
│   ├── CastStringToJson.php            ← Handler
│   └── CastStringToArray.php           ← Handler
└── Exception/
    ├── DotEnvException.php
    ├── CircularEnvIncludeException.php
    ├── EnvFileNotFoundException.php
    └── EnvFileNotReadableException.php

API Reference

DotEnv

MethodSignatureDescription
loadPublicloadPublic(string $path): voidLoads into $_ENV, $_SERVER, putenv()
loadPrivateloadPrivate(string $path): array<string, mixed>Returns isolated array
addHandleraddHandler(object $handler, bool $prepend = false): voidAdd handler to cast pipeline
removeHandlerremoveHandler(string $handlerClass): voidRemove handler by FQCN

Interface

DotEnv implements JardisSupport\Contract\DotEnv\DotEnvInterface with loadPublic() and loadPrivate(). The addHandler() and removeHandler() methods are extensions of the concrete class.

Complete Example

Everything together, modular configuration with includes, variable interpolation, file secrets and type casting:

ini
# .env
APP_NAME=MyApp
APP_ENV=production

load(.env.database)
load(.env.cache)
load?(.env.local.overrides)

LOG_PATH=~/logs/${APP_NAME}
APP_DEBUG=false
ini
# .env.database
DB_HOST=db.example.com
DB_PORT=3306
DB_NAME=myapp
DB_PASSWORD_FILE=secrets/db_password
DATABASE_URL=mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}
ini
# .env.cache
CACHE_LAYERS=[memory,redis]
REDIS_HOST=redis.example.com
REDIS_PORT=6379
php
use JardisSupport\DotEnv\DotEnv;

$dotEnv = new DotEnv();
$config = $dotEnv->loadPrivate(__DIR__);

// $config = [
//     'APP_NAME'     => 'MyApp',
//     'APP_ENV'      => 'production',
//     'DB_HOST'      => 'db.example.com',
//     'DB_PORT'      => 3306,
//     'DB_NAME'      => 'myapp',
//     'DB_PASSWORD'  => 's3cret!Pass',
//     'DATABASE_URL' => 'mysql://db.example.com:3306/myapp',
//     'CACHE_LAYERS' => ['memory', 'redis'],
//     'REDIS_HOST'   => 'redis.example.com',
//     'REDIS_PORT'   => 6379,
//     'LOG_PATH'     => '/home/deploy/logs/MyApp',
//     'APP_DEBUG'    => false,
// ]