sixlive/dotenv-editor
sixlive/dotenv-editor is a PHP package for reading and editing .env files programmatically. Update, add, or delete individual environment variables while preserving comments, whitespace, and file structure—ideal for Laravel apps and tooling that needs safe, targeted .env changes.
Install the package in your Laravel app:
composer require sixlive/dotenv-editor
There is no service provider to register if you're on Laravel 5.5+ (auto-discovery), and no publishable config to worry about — the package operates directly against a path you hand it, defaulting to base_path('.env').
The fastest first use case is writing a key from a console command or a setup controller:
use Sixlive\DotenvEditor\DotenvEditor;
$editor = new DotenvEditor();
$editor->load(); // defaults to base_path('.env')
$editor->setKey('APP_NAME', 'Acme');
$editor->save(); // nothing is written until save()
The three methods you'll spend 90% of your time in are load(), setKey()/addKey(), and save(). Read those three first in the source (src/DotenvEditor.php) — the rest of the API is buffer and line manipulation built on top of the same parsed file.
Use the facade (DotenvEditor::) if you want the singleton buffer to persist across calls in one request; use new DotenvEditor() if you want isolation (e.g. editing two different .env files in the same job). The facade is bound to a single instance, so state leaks between calls — that's a feature for chained edits, a footgun for parallel file operations.
The canonical first project: an install/onboarding wizard that collects DB credentials, writes them to .env, and then nudges you to php artisan config:clear.
Chained editor in a setup command. The most common real-world use is a custom Artisan command that mutates several keys in one pass so the parse/serialize cost is paid once:
DotenvEditor::load(config('app.env_path', base_path('.env')))
->setKey('DB_DATABASE', $db)
->setKey('DB_USERNAME', $user)
->setKey('DB_PASSWORD', $password)
->save();
setKey overwrites if the key exists and appends if it doesn't, which is what you want for idempotent setup flows.
Idempotent provisioning with addKey. When you're seeding defaults you don't want to clobber user edits. Pair addKey with keyExists for anything that might be hand-tuned by ops:
if (! DotenvEditor::keyExists('QUEUE_CONNECTION')) {
DotenvEditor::addKey('QUEUE_CONNECTION', 'redis');
}
Actually addKey already no-ops when the key is present, so the keyExists guard is only needed when the value computed is expensive or when you want to branch (e.g. log that you skipped it).
Toggle features from an admin panel. A boolean feature flag stored in .env and flipped by an admin action is a reasonable pattern if you accept that config is cached:
DotenvEditor::load();
DotenvEditor::setKey('FEATURE_BETA', $enabled ? 'true' : 'false');
DotenvEditor::save();
Artisan::call('config:clear');
Always pair the write with a config cache clear; without it the change is invisible to the running app.
Per-environment targets. load() takes a path, so a deploy script can target .env.production or a tenant-scoped .env.tenant_42 without touching the active file. This is the cleanest way to avoid the "I edited the wrong env" class of incident — pass the explicit path rather than relying on the default.
Reading without writing. getKey() reads from the loaded buffer, which is useful for a pre-flight diff ("here's what will change") before save(). Combine with getContent() to render a confirmation screen in an installer UI. Since nothing hits disk until save(), you can validate the whole batch first and bail before mutating the file.
Testing. Because the constructor takes a path in load(), point it at a fixture in tests/fixtures/.env.test and assert against getContent() or a fresh load() of the written file. There's no filesystem abstraction baked in, so use Storage::fake() only if you're wrapping the path yourself — the package calls PHP file functions directly.
Extension point: post-write hooks. If you need to react to a change (flush OPcache, rotate secrets, notify), wrap the editor in your own small service rather than scattering DotenvEditor::save() calls. A single EnvWriter class with set(string $k, string $v) and an event dispatch after save() keeps the third-party call surface tiny and makes swapping the implementation later trivial.
Nothing is written until save(). The most common bug report is "my change didn't stick" — someone chained setKey and forgot save(). The buffer is in-memory; every mutation is deferred.
The facade is a singleton with a live buffer. Two sequential DotenvEditor::load() calls reset the buffer, so editing two files in one request via the facade will silently discard the first file's pending changes if you forget to save() between them. Instantiate directly for multi-file work.
Value quoting is on you. setKey('PASSWORD', 'p@ss word#1') writes the raw string; the parser on the next load() will treat an unquoted # as a comment and truncate the value, and unquoted spaces will break parsing depending on the parser version. Pre-quote values you don't control:
$value = '"' . addcslashes($raw, '"\\') . '"';
DotenvEditor::setKey('DB_PASSWORD', $value);
You'll then see the quotes on disk — that's expected and correct for dotenv semantics, but it surprises people diffing the file.
Booleans, nulls, and arrays aren't first-class. Everything is a string. setKey('X', true) will coerce to "1". Convert explicitly: true/false → 'true'/'false', null → 'null' or delete the key, arrays → JSON or a comma list. Decide the convention per key and document it, because env('X') in Laravel returns strings unless you use env('X', false) casting.
Config cache is not invalidated for you. Editing .env updates the file, not env() at runtime and not config(). In a request lifecycle you must config:clear (or restart the worker) before the change is observable. In Octane/Swoole this is doubly true — the config is held in worker memory.
File permissions bite in web-triggered installers. save() will throw a RuntimeException (or emit a PHP warning depending on your error handler) when the web server user can't write .env. Check writability up-front in setup wizards: is_writable(base_path('.env')), and give the user an actionable error rather than a 500.
Loading a non-existent file is not graceful. Ensure the target exists — or catch and create it — before calling load() in a first-run scenario. A common fix is touch($path) guarded by file_exists, then load($path).
No file locking. save() is a plain write. Concurrent edits (two deploys, an admin action during a deploy) can lose the second write. For anything multi-process, treat .env as config-as-code and avoid runtime writes entirely, or wrap your writes in flock in your own service.
Comments and ordering are preserved, new keys go to the end. Existing keys keep their position and inline comments; appended keys land at the bottom in the order you set them. If your repo enforces a canonical .env.example order, don't rely on this package to maintain it — re-sort in your own layer or generate the file wholesale.
Empty-line helpers are a maintenance smell. addEmptyLine() and deleteEmptyLines() exist, but relying on deleteEmptyLines() will also strip any deliberately-grouped blank lines a human added for readability. Use them once for cleanup, not as part of every write.
Windows line endings. If the file has CRLF, the editor's line splitting may leave \r artifacts in parsed values on some PHP/dotenv versions. Normalise to LF in your own preprocessing step if you deploy to mixed platforms.
Escape hatches. setBuffer() and getBuffer() give you the raw string array — useful when you need to do something the API doesn't model (reorder blocks, insert a comment banner, drop a whole section). Anything you can express as string surgery on the buffer you can do there, then save(). Prefer this over patching the file with file_put_contents yourself; you keep the package's write semantics.
Debugging a bad write. When a key "disappears" after a write, dump getBuffer() before save() and again after re-load(). The diff almost always shows a quoting issue, a key name typo, or a stray # from a value you didn't escape.
How can I help you explore Laravel packages today?