NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

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:

expect($configPath)->toSatisfyConfigContract(string|array $srcDirs, array $options = []);
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, has, string, integer, boolean, float, array and collection family wherever a method is called:

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::integer('pkg.ttl', 3600);                            // package-toolkit's strict readers, static…
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, such as ->get(['pkg.a' => $default]).
  • package-toolkit-for-laravel’s readers, static and chained: Config::boolean|integer|enum|oneOf|requireString('pkg.key', …); the same five 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. 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.

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

OptionTypeEffect
excludeFromReverselist<string>Files that only render config (an about payload) — a render is not a read.
sectionVariablesarray<string, array<string, string>>Per file, map a local or property to a config section; its array offsets count as leaf reads.
extraReadPrefixeslist<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.
allowUnreadlist<string>Shipped-but-unread keys to tolerate. Rot-checked: an entry that silences nothing fails.
allowUnshippedlist<string>Read-but-unshipped keys to tolerate. Rot-checked the same way.
reversebool (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:

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:

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:

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

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