Environment loading — reading .env without a library
Concept
Environment variables are configuration values that differ between deployment environments — local, staging, production — and that contain secrets which must never be committed to version control. The .env file (popularised in PHP by the vlucas/phpdotenv library that Laravel uses) provides a simple KEY=VALUE syntax for setting these variables before your application boots.
The underlying mechanism is PHP's $_ENV superglobal and the putenv() / getenv() functions. When you set DB_PASSWORD=secret in .env, your loader calls putenv('DB_PASSWORD=secret') and also sets $_ENV['DB_PASSWORD'] = 'secret'. Code then reads it via $_ENV['DB_PASSWORD'] or getenv('DB_PASSWORD'). The phpdotenv library also populates $_SERVER for compatibility with hosting environments where $_ENV is disabled.
Why not use a library? Loading .env without vlucas/phpdotenv is genuinely simple — the file format is line-based and easy to parse. Writing our own loader teaches you exactly what the library does: open the file, skip comments and blank lines, split on the first =, strip quotes, call putenv(). The tricky parts are: quoted values (DB_NAME="my database"), inline comments (PORT=3306 # default), and variable interpolation (URL=http://${HOST}). Our implementation will handle the first two; we omit interpolation to keep the code readable.
The critical security rule: .env must be in .gitignore. Instead, commit a .env.example with the same keys but no real values. This documents what variables the application needs without exposing secrets. Laravel's starter projects all follow this pattern.
An important operational detail: in production you should NOT use a .env file at all. Production secrets should be injected via the operating system's environment (Docker env vars, Kubernetes secrets, AWS Parameter Store). The .env file is a development convenience. Laravel's config:cache command actually removes the need for runtime .env parsing in production.
Code Example
<?php
declare(strict_types=1);
namespace Lumen\Foundation\Bootstrap;
/**
* Loads the .env file and populates $_ENV, $_SERVER, and putenv().
*
* Supports:
* - KEY=VALUE (basic)
* - KEY="quoted value" (double quotes)
* - KEY='quoted value' (single quotes)
* - # comment lines (skipped)
* - inline comments (KEY=value # comment → strips the comment)
*
* Laravel equivalent: vlucas/phpdotenv loaded via
* Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables
*/
class LoadEnvironmentVariables
{
public function __construct(
private readonly string $path,
private readonly string $filename = '.env'
) {}
public function bootstrap(): void
{
$file = $this->path . DIRECTORY_SEPARATOR . $this->filename;
if (!is_file($file) || !is_readable($file)) {
// In production you may run without a .env file — that's fine.
// Variables come from the OS environment instead.
return;
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
throw new \RuntimeException("Unable to read environment file: {$file}");
}
foreach ($lines as $line) {
$line = trim($line);
// Skip comments
if (str_starts_with($line, '#')) {
continue;
}
// Must contain an = sign
if (!str_contains($line, '=')) {
continue;
}
[$name, $value] = $this->parseLine($line);
// Only set if not already set (OS env takes priority)
if (getenv($name) === false) {
$this->setVariable($name, $value);
}
}
}
/**
* Parse a single KEY=VALUE line.
* Handles quoted values and strips inline comments.
*
* @return array{0: string, 1: string}
*/
private function parseLine(string $line): array
{
// Split on the FIRST = only (values may contain =)
$pos = strpos($line, '=');
$name = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
// Remove surrounding quotes
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
$value = substr($value, 1, -1);
} elseif (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
} else {
// Strip inline comment: KEY=value # this is a comment
if (str_contains($value, ' #')) {
$value = trim(strstr($value, ' #', true));
}
}
return [$name, $value];
}
private function setVariable(string $name, string $value): void
{
putenv("{$name}={$value}");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
// ------------------------------------------------------------------
// Helper function (app-level, not framework) — env()
// ------------------------------------------------------------------
/**
* Get an environment variable with a typed default.
*
* env('APP_DEBUG', false) → false if APP_DEBUG not set
* env('APP_DEBUG', false) → true if APP_DEBUG='true'
*/
function env(string $key, mixed $default = null): mixed
{
$value = getenv($key);
if ($value === false) {
return $default;
}
return match (strtolower($value)) {
'true', '(true)' => true,
'false', '(false)' => false,
'null', '(null)' => null,
'empty', '(empty)' => '',
default => $value,
};
}Interview Q&A
Q: Why does our loader skip variables that are already set in the environment (getenv($name) !== false)? What scenario does this handle?
In production deployments, secrets are injected into the process environment by the container orchestrator (Docker, Kubernetes) or the hosting platform (Heroku, AWS Elastic Beanstalk). If the .env file also defines those variables, you want the OS-injected values to win — they come from a secure secrets manager, not a file on disk. By checking getenv($name) === false before setting, we give OS environment variables priority over .env file values. Laravel's phpdotenv does the same thing via the Dotenv\Repository\Adapter\PutenvAdapter with immutable mode.
Q: Why should production deployments not use a .env file?
Three reasons: (1) Files on disk can be read by other processes running on the same host, or leaked via directory traversal bugs. (2) Updating a secret requires SSH access and a file edit, making automated secret rotation difficult. (3) When running multiple instances behind a load balancer, you must keep .env files in sync across all instances. Environment variables injected by the orchestrator avoid all three problems — they are instance-specific, managed by a secrets backend, and never touch the filesystem. Laravel recommends running php artisan config:cache in production which compiles config (including env calls) into a single PHP file, removing the getenv() calls from the hot path entirely.
Q: What is the env() helper's match expression doing for 'true' and 'false'?
Environment variables are strings at the OS level — there is no native boolean type. If you set APP_DEBUG=true in your .env file, getenv('APP_DEBUG') returns the string 'true', not the boolean true. The match expression converts these string representations to their PHP equivalents so that env('APP_DEBUG', false) returns true (boolean) not 'true' (string). This matters for strict type checking: if (env('APP_DEBUG') === true) would fail without this conversion. The same logic handles null and empty. Laravel's Illuminate\Support\Env::getOrFail() does the same normalisation.