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 casting —
truebecomesbool,42becomesint,[a,b,c]becomesarray. No more manual casts in application code. - Variable interpolation —
DATABASE_URL=mysql://${DB_HOST}/${DB_NAME}is resolved at load time. One source of truth, no duplicates. - Modular includes —
load(.env.database)splits configuration into domain-specific units. No more 200-line.envfiles. - Environment cascading —
.env→.env.local→.env.staging→.env.staging.local. Automatic, based onAPP_ENV. - Secure secrets —
_FILEpattern for Docker Secrets and Vault mounts. Encrypted values withjardissupport/secret. Plain-text passwords in.envare 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
composer require jardissupport/dotenvGitHub: jardisSupport/dotenv
Optional dependency for encrypted values:
composer require jardissupport/secretBasic Usage
Two lines of code, and the entire configuration is available typed.
Public load — write to global state
use JardisSupport\DotEnv\DotEnv;
$dotEnv = new DotEnv();
$dotEnv->loadPublic(__DIR__);The values are then available in $_ENV, $_SERVER and via getenv():
$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
$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)
| File | Purpose |
|---|---|
.env | Default values for all environments |
.env.local | Local overrides (not in VCS) |
Stage 2 — Environment-specific (when APP_ENV is set)
| File | Purpose |
|---|---|
.env.{APP_ENV} | Environment-specific values (e.g. .env.staging) |
.env.{APP_ENV}.local | Local 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 overridesNon-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
| # | Handler | Input | Output |
|---|---|---|---|
| 1 | CastStringToValue | ${DB_HOST} | resolved string |
| 2 | CastUserHome | ~/logs | /home/user/logs |
| 3 | CastStringToNumeric | "3306" | 3306 (int) |
| 4 | CastStringToBool | "true" | true (bool) |
| 5 | CastStringToJson | '{"a":1}' | ['a' => 1] (array) |
| 6 | CastStringToArray | [a=>1,b=>2] | ['a' => 1, 'b' => 2] (array) |
Variable Interpolation
Already loaded variables can be referenced with ${VAR}:
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:
LOG_PATH=~/logs
CACHE_DIR=~/cache/myappWorks on Unix ($HOME) and Windows (HOMEDRIVE + HOMEPATH). A HOME=/custom/path defined in .env is respected.
Numeric Values
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
DEBUG=true # bool: true
CACHE_ENABLED=yes # bool: true
VERBOSE=off # bool: falseJSON Values
Values that start with { or [ and are valid JSON:
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:
# 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:
[
'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:
APP_NAME=MyApp
APP_ENV=production
load(.env.database)
load(.env.logger)
load?(.env.optional)
APP_DEBUG=falseload() vs. load?()
| Directive | Behavior 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:
load(.env.database)Loads (if they exist):
.env.database.env.database.local.env.database.{APP_ENV}, ifAPP_ENVis known.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:
DB_PASSWORD_FILE=secrets/db_password
REDIS_TOKEN_FILE=secrets/redis_tokenBehavior:
DB_PASSWORD_FILEis resolved: the contents ofsecrets/db_passwordare read and trimmed- The key in the result is
DB_PASSWORD(without_FILE) DB_PASSWORD_FILEitself 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:
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
// 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
$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:
DB_PASSWORD=secret(aes:base64encodedEncryptedValue)
API_KEY=secret(sodium:base64encodedEncryptedValue)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
| Exception | Cause |
|---|---|
EnvFileNotFoundException | load() references a non-existent file |
EnvFileNotReadableException | File exists but is not readable |
CircularEnvIncludeException | Circular include chain detected |
All exceptions inherit from DotEnvException.
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] → arrayDirectory 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.phpAPI Reference
DotEnv
| Method | Signature | Description |
|---|---|---|
loadPublic | loadPublic(string $path): void | Loads into $_ENV, $_SERVER, putenv() |
loadPrivate | loadPrivate(string $path): array<string, mixed> | Returns isolated array |
addHandler | addHandler(object $handler, bool $prepend = false): void | Add handler to cast pipeline |
removeHandler | removeHandler(string $handlerClass): void | Remove 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:
# .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# .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}# .env.cache
CACHE_LAYERS=[memory,redis]
REDIS_HOST=redis.example.com
REDIS_PORT=6379use 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,
// ]