Skip to content

levelbrookphp/typed-env

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

typed-env

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-env

The 60-second example

use 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

Why parse-don't-validate

"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.


Installation

composer require levelbrook/typed-env

PHP 8.2 or higher. No runtime dependencies.


Define → Parse → Access

1. Define a schema

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.

2. Parse the environment

// 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.

3. Access typed values

$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 names

Accessing 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.


Type and constraint reference

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.

bool coercion table

Raw string (case-insensitive) Resolves to
1, true, yes, on true
0, false, no, off false
anything else validation error

string constraints in detail

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()

Error messages

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);
}

Laravel integration

typed-env has an optional Laravel service provider that validates the environment during application boot. A misconfigured deploy fails before serving a single request.

Setup

// config/app.php (or use auto-discovery)
'providers' => [
    // ...
    Levelbrook\TypedEnv\Laravel\TypedEnvServiceProvider::class,
],

Publish the config file:

php artisan vendor:publish --tag=typed-env

Define your schema

Create 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.

Injection

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');

Testing

// 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);

License

MIT © Levelbrook Consulting. Built and maintained by Levelbrook's PHP practice — we do Laravel, Symfony, and legacy-rescue staff augmentation. Get in touch.

About

Parse-don't-validate, schema-validated environment config for PHP/Laravel — fail fast at boot, typed accessors.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages