m1/env
m1/env extends Laravel's environment handling, letting you load and cascade multiple .env files so shared, environment-specific and local values stay separate across local, staging and production setups.
m1/env is a read-only parser: you hand it the contents of a .env file and it returns a PHP array with type casting applied. It does not load variables into getenv()/$_ENV, and it is not a replacement for Laravel's own vlucas/phpdotenv bootstrap. Use it when you need to inspect, validate, diff, or import .env files that Laravel itself does not own.
Install and first parse
composer require m1/env
use M1\Env\Parser;
$contents = file_get_contents(base_path('.env.example'));
$env = (new Parser($contents))->getContent();
// $env is an associative array
// APP_ENV=local => 'local'
// APP_DEBUG=false => false (bool, not string)
// DB_PORT=3306 => 3306 (int, not string)
Where to look first
src/M1/Env/Parser.php — it is a small class; read the value-parsing method before trusting edge cases.M1\Env\Exception\ParseException — this is what malformed lines throw; catch it and add your file path, because the exception alone will not tell you which file failed.First real use case: a deploy preflight check
Before php artisan config:cache runs in CI, parse the target environment's .env and fail fast if required keys are missing or empty. This catches the classic "deployed with .env.example" incident without booting the whole framework against that file.
$env = (new Parser(file_get_contents($path)))->getContent();
foreach (['APP_KEY', 'DB_HOST', 'DB_DATABASE', 'DB_PASSWORD'] as $key) {
if (! array_key_exists($key, $env) || $env[$key] === null || $env[$key] === '') {
throw new RuntimeException("Missing required env key: {$key}");
}
}
Note the use of array_key_exists rather than empty(): parsed values can legitimately be false, 0, or '0', and empty() will reject them.
Version reality check
The last release was February 2020. The library is dependency-free and small, so it generally keeps working on modern PHP, but treat it as frozen: pin the major version in composer.json, run your test suite on your actual PHP version, and keep the parser behind a thin wrapper class so swapping to symfony/dotenv's parser or another implementation is a one-file change.
Do not scatter new Parser(...) calls across commands and providers. A small wrapper gives you consistent error messages, path resolution, and a place to cache results.
namespace App\Support;
use M1\Env\Exception\ParseException;
use M1\Env\Parser;
class EnvFileReader
{
/** @var array<string, array> */
private array $cache = [];
public function read(string $path): array
{
if (isset($this->cache[$path])) {
return $this->cache[$path];
}
if (! is_readable($path)) {
throw new \RuntimeException("Env file is not readable: {$path}");
}
// Strip a UTF-8 BOM; otherwise the first key silently becomes "\xEF\xBB\xBFAPP_NAME".
$contents = ltrim(file_get_contents($path), "\xEF\xBB\xBF");
try {
return $this->cache[$path] = (new Parser($contents))->getContent();
} catch (ParseException $e) {
// Never include values in the message — they are secrets.
throw new \RuntimeException("Could not parse env file: {$path}", 0, $e);
}
}
}
Register it as a singleton in a service provider. Because it is request-scoped in Laravel, the in-memory cache is usually enough; for long-running queue workers that read many tenant files, use a bounded cache or Cache::remember with a short TTL.
.env files to catch configuration driftThis is the highest-value day-to-day pattern: compare production/staging files against .env.example or against each other.
$expected = $reader->read(base_path('.env.example'));
$actual = $reader->read(base_path('.env'));
$missing = array_diff(array_keys($expected), array_keys($actual));
$extra = array_diff(array_keys($actual), array_keys($expected));
// Normalize before comparing values, because auto-casting produces different types:
$normalize = fn (array $env) => array_map(
fn ($v) => is_bool($v) ? ($v ? 'true' : 'false') : (string) $v,
$env
);
$changed = array_keys(array_diff_assoc($normalize($expected), $normalize($actual)));
Expose it as php artisan env:diff {file?} so it can run in CI or as a deploy step. This turns a class of "works on my machine" incidents into a failing pipeline.
.env into Laravel configWhen migrating a legacy app or integrating a service that ships its own .env, parse it and merge it under a namespaced config key rather than mutating the environment.
$legacy = app(EnvFileReader::class)->read(storage_path('legacy/.env'));
config([
'services.legacy' => [
'host' => $legacy['DB_HOST'] ?? null,
'port' => (int) ($legacy['DB_PORT'] ?? 3306),
'database' => $legacy['DB_DATABASE'] ?? null,
],
]);
Important: parsing does not make env('DB_HOST') return anything new. env() reads the repository loaded at bootstrap. Consume the array directly or via config(). Also remember that php artisan config:cache snapshots config at build time — if this file changes at runtime, you must re-cache or read it dynamically.
The parser is ideal for testing configuration-reading logic without touching the filesystem or the process environment:
$env = (new Parser("APP_DEBUG=false\nDB_PORT=3306\nAPP_NAME=\"My App\""))->getContent();
$this->assertFalse($env['APP_DEBUG']);
$this->assertSame(3306, $env['DB_PORT']);
$this->assertSame('My App', $env['APP_NAME']);
Write fixtures that cover quoted numbers, # inside values, empty values, and base64 keys. Those are the cases that break in production.
If downstream code expects strings (ports, IDs, version numbers), either quote in the file (DB_PORT="3306") or normalize immediately after parsing. Do the normalization once in the wrapper, document it, and never rely on the raw parser output being uniformly typed.
Auto-casting is the number one source of bugs. Unquoted true/false become booleans, null becomes null, and numeric-looking values become int/float. That means APP_VERSION=1.0 becomes the float 1.0, which string-concatenates to "1". DB_PORT=3306 becomes an int, so a strict === '3306' comparison fails. Phone numbers, ZIP codes, and zero-padded IDs lose their leading zeros. Quoted values are the escape hatch — wrap the value in single or double quotes to keep it a string.
Single vs double quotes are not equivalent. Single quotes are literal; double quotes interpret escape sequences (\n, \t, \", \\). For Windows paths, regexes, and anything containing backslashes, prefer single quotes — an unrecognized escape in double quotes may drop the backslash.
Unquoted # starts a comment. PASSWORD=abc#123 will likely parse as abc. Quote any value containing #, spaces, or shell-significant characters.
Values are split on the first =. Base64 values with trailing = padding usually survive, but when in doubt, quote them. Values containing spaces must be quoted.
Empty values are ambiguous. KEY= may resolve to null under auto-casting rather than ''. Use KEY="" when you need a genuine empty string, and test this against your installed version.
BOM and line endings. A UTF-8 BOM will corrupt the first key, producing an "undefined index" mystery. Strip it in your wrapper. CRLF is generally tolerated, but normalize if you see phantom parse failures.
Key naming. Stick to [A-Z0-9_]. Keys containing dots or dashes may not parse the way you expect; if you must represent nested config, encode it and expand in code.
No export prefix. Shell-style export KEY=value lines are not part of the format. Do not assume compatibility with files generated for source.
Interpolation: verify, don't assume. Do not assume phpdotenv-style ${VAR} expansion. Treat values as literal unless your installed version's tests prove otherwise, and resolve dependencies explicitly in PHP after parsing. This is safer anyway, because it makes the resolution order visible.
Duplicates: last one wins. Do not depend on it; a duplicate key is almost always a merge accident. Your diff command should flag duplicates by comparing against a line-based scan if this matters.
Do not putenv() the parsed values. It pollutes the process, breaks test isolation, and can shadow Laravel's own environment repository. Keep the parsed array as data and pass it through config() or method arguments.
Handle ParseException with context. Catch it in the wrapper, attach the file path, and log the line number and key if available — never the value. A malformed line in a 200-line production .env is otherwise painful to locate; a quick preg_split('/\R/', $contents) and a per-line trial parse will find the offender.
Security. These files contain credentials. Keep them outside public/, use 0600 permissions, never expose parsed arrays through a route or log, and be careful with dd()/dump() in commands that run in CI (CI logs are often broadly readable).
Extension points are limited. The parser is essentially a single class, and its internals may be private, so subclassing to customize casting is unreliable. Post-process the returned array in your wrapper instead, and keep the third-party class behind that wrapper so a future swap (e.g., to symfony/dotenv) does not ripple through your codebase.
How can I help you explore Laravel packages today?