vlucas/phpdotenv
PHP dotenv loads environment variables from a .env file into getenv(), $_ENV and $_SERVER, keeping credentials and per-environment config out of code. A PHP port of Ruby dotenv, installable via Composer and works with Apache, Nginx, CLI and PHP's built-in server.
In a Laravel app you almost never touch this package directly — Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables already calls Dotenv::create(...) for you. So the first thing to look at is not the package, but:
.env.example in the repo root (the contract of required keys)..env on your machine (copy of the example; must be in .gitignore).config/*.php — the only place env() should ever be called.vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php — the exact create/load call and the repository configuration Laravel uses.Minimal bootstrap in a non-Laravel entry point (legacy script, cron job, standalone test bootstrap, package CLI):
require __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad(); // don't explode if .env is absent
$dotenv->required(['DB_HOST', 'DB_DATABASE'])->notEmpty();
$dotenv->required('DB_PORT')->isInteger();
$dsn = sprintf('mysql:host=%s;dbname=%s', $_ENV['DB_HOST'], $_ENV['DB_DATABASE']);
First real-world use case: onboarding a new developer — copy .env.example to .env, run php artisan key:generate, and have the app fail fast with a readable message (DATABASE_DSN is missing) instead of a random TypeError three layers down. That validation block is the single highest-value thing you can add on top of the default Laravel wiring.
Immutable by default, mutable only for local layering. createImmutable() means real environment variables (Docker, Kubernetes, Forge/Vapor secrets, CI) always win over the file. Keep that for production. For local multi-file layering, use mutable reads in order:
Dotenv\Dotenv::createMutable(__DIR__, '.env')->load();
Dotenv\Dotenv::createMutable(__DIR__, '.env.local')->safeLoad(); // dev overrides
Validate the contract at boot, not at use. Treat the fluent assertions as executable documentation of your environment:
$dotenv->required(['APP_KEY', 'DB_CONNECTION'])->notEmpty();
$dotenv->required('APP_DEBUG')->isBoolean();
$dotenv->required('SESSION_DRIVER')->allowedValues(['file', 'redis', 'database']);
$dotenv->required('MAIL_FROM_ADDRESS')->allowedRegexValues('/^.+@.+\..+$/');
$dotenv->ifPresent('QUEUE_WORKERS')->isInteger(); // optional but typed
Parse without loading for tests and assertions. This is the underused gem — it lets you verify .env.example in CI without touching the process environment:
$example = file_get_contents(base_path('.env.example'));
$parsed = Dotenv\Dotenv::parse($example); // nested ${VAR} resolved
$this->assertArrayHasKey('APP_KEY', $parsed);
Same API powers test fixtures and config-generation scripts where you want an array, not superglobals.
Custom repositories for constrained environments. Allow-listing is useful when a shared .env contains keys a sub-application must not see, and mixed read/write adapters let you read from $_SERVER (populated by FPM/fastcgi_param) while writing only to $_ENV:
$repository = Dotenv\Repository\RepositoryBuilder::createWithDefaultAdapters()
->allowList(['APP_*', 'DB_*']) // pattern-style allow lists
->immutable()
->make();
Dotenv\Dotenv::create($repository, __DIR__)->load();
Nesting to keep .env DRY — ${BASE_DIR} style interpolation works, but see the ordering caveat below.
In Laravel, always funnel through config. env() calls live in config/*.php; application code calls config(). This makes php artisan config:cache safe and keeps you deployable.
Testing pattern. Put fixed values in phpunit.xml (<env name="DB_CONNECTION" value="sqlite"/>) so tests never depend on a developer's .env, and use .env.testing (loaded automatically by Laravel when APP_ENV=testing) for anything else.
config:cache breaks env() outside config files. Once cached, .env is not read and env() returns null anywhere but config/. The classic symptom: works locally, returns null in production. Only ever call env() inside config/.createImmutable() will not override an existing value — including a stale export DB_HOST=localhost in someone's ~/.zshrc or a leftover FPM fastcgi_param. If a value "won't change", check getenv()/$_SERVER before blaming the file.$_ENV may be empty on shared hosting. If variables_order in php.ini lacks E (or S), superglobals are missing and writes appear to vanish. Check php -i | grep variables_order. This is the #1 "dotenv doesn't work" ticket.safeLoad() is not try/catch. It suppresses only the missing file exception; validation assertions still throw RuntimeException. Decide deliberately whether a missing .env is fatal.FOO= yields '', which passes required() but fails notEmpty(). Use notEmpty() for anything you concatenate into a DSN."false" is truthy in PHP. Either validate with isBoolean() or rely on Laravel's env() coercion of true/false/null/empty/(true)/(false). Don't hand-roll (bool) $_ENV['X'].${VAR} and escape sequences; single quotes are literal. A trailing # starts a comment only in the right context — v5.7 tightened these rules (BOM stripping, comments no longer start multiline values, safer single-char export parsing), so pin ^5.7 if you parse anything exotic.${BASE_DIR} must appear before use in the same file; values already in the environment are also resolvable, which is why immutability affects interpolation sources.getenv()/putenv() is not thread-safe — hence createUnsafeImmutable() being explicitly "unsafe". Avoid it under ZTS/Octane-style workloads; the default adapters are enough..env once. Changing it does nothing until restart; never read $_ENV at request time expecting live values.Dotenv\Dotenv::parse(file_get_contents('.env')) → $_ENV → $_SERVER → getenv() → config(). Whichever layer first differs tells you whether the problem is parsing, adapters, immutability, or config caching..env in .gitignore, .env.example committed and kept in sync (add a CI diff-keys check), real secrets injected at deploy time via your platform's secret store rather than shipped in the image, and never dump() the parsed array into logs or exception handlers.RepositoryBuilder (adapters + allowList + immutable) and the adapter interface are the supported seams; the parser itself is not intended for subclassing.How can I help you explore Laravel packages today?