Skip to content

Dotenv expand

composer require quillstack/dotenv-expand

Values built from other values in a .env file, resolved strictly: an unknown name is an error, never an empty string.

quillstack/dotenv reads the file. This resolves what one value says about another, and it is a separate package because that is a separate decision.

Why this exists

.env has no specification. Running the same file through eight implementations across six languages, they disagree about nearly every case that is not the obvious one — whether a bare $NAME counts, whether a name defined further down the file may be used further up, what \${ means, what ${NAME:-default} does.

On one case they agree, and it is the wrong one:

text
PASSWORD=pa${ss}word

where ss is not defined anywhere. symfony/dotenv, python-dotenv, dotenv-expand for Node, Ruby's dotenv, Dart's dotenv and dotenv-c all return paword. No error, nothing empty, and an application starting with a password nobody chose.

That is the one behaviour this package refuses to copy. Every rule here was chosen so that nothing has to be guessed, and where a guess would be needed it stops instead.

It is a separate package for the same reason: interpolation turns a list of pairs into a small language, with escaping and ordering to think about. Take it and you get ${SOMETHING}; leave it out and you pay nothing for it — and quillstack/dotenv refuses a file it cannot finish reading rather than handing you text that looks like an address and is not one.

Requirements

  • PHP 8.1 or newer

Installation

shell
composer require quillstack/dotenv-expand

Usage

text
APP_HOST=api.example.org
APP_URL=https://${APP_HOST}
DB_PORT=5432
DB_DSN=pgsql:host=127.0.0.1;port=${DB_PORT}
php
use Quillstack\DotenvExpand\Expand;

(new Expand('.env'))->load();
php
env('APP_URL');   // 'https://api.example.org'
env('DB_DSN');    // 'pgsql:host=127.0.0.1;port=5432'
env('DB_PORT');   // 5432, still a number

parse() reads without touching the environment, the same way round as the package underneath:

php
$values = (new Expand('.env'))->parse();

An already-built reader can be handed over instead of a path:

php
(new Expand(new Dotenv('.env')))->load();

The rules, and why they are narrow

An unknown name is an error

text
PASSWORD=pa${ss}word
text
UndefinedVariableException:
The value of `PASSWORD` uses `${ss}`, and `ss` is not defined above it in the file or in
the environment.

Only names already known

A name is resolved from what the file has already said, or from what the environment already held — never from further down the file:

text
FORWARD=${LATER}/x
LATER=defined-after

That is an error too. Otherwise a value depends on the order the file happens to be written in, and the libraries that allow it disagree about what it means: one resolves it, one leaves the text, one throws.

The environment this process started with counts as already known, which is what makes a value depend on where it is deployed rather than on what is in the file:

text
BUCKET=uploads-${DEPLOY_REGION}
php
env('BUCKET');   // 'uploads-eu-central-1'

Only ${NAME}, never $NAME

text
PASSWORD=hunter2$SOMETHING

That is a password and it comes back whole. Half the implementations of this idea expand a bare $NAME and half do not; passwords are full of dollar signs, and one quietly cut short is the worse of the two mistakes available.

\${ is a literal ${

text
PRICE=\${9.99}
php
env('PRICE');   // '${9.99}'

Four of the libraries measured below get this wrong the same way: they keep the backslash and expand, giving \https://example.org/v3.

No ${NAME:-default}

A shell expression, not a .env one. Ruby's and Dart's libraries return the text :-fallback} for it, which is neither the default nor an error. A default belongs where it can be read:

php
env('MISSING', 'fallback');

Types survive

quillstack/dotenv reads false as a boolean and 5432 as a number, and a name standing for one of those still stands for it:

text
DEBUG=false
MESSAGE=debug is ${DEBUG} here
php
env('MESSAGE');   // 'debug is false here'

Building on what was already resolved

A resolved value is simply a value, so the next line can use it:

text
APP_ENV=production
LOG_PATH=/var/log/${APP_ENV}
UPLOADS=${LOG_PATH}/uploads
php
env('LOG_PATH');   // '/var/log/production'
env('UPLOADS');    // '/var/log/production/uploads'

Making it optional

An application can work whether or not this package is installed:

php
use Quillstack\Dotenv\Dotenv;
use Quillstack\DotenvExpand\Expand;

$path = __DIR__ . '/../.env';

class_exists(Expand::class)
    ? (new Expand($path))->load()
    : (new Dotenv($path))->load();

With it installed, ${SOMETHING} resolves. Without it, a .env that uses one is refused and says so:

text
DotenvInterpolationNotSupportedException:
The value of `API` uses `${...}`, which this package does not expand. Install
quillstack/dotenv-expand to resolve it, or write `\${` for a literal `${`.

Which means the fallback is honest: an application never silently loads a half-read file because somebody forgot a dependency.

Benchmark

Measured with quillstack/benchmark on one file of 37 keys, 8 of which are built from another value. Runs are interleaved, each figure is the median of five, and PHP is 8.5.7.

Version
quillstack/dotenv-expandv0.6.1 (on quillstack/dotenv v0.7.1)
symfony/dotenvv7.4.15
josegonzalez/dotenv4.0.0 (on m1/env 2.2.0)
vlucas/phpdotenvv5.6.4

All four resolve that file to the same values. Reading it, once:

Per loadRelative
quillstack/dotenv-expand190 µs
symfony/dotenv266 µs1.40×
josegonzalez/dotenv275 µs1.44×
vlucas/phpdotenv470 µs2.47×
quillstack/dotenv alonerefuses the file

The last row is the point of the arrangement rather than a gap in it: quillstack/dotenv cannot finish reading a file that uses ${…}, so it stops and names this package instead of handing back the literal text.

What it costs to add

On a file with no interpolation in it at all — where this package has nothing to resolve and is pure overhead — the reader alone takes 146 µs and the pair takes 176 µs. Adding interpolation costs about a fifth of the reading time, and that table is in quillstack/dotenv's README, measured the same way on the same machine so the two can be compared.

What the numbers do not say: symfony/dotenv and vlucas/phpdotenv also read .env.local layering, shell command substitution and ${NAME:-default}, none of which is here. Being faster because you do less is not being faster — what this package claims is the strictness, and the speed is what that strictness happens to cost, which is nothing.

benchmark:console reports Took and calls per second too; both are dominated by PHP process start-up, identical for every library. The figure that means anything is avg call time, which each measured script reports about itself.

Tests

shell
composer test
composer stan

The rest of Quillstack

This is one component of Quillstack, a PHP framework which is as simple to use as it is strict about what it does.

License

MIT — see LICENSE.

Released under the MIT License.