Parse-don't-validate, schema-validated environment configuration for PHP.
PHP's getenv() and Laravel's env() return loosely typed strings with no
validation. A typo in APP_DEBUG, a missing DATABASE_URL, or an out-of-range
PORT does nothing at boot — it silently passes until some code path deep in a
request trips over a string where an integer was expected. By then the stack
trace points at the wrong place and the error message is cryptic.
typed-env fixes this at the source: you declare a schema once, parse at
boot, and get back a ParsedEnv object with fully typed accessors. If anything
is wrong the library throws a single InvalidEnvironmentException listing
every problem — missing required vars, wrong types, out-of-range ints, invalid
URLs, illegal enum values — before a single request is served.
Inspired by T3 Env and the Zod-env pattern from the JavaScript world. Original PHP take, no dependencies.
composer require levelbrook/typed-envuse Levelbrook\TypedEnv\Env;
use Levelbrook\TypedEnv\Type\Type;
$env = Env::define([
'APP_DEBUG' => Type::bool()->default(false),
'PORT' => Type::int()->min(1)->max(65535)->default(8080),
'APP_URL' => Type::url()->required(),
'LOG_LEVEL' => Type::enum(['debug','info','warning','error'])->default('info'),
'API_KEY' => Type::string()->required()->minLength(10),
])->parse($_ENV);
// All values are now coerced, validated, and typed.
$env->int('PORT'); // int — 8080 if absent
$env->bool('APP_DEBUG'); // bool — false if absent
$env->string('APP_URL'); // string
$env->string('LOG_LEVEL'); // string, guaranteed to be one of the four literals
$env->get('API_KEY'); // mixed (the coerced native value)If the environment is bad you get one exception immediately:
Levelbrook\TypedEnv\Exception\InvalidEnvironmentException:
Environment configuration is invalid:
- APP_URL: required but not set
- PORT: must be >= 1, got 0
- API_KEY: must be at least 10 character(s), got 5
"Validate" means check a value and reject it if bad. "Parse" means transform an
untyped raw value into a typed domain object that cannot represent an invalid
state. The distinction matters because once $env->int('PORT') succeeds you hold
a genuine PHP int, not a string that happens to look like one. You cannot pass
it to code that expects a string by accident; the type system enforces the contract.
typed-env is a narrow application of this principle to environment
configuration: take the stringly-typed chaos of $_ENV, run it through a schema
at boot, and hand the rest of the application a sealed, typed object that only
exposes what was declared.
composer require levelbrook/typed-envPHP 8.2 or higher. No runtime dependencies.
use Levelbrook\TypedEnv\Env;
use Levelbrook\TypedEnv\Type\Type;
$schema = Env::define([
'DB_HOST' => Type::string()->required(),
'DB_PORT' => Type::int()->min(1)->max(65535)->default(5432),
'APP_DEBUG' => Type::bool()->default(false),
'REDIS_URL' => Type::url()->required(),
'LOG_LEVEL' => Type::enum(['debug','info','warning','error'])->default('info'),
'CONCURRENCY' => Type::float()->min(0.0)->max(1.0)->default(0.5),
'SENTRY_DSN' => Type::string()->nullable(),
]);Env::define() returns an EnvSchema object. Nothing is validated yet.
// From PHP's superglobal (populated by most web SAPIs and dotenv loaders):
$env = $schema->parse($_ENV);
// From getenv() (populated by the system environment and putenv()):
$env = $schema->parse(); // defaults to getenv() when null is passed
// From a fixture in tests:
$env = $schema->parse([
'DB_HOST' => 'localhost',
'REDIS_URL' => 'redis://localhost:6379',
]);parse() coerces and validates every declared key. If any key fails validation
the method throws InvalidEnvironmentException with all errors listed. On success
it returns a ParsedEnv.
$env->string('DB_HOST'); // string
$env->int('DB_PORT'); // int
$env->bool('APP_DEBUG'); // bool
$env->float('CONCURRENCY'); // float
$env->get('LOG_LEVEL'); // mixed (the coerced native PHP value)
$env->nullable('SENTRY_DSN'); // mixed|null; never throws for null values
$env->nullable('SENTRY_DSN', 'https://fallback.sentry.io'); // with fallback
$env->toArray(); // array<string, mixed> — all parsed values
$env->keys(); // list<string> — all declared key namesAccessing a key that was not declared in the schema throws
UndeclaredKeyException immediately. This is a programming error, not an ops
error, so it surfaces at development time rather than silently returning null.
| Factory | PHP type after parse | Constraints available |
|---|---|---|
Type::string() |
string |
minLength(int), maxLength(int), pattern(string) |
Type::int() |
int |
min(int|float), max(int|float) |
Type::float() |
float |
min(int|float), max(int|float) |
Type::bool() |
bool |
— |
Type::enum(array) |
string |
set at construction: Type::enum(['a','b','c']) |
Type::url() |
string |
must be http:// or https:// with a host |
All types support the three optionality modifiers:
| Modifier | Meaning |
|---|---|
->required() |
Missing or empty value is an error. |
->nullable() |
Missing value resolves to null without error. |
->default(mixed) |
Missing value uses this fallback (returned as-is, cast when accessed via a typed accessor). |
->required() and ->default() are mutually exclusive in intent: a required
field with a default is technically valid but the default is never used (the
field must be present). If neither is set, an absent value resolves to null.
| Raw string (case-insensitive) | Resolves to |
|---|---|
1, true, yes, on |
true |
0, false, no, off |
false |
| anything else | validation error |
Type::string()
->minLength(1) // at least 1 UTF-8 codepoint (mb_strlen)
->maxLength(255) // at most 255 UTF-8 codepoints
->pattern('/^[a-z]+$/') // must match this PCRE pattern
->required()All errors name the variable and explain the problem:
APP_URL: required but not set
PORT: must be >= 1, got 0
PORT: expected integer, got "banana"
LOG_LEVEL: must be one of ["debug", "info", "warning", "error"], got "verbose"
API_KEY: must be at least 10 character(s), got 5
APP_URL: URL must use http or https scheme, got "ftp://files.example.com"
FLAG: expected boolean (1/0/true/false/yes/no/on/off), got "enabled"
The $e->errors property holds the raw array when you need to log or display
individual messages:
try {
$env = Env::define($schema)->parse();
} catch (InvalidEnvironmentException $e) {
foreach ($e->errors as $error) {
logger()->critical('Bad env: ' . $error);
}
exit(1);
}typed-env has an optional Laravel service provider that validates the
environment during application boot. A misconfigured deploy fails before serving
a single request.
// config/app.php (or use auto-discovery)
'providers' => [
// ...
Levelbrook\TypedEnv\Laravel\TypedEnvServiceProvider::class,
],Publish the config file:
php artisan vendor:publish --tag=typed-envCreate app/Env/AppEnvSchema.php:
<?php
namespace App\Env;
use Levelbrook\TypedEnv\Laravel\EnvSchemaProvider;
use Levelbrook\TypedEnv\Type\Type;
final class AppEnvSchema implements EnvSchemaProvider
{
public function schema(): array
{
return [
'APP_DEBUG' => Type::bool()->default(false),
'APP_URL' => Type::url()->required(),
'LOG_LEVEL' => Type::enum(['debug','info','warning','error'])->default('info'),
'DATABASE_URL' => Type::url()->required(),
'CACHE_TTL' => Type::int()->min(0)->default(3600),
];
}
}Then reference it in config/typed-env.php:
return [
'schema' => \App\Env\AppEnvSchema::class,
];The EnvSchemaProvider interface is optional. The config also accepts:
- A file path that returns an
array<string, TypeBuilder>. - A closure
fn ($app) => [...]for dynamic schemas.
Once the provider runs, a ParsedEnv singleton is registered in the container:
use Levelbrook\TypedEnv\ParsedEnv;
class MyController
{
public function __construct(private readonly ParsedEnv $env) {}
public function index(): Response
{
if ($this->env->bool('APP_DEBUG')) {
// ...
}
}
}
// Or via the helper:
$port = app(ParsedEnv::class)->int('PORT');// In tests, pass a fixture array directly — no real environment needed:
$env = Env::define([
'APP_URL' => Type::url()->required(),
'PORT' => Type::int()->default(8080),
])->parse([
'APP_URL' => 'https://example.test',
'PORT' => '9000',
]);
expect($env->int('PORT'))->toBe(9000);MIT © Levelbrook Consulting. Built and maintained by Levelbrook's PHP practice — we do Laravel, Symfony, and legacy-rescue staff augmentation. Get in touch.