---
title: "Config validation — Package Toolkit for Laravel | Roundly"
description: "Strict config readers — integers, decimals, strings, lists, enums, fixed vocabularies and booleans, DTO arrays and your own exception class."
url: https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel/config-validation
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Package Toolkit for Laravel](https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel.md)

# 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:

```php
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:

```php
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:

```php
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:

```php
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:

```php
// 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:

```php
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 InvalidConfigurationException
```

## Not 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:

```php
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:

```xml
<!-- 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:

```php
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:

```php
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 message
```

Both 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:

```php
$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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel.md)
