---
title: "Config contract — Testing for Laravel | Roundly"
description: "Pin both ways that a config file ships exactly the keys the code reads — token-scraped, with rot-checked allow-lists and an app mode."
url: https://roundly-consulting.com/open-source/docs/testing-for-laravel/config-contract
language: en
---

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

[Testing for Laravel](https://roundly-consulting.com/open-source/docs/testing-for-laravel.md)

# Config contract

Pins, in both directions, that a config file ships exactly the keys the code reads. Reads are scraped from PHP source tokens — never a regex — so a key mentioned only in a docblock never counts. The key prefix defaults to the config file’s basename:

```php
expect($configPath)->toSatisfyConfigContract(string|array $srcDirs, array $options = []);
```

```php
expect(config_path('passkeys.php'))->toSatisfyConfigContract(__DIR__.'/../../src', [
    'excludeFromReverse' => ['PasskeysServiceProvider.php'], // renders keys; a render is not a read
    'sectionVariables'   => ['PasskeyConfig.php' => ['$rp' => 'passkeys.rp']], // DTO array-offset reads
]);
```

## Two directions

- Forward — every key the code reads must ship in the file. It catches a feature reading shops.payments.\* while the file ships payment.\*: the env switch did nothing, and 330 tests stayed green because the suite set the same wrong key.
- Reverse — every shipped leaf must be read somewhere. It catches documented keys that nothing reads, such as a max\_file\_size cap that never applied. Reading a parent wholesale does not prove a specific leaf is used, so a dead sub-key stays detectable.

Both directions are computed from one read-set and reported together, so fixing one never unmasks a fresh batch of findings from the other.

## What counts as a read

Reads are counted with the get, getMany, has, string, integer, boolean, float, array and collection family wherever a method is called, with the key passed by position or by name — config(key: 'pkg.x'), Config::get(default: 1, key: 'pkg.x'), getMany(keys: \[...\]); a key: label is never read as part of the key:

```php
config('pkg.key');                          // the helper (also \config())
Config::get('pkg.key');                     // the facade — bare, \Config, fully qualified or aliased
$this->config->get('pkg.key');              // an injected Illuminate\Contracts\Config\Repository
config()->string('pkg.key');                // the repository reached through an expression
app('config')->get('pkg.key');
$app['config']->get('pkg.key');
$this->config->get(['pkg.a' => $default]);  // each literal key of an array handed to a read method
config()->getMany(['pkg.a', 'pkg.b']);      // getMany() — listed keys count too
Config::get(default: 1, key: 'pkg.key');    // the key passed by name

Config::integer('pkg.ttl', 3600);                            // package-toolkit's strict readers, static…
Config::requireSecret('pkg.api_key');                        // …its secret readers (toolkit 1.3)…
Config::using(PkgException::class)->enum('pkg.mode', Mode::class); // …and chained on a ConfigValidator
KeyType::fromConfig('pkg.key_type');
ModelResolver::for('pkg.model');

config(['pkg.x' => true]);                  // a runtime write — skipped, never counted as a read
```

- The helper — config('pkg.key') or \\config('pkg.key').
- The facade — Config::get('pkg.key'), \\Config::get(…), \\Illuminate\\Support\\Facades\\Config::get(…), or the facade under an import alias.
- An injected Illuminate\\Contracts\\Config\\Repository — $this->config->get('pkg.key').
- The repository reached through an expression — config()->string(…), app('config')->get(…), app(Repository::class)->get(…), resolve('config')->…, ->make('config')->… and $app\['config'\]->….
- Each literal key of an array handed to a read method — getMany(\[...\]), or get(\[...\]), which is getMany — listed or as 'pkg.key' => $default.
- package-toolkit-for-laravel’s readers, static and chained: Config::boolean|integer|float|string|list|enum|oneOf|requireString('pkg.key', …) and the secret readers Config::secret|requireSecret|secretList('pkg.key', …), in any letter case; the same eleven on a ConfigValidator — Config::using(X::class)->integer('pkg.key', …), Config::for($values)->enum(…), ConfigValidator::forRepository()->…, a method declared to return one (self::validator()->…) or a variable declared or assigned as one.
- KeyType::fromConfig('pkg.key\_type'), ModelResolver::for('pkg.model') / ::newModel(…), and $this->modelClass(…) / ->newModel(…) in a class using ResolvesModels.
- In a PackageServiceProvider: $this->bindFromConfig(Contract::class, 'pkg.key', …), $this->observesModel('pkg.model', …), and the switch of $package->hasRoutes('pkg.php', enabledVia: 'pkg.routes.enabled') / ->hasFacadeAlias(X::class, 'pkg.alias') — never the routes filename beside it.
- Array-offset reads through sectionVariables, followed to any depth: with \['$rl' => 'pkg.rate\_limiters'\], $rl\['public'\]\['enabled'\] reads pkg.rate\_limiters.public.enabled.

The repository binding is resolved from the declared type, or from the 'config' binding the expression names, so $cache->get('pkg.x') or app('cache')->get('pkg.x') is correctly not a config read. The toolkit readers are resolved the same way — through the file’s imports and declared types, never by method name alone — so Rules::enum('pkg.x') or an untyped $cache->requireString('pkg.x') is not a read either. That matters most for float, string and list (toolkit 1.2) and secret, requireSecret and secretList (toolkit 1.3), which count only on those receivers: a request’s $request->string('pkg.x'), a vault client’s $vault->secret('pkg.x'), a same-named static on another class or a list(…) = $values destructure is not a read. A package’s own reader that takes the key as an argument (Support\\PkgConfig::string('pkg.key', …)) is not followed: name those keys in extraReadPrefixes, one exact key per entry. Writes never count: an array handed to the helper — config(\['pkg.x' => true\]) — is a runtime write, skipped rather than reported as unresolvable, and set() and push() are not evidence that anything consumes a key.

## Forward reads must land on a shipped path

Naming a parent is fine, and so is reading into a leaf that can hold more than it ships — a list, an empty map, a null placeholder. Reading below a scalar is not: the value is always null, and the forward finding says so.

```php
// config/pkg.php ships: 'cache' => 'redis'
config('pkg.rp');           // fine — naming a parent
config('pkg.cache.store');  // always null: a read below a scalar is a forward finding
```

## Options

| Option | Type | Effect |
| --- | --- | --- |
| `excludeFromReverse` | list<string> | Files that only render config (an about payload) — a render is not a read. |
| `sectionVariables` | array<string, array<string, string>> | Per file, map a local or property to a config section; its array offsets count as leaf reads. |
| `extraReadPrefixes` | list<string> | Dotted prefixes whose literals count as reads anywhere — for keys a package’s own reader takes as an argument, such as Support\\PkgConfig::string('pkg.key'). Name one exact key per entry. |
| `allowUnread` | list<string> | Shipped-but-unread keys to tolerate. Rot-checked: an entry that silences nothing fails. |
| `allowUnshipped` | list<string> | Read-but-unshipped keys to tolerate. Rot-checked the same way. |
| `reverse` | bool (true) | false switches to forward-only — the app mode. |

## Scan scope

Each $srcDirs entry is scanned, plus its sibling database/ and routes/ directories when they exist. A reverse finding therefore means “no reader in the scanned directories” — never “no reader anywhere”. The failure message prints the directories it searched. If it names a key you can see a reader for, the scope is wrong, not the key — add the reader’s directory:

```php
expect(config_path('media.php'))->toSatisfyConfigContract([
    __DIR__.'/../../src',
    __DIR__.'/../../resources/views',   // a directory the default scope misses
]);
```

A .blade.php file is read through the parts Blade runs as PHP — {{ … }} and {!! … !!} echoes, @directive( … ) arguments, @php … @endphp and <?php … ?> blocks, and :attribute="…" bindings on <x-…> component tags. {{-- comments --}}, @{{ escaped }} echoes, @@directive escapes and @verbatim blocks are not reads, and markup is never tokenized — so {{ config('media.max\_file\_size') }} in a view counts once its directory is passed.

Reach for allowUnread only when a key is genuinely unread or unmappable by the scraper — never to silence a key you know is read from an unscanned directory.

## Driver-keyed sections

A section keyed by a runtime driver name — Laravel’s own database.connections.<name> shape — is read by interpolation and scraped as a wildcard. The leaf is still proven: a shipped providers.<driver>.timeout that no read names goes red. A read that stops at the hole is a wholesale section read; map the local to prove its leaves:

```php
config("git.providers.{$key}.url")   // scraped as: git.providers.*.url

$http = config("git.providers.{$this->key()}", []);   // wholesale: proves no leaf
$http['timeout'];                                     // proves git.providers.*.timeout, once mapped

'sectionVariables' => ['BaseProvider.php' => ['$http' => 'git.providers.*']],
```

A wildcard base that matches nothing shipped (a typo such as git.provider.\*) fails. Known gap, stated rather than papered over: a pattern proves the leaf, never the driver — shipping config for a driver the host never selects is correct, not dead.

## Unresolvable keys

A key that resolves to no checkable pattern fails, and there is deliberately no allow-list for it. Name a literal, a driver-keyed read that names its leaf, or a sectionVariables offset instead:

```php
config("pkg.drivers.{$name}x");   // a hole that does not fill a whole segment
config($this->keyFor('x'));       // a key not built from literals and holes
config("pkg.{$x}");               // a hole directly under the root
```

## extraReadPrefixes

extraReadPrefixes counts a matching literal wherever it appears — including where it is not a config key at all, such as a routes filename. A forward finding therefore names the literal, the file it came from and that the prefix is why it counted. Name keys exactly rather than blanket-prefixing:

```
FORWARD — the code reads 'purchases.' keys that the config file does not ship:
  - purchases.php  [read in PurchasesServiceProvider.php (counted because it matches extraReadPrefixes)]
```

## App mode

In an application, pass 'reverse' => false. An app’s config legitimately carries keys read by vendor packages outside your source directories, so only the forward direction applies.

## 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/testing-for-laravel.md)
