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::float('comments.sample_rate', 1.0, min: 0, max: 1); // '0.25' → 0.25; '1e3', '0,5', '.5' throw
Config::requireString('comments.table'); // not set (absent or blank) throws missing
Config::string('comments.queue', 'default'); // blank COMMENTS_QUEUE= → 'default'; 5 throws
Config::list('comments.locales', ['en']); // 'en, sk' → ['en', 'sk']; ',' → ['en']
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
Config::requireSecret('comments.akismet_key'); // errors name the type, never the value| 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. |
float(string $key, float $default, ?float $min = null, ?float $max = null) | Returns $default — range-checked too; a NAN or infinite default throws. | Throws unless an int, a finite float or a canonical decimal string ('0.25', '-0.5', ' 1 '); '1e3', '0,5', '.5', '5.', '+1' or 'NAN' 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). |
string(string $key, string $default) | Returns $default — a host’s blank KEY= included. | Throws notAString for any non-string (an int, a float, a bool, an array). A present string comes back untrimmed. |
list(string $key, array $default, ?Closure $each = null) | Returns $default — also when the value yields no items (',', []). | Throws for a value that is neither an array nor a string, an array item that is not a string, or an item $each does not return true for. |
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). |
secret(string $key) | Returns null. | Throws notAString for any non-string, describing the value by its type only. |
requireSecret(string $key) | Throws missing. | As secret(): the value is described by its type only. |
secretList(string $key, ?Closure $each = null) | Returns [] — also when the value yields no items. | As list(), with the value or item described by its type only. |
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. list() names the offending item instead (… must contain only valid items, [de] given.). The secret readers are the exception: they describe a value by its type only ([int] given.) — see Secrets in config and facades.
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.Decimals
float() reads a decimal setting such as a sample rate. It accepts an int, a finite float, or a canonical decimal string: an optional -, digits, optionally a . followed by digits, surrounding whitespace ignored ('0.25', '-0.5', ' 1 ', '3', '007.50'). The result is always a float ('3' → 3.0). Everything else throws must be a decimal number — 'abc', '1e3', '0,5', '+1', '.5', '5.', '1.2.3', '0x1A', 'NAN', 'INF', a string too long for a float, the floats NAN and INF, a bool or an array. '.5' and '5.' are refused rather than guessed at, matching integer(). $min and $max are inclusive and independent, and the message prints them as written (must be between 0 and 1, at least 0.5). When the key is not set, the default is used, range-checked, and refused when it is NAN or infinite:
use RoundlyConsulting\PackageToolkit\Support\Config;
// .env: COMMENTS_SAMPLE_RATE=0.25
Config::float('comments.sample_rate', 1.0, min: 0, max: 1); // 0.25
// COMMENTS_SAMPLE_RATE unset, or blank — the default, range-checked too
Config::float('comments.sample_rate', 1.0, min: 0, max: 1); // 1.0
// .env: COMMENTS_SAMPLE_RATE=0,5 — not a canonical decimal string
// throws: Configuration value [comments.sample_rate] must be a decimal number, [0,5] given.
// .env: COMMENTS_SAMPLE_RATE=1.5 — out of range
// throws: Configuration value [comments.sample_rate] must be between 0 and 1, [1.5] given.Optional strings
string() is for a string setting with a shipped default — a queue name, a guard, an audience, a route prefix. Not set, including a host’s blank COMMENTS_QUEUE=, returns the default, where (string) config(...) would have returned ''. A present string comes back as given, never trimmed, so a secret or a pattern keeps every character; a non-string (an int, a float, a bool, an array) throws must be a non-empty string. There is no switch that lets a blank value through — blank always means the default — and the default itself is returned as given, unchecked:
use RoundlyConsulting\PackageToolkit\Support\Config;
// .env: COMMENTS_QUEUE= — blank, so the default; (string) config(...) would give ''
Config::string('comments.queue', 'default'); // 'default'
// config('comments.queue') === 5 — not a string
// throws: Configuration value [comments.queue] must be a non-empty string, [5] given.Lists
list() is one reader for both ways a list reaches config: a published config file holds a PHP array, an env variable holds a comma-separated string. Pass an optional $each closure to validate every item:
// config/comments.php
return [
'locales' => env('COMMENTS_LOCALES'), // 'en, sk' from env, or publish an array here
];
// In your package
$locales = Config::list('comments.locales', ['en'], fn (string $locale): bool => in_array($locale, ['en', 'sk', 'de'], true));
// .env: COMMENTS_LOCALES="en, fr"
// throws: Configuration value [comments.locales] must contain only valid items, [fr] given.| Config value | Result |
|---|---|
['en', 'sk'], ' en, sk ', 'en,,sk', [' en ', '', 'sk'] | ['en', 'sk'] |
['x' => 'en', 'y' => 'sk'] | ['en', 'sk'] — keys dropped, re-indexed |
| Not set: absent, null, '' or whitespace | $default |
| No items: ',', ' , ', [] or [' ', ''] | $default |
| 5, 1.5, true, an object | Throws must be a list of strings (an array or a comma-separated string). |
| An array holding a non-string: ['en', 5], ['en', null] | Throws must contain only string items, [5] given. |
| An item $each does not return true for | Throws must contain only valid items, [de] given. |
- Items are trimmed and empty ones dropped. Inner spaces and duplicates are kept ('en GB' stays one item; 'a,a' is ['a', 'a']), and a comma inside an array item is not split.
- A list with no items counts as not set, like a blank value, so the default applies. A host therefore cannot configure an empty list over a non-empty default — ship [] as the default when “none” must be possible.
- The default is normalised the same way (trimmed, empties dropped, re-indexed) and, when it is the value returned, passed through $each too — just as integer() range-checks its default. A default $each rejects throws.
- $each is a Closure(string): bool and sees each trimmed item. Only a true return accepts the item, so a closure returning preg_match()’s 1 rejects everything — compare with === 1.
- list is a PHP reserved word but a valid method name: Config::list() and $validator->list() work on PHP 8.4, and Pint and Larastan accept them.
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(), requireSecret() and enum() called without a default have 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.Switches in phpunit.xml
In phpunit.xml, write on/off or 1/0, never true/false. PHPUnit turns value="false" into an empty string, which the strict readers treat as not set — so the default applies instead of “off”. value="off" or value="0" reaches boolean() as written:
<!-- Arrives as '' — not set, so boolean() returns the default, not false -->
<env name="COMMENTS_ALLOW_GUESTS" value="false"/>
<!-- Reaches boolean() as written: false -->
<env name="COMMENTS_ALLOW_GUESTS" value="off"/>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 all eleven readers the static accessors have — integer(), float(), requireString(), string(), list(), enum(), oneOf(), boolean(), secret(), requireSecret() and secretList() — 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.