Config validation
Config provides the repeated mechanism — read a key, check its shape, fail loudly on misconfiguration — while the domain bounds stay at your call site. Every reader is strict: the default applies only to a key that is not set — absent, null, or blank ('' or whitespace only, what a host’s KEY= line yields) — and any other invalid value throws InvalidConfigurationException naming the key and the value. The static readers read the global config repository:
use RoundlyConsulting\PackageToolkit\Support\Config;
// Strict config readers: the default applies only to a key that is not set (absent, null, or
// blank like a host's KEY=); an invalid value throws InvalidConfigurationException naming
// the key and the value.
Config::integer('comments.per_page', 20, min: 1, max: 100); // '20' → 20; '' → 20; 'five', '5.5', '1e3' throw
Config::requireString('comments.table'); // not set (absent or blank) throws missing
Config::enum('comments.hash_algo', HashAlgorithm::class); // no default: missing throws too
Config::enum('comments.sort', Sort::class, Sort::Newest); // absent → Sort::Newest; 'neweset' throws
Config::oneOf('comments.renderer', ['markdown', 'plain'], 'markdown'); // absent → 'markdown'; 'html' throws
Config::boolean('comments.allow_guests', false); // 'on'/'off' etc.; 'ture' throws| Accessor | Not set (absent, null or blank) | Value invalid |
|---|---|---|
integer(string $key, int $default, ?int $min = null, ?int $max = null) | Returns $default — range-checked like any other value. | Throws unless an int or a canonical integer string ('30', '-5', ' 30 '); 'five', '5.5', '1e3' or an overflowing number throw, and so does a value outside $min / $max. |
requireString(string $key) | Throws missing — required but not set; a blank or whitespace-only string counts as not set. | Throws notAString for any non-string (an int, a bool, an array). |
enum(string $key, string $enum, ?BackedEnum $default = null) | Returns $default; throws when no default is given. | Throws, listing the allowed values — even when a default is given. A case passes through; a backing value matches exactly and case-sensitively, after coercion to the backing type. |
oneOf(string $key, array $allowed, string $default) | Returns $default — which must itself be in $allowed. | Throws, listing $allowed, unless a string in it (exact, case-sensitive). |
boolean(string $key, bool $default = false) | Returns $default. | Throws unless true / 1 / on / yes or false / 0 / off / no (case-insensitive, trimmed). |
Every message has one shape — Configuration value [comments.per_page] must be between 1 and 100, [500] given. — so the key and the offending value are always in front of the reader.
Integers
Every env value is a string, so integer() accepts an int or a canonical integer string: an optional -, decimal digits, surrounding whitespace ignored ('30', '-5', ' 30 '). A blank '' is not set and gives the default. It rejects what a blunt (int) cast would quietly turn into a different number — 'five', '5.5', '5abc', '1e3', '0x10', '+5', an overflowing number, a float, a bool or an array all throw. The $min / $max range check applies to the default too:
use RoundlyConsulting\PackageToolkit\Support\Config;
// .env: COMMENTS_PER_PAGE=" 30 " — surrounding whitespace is ignored
Config::integer('comments.per_page', 20, min: 1, max: 100); // 30
// COMMENTS_PER_PAGE unset, or blank (COMMENTS_PER_PAGE=) — not set, so the default,
// range-checked like any other value
Config::integer('comments.per_page', 20, min: 1, max: 100); // 20
// .env: COMMENTS_PER_PAGE=1e3 — not a canonical integer string
Config::integer('comments.per_page', 20, min: 1, max: 100);
// throws: Configuration value [comments.per_page] must be an integer, [1e3] given.
// .env: COMMENTS_PER_PAGE=500 — out of range
// throws: Configuration value [comments.per_page] must be between 1 and 100, [500] given.Enums and fixed vocabularies
enum() matches a case of the enum, or its backing value exactly and case-sensitively ('UUID' does not match 'uuid'). The value is coerced to the backing type first — '2' resolves a case of an int-backed enum (by the integer rules above), and an int resolves a numeric string-backed case. Anything else throws, listing the allowed values, even when a default is given; a TypeError never escapes. oneOf() is the same contract for a plain string vocabulary with no enum:
use RoundlyConsulting\PackageToolkit\Support\Config;
enum Priority: int
{
case Low = 1;
case High = 2;
}
// .env: COMMENTS_PRIORITY=2 — every env value is a string
Config::enum('comments.priority', Priority::class); // Priority::High
// .env: COMMENTS_PRIORITY=2.0 — not a canonical integer string, so it names no case
Config::enum('comments.priority', Priority::class, Priority::Low); // throws — a default never stands in for a typo
Config::enum('comments.priority', Priority::class); // throws InvalidConfigurationExceptionNot set versus invalid
A blank value is not a typo: '' or whitespace only — what a host’s KEY= line yields — counts as not set and takes the default, exactly like an absent key. requireString() — and enum() called without a default — has no default to fall back on, so a blank value throws missing there. But a default never stands in for a typo. An env value that is present but wrong — an enum typo, a boolean spelled 'disabled', a renderer outside the vocabulary — stops the app with a message naming the key and the value, instead of silently downgrading to the default:
use RoundlyConsulting\PackageToolkit\Enums\KeyType;
use RoundlyConsulting\PackageToolkit\Support\Config;
// config('comments.key_type') === 'uudi' — a typo
Config::enum('comments.key_type', KeyType::class, KeyType::BigInt);
// throws InvalidConfigurationException — the default covers only a key that is not set:
// Configuration value [comments.key_type] must be one of [bigint, uuid, ulid], [uudi] given.
// .env: COMMENTS_ALLOW_GUESTS=disabled
Config::boolean('comments.allow_guests', false);
// throws InvalidConfigurationException:
// Configuration value [comments.allow_guests] must be a boolean (true/false, 1/0, on/off or yes/no), [disabled] given.
// config('comments.renderer') === 'html'
Config::oneOf('comments.renderer', ['markdown', 'plain'], 'markdown');
// throws InvalidConfigurationException:
// Configuration value [comments.renderer] must be one of [markdown, plain], [html] given.Validating a DTO’s input
The static accessors read the global config repository by key. When a DTO validates an array it was handed (a fromArray()), reading the repository would let a value the caller never passed slip through — so start a validator bound to that array with Config::for(). Keys support dot notation. Nominate your package’s own exception class as the second argument and misconfiguration surfaces through your hierarchy, not the toolkit’s:
use RoundlyConsulting\PackageToolkit\Support\Config;
final readonly class PasskeyConfig
{
public static function fromArray(array $config): self
{
$v = Config::for($config, PasskeyException::class);
return new self(
timeout: $v->integer('timeout', 60, min: 1, max: 300), // validates the 99999 you were handed
trust: $v->enum('attestation', TrustMode::class), // a typo throws PasskeyException, never a silent downgrade
rpId: $v->requireString('rp_id'),
);
}
}Your exception, global config
Config::using() gives the same nominated-exception validator while still reading the global repository — for config a package owns outright. The nominated class must accept a string message; the toolkit’s message is preserved:
use RoundlyConsulting\PackageToolkit\Support\Config;
$v = Config::using(CommentsException::class);
$perPage = $v->integer('comments.per_page', 20, min: 1, max: 100);
$algo = $v->enum('comments.hash_algo', HashAlgorithm::class);
// a misconfiguration throws CommentsException, carrying the toolkit's messageBoth return a ConfigValidator with every reader the static accessors have — integer(), requireString(), enum(), oneOf() and boolean() — so a switch in a handed array fails loudly too. ConfigValidator::forArray() and ConfigValidator::forRepository() are the underlying constructors:
$v = Config::for($config, PasskeyException::class);
$v->boolean('user_verification', true); // absent or blank → true; 'ture' throws PasskeyException
Config::using(PasskeyException::class)->boolean('passkeys.enabled', false);Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.