---
title: "Secrets in config and facades — Package Toolkit for Laravel | Roundly"
description: "Read API keys and key rings with errors that never show the value, and keep secret facade arguments out of stack traces with one trait."
url: https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel/secrets
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)

# Secrets in config and facades

API keys, signing secrets, passwords and key rings need the same strict checks as any other setting — but a misconfigured secret must never end up in an error message, a stack trace or an error tracker. The toolkit covers both places a secret travels through a package: its config readers and its facade.

## Secret readers

secret(), requireSecret() and secretList() read like string(), requireString() and list(), with two differences. Errors never show the value: a misconfigured secret is described by its type only, as in Configuration value \[comments.akismet\_key\] must be a non-empty string, \[int\] given., and a list item $each rejects reads must contain only valid items, \[string\] given. The value is in neither the message nor the trace, whichever exception class carries it (Config::for() / Config::using()). And there is no shipped default, because a secret has no sensible one: secret() returns null when the key is not set and secretList() returns \[\], so the caller decides what “not configured” means; requireSecret() throws is required but missing. A present string is returned as given, never trimmed:

```php
use RoundlyConsulting\PackageToolkit\Support\Config;

// Required: not set throws "is required but missing"
$apiKey = Config::requireSecret('comments.akismet_key');

// Optional: not set → null, and the caller decides what "not configured" means
$apiKey = Config::secret('comments.akismet_key');

// config('comments.akismet_key') === 12345 — misconfigured, and the message names the type only:
// Configuration value [comments.akismet_key] must be a non-empty string, [int] given.
```

| Config value | secret() | requireSecret() | secretList() |
| --- | --- | --- | --- |
| A string, e.g. ' key ' | ' key ' — as given, never trimmed | ' key ' | \['key'\] — items trimmed |
| `'a, b'` | 'a, b' | 'a, b' | \['a', 'b'\] |
| `['a', 'b']` | Throws \[array\] given. | Throws \[array\] given. | \['a', 'b'\] |
| Not set: absent, null, '' or whitespace | null | Throws is required but missing. | \[\] |
| `','` | ',' | ',' | \[\] |
| \[\] or \[' ', ''\] | Throws \[array\] given. | Throws \[array\] given. | \[\] |
| An int, a float, a bool, an object | Throws \[int\] / \[float\] / \[bool\] / \[ClassName\] given. | Same | Throws must be a list of strings …, \[int\] given. |
| An array holding a non-string | Throws \[array\] given. | Same | Throws must contain only string items, \[int\] given. |

## Key rings

secretList() parses like list(): a published array or an env comma list, items trimmed, empty ones dropped, keys discarded. A secret that contains a comma therefore cannot sit in an env comma list — publish the config array instead. $each sees every item, so give its parameter #\[SensitiveParameter\]: the toolkit cannot redact your closure’s own frame, and a closure that throws would otherwise put the item in the trace:

```php
use RoundlyConsulting\PackageToolkit\Support\Config;
use SensitiveParameter;

$keys = Config::secretList('comments.signing_keys', static fn (#[SensitiveParameter] string $key): bool => strlen($key) >= 32);
$current = $keys[0] ?? throw new RuntimeException('Set COMMENTS_SIGNING_KEYS.');
```

## Your own messages around a secret

Every InvalidConfigurationException factory describes a value wrapped in PHP’s SensitiveParameterValue by the type of what it wraps, never its content. Use that when your package builds its own message around a secret:

```php
use RoundlyConsulting\PackageToolkit\Exceptions\InvalidConfigurationException;
use SensitiveParameterValue;

throw InvalidConfigurationException::notAString($key, new SensitiveParameterValue($value)); // "[int] given."
```

## Values in stack frames

Every exception records the arguments of each function on its stack, unless production’s zend.exception\_ignore\_args strips them — and error trackers that collect frame arguments, print\_r($e) and custom trace loggers serialise them. Every parameter that carries a configuration value is therefore #\[SensitiveParameter\]: each reader’s $default, the array handed to Config::for() / ConfigValidator::forArray(), and every InvalidConfigurationException factory’s value. A misconfigured value shows in the message of the non-secret readers, which name it on purpose, and nowhere else. The secret readers keep it out of the message too.

## Secrets passed through a facade

PHP replaces a #\[SensitiveParameter\] argument with a SensitiveParameterValue only in the frame of the function that declares the attribute. A call through a stock Laravel facade passes Facade::\_\_callStatic($method, $args) first, and that frame carries every argument raw:

```php
Crypto::constantTimeEquals($known, $userInput);
// #0 CryptoManager->constantTimeEquals(Object(SensitiveParameterValue), Object(SensitiveParameterValue))
// #1 Facade::__callStatic('constantTimeEquals', Array)   ← Array holds $known and $userInput raw
```

getTraceAsString() prints that frame’s array as Array, so the secret never shows there. It does show wherever frame arguments are serialised: error trackers that collect them, print\_r($e) / var\_dump($e), custom trace loggers. Laravel’s log formatter and error page show argument types only, and production’s zend.exception\_ignore\_args = On strips frame arguments altogether, so the exposure needs that setting off. The same call through dependency injection was already redacted.

The RedactsSensitiveArguments trait closes the gap: it hides, in the facade’s own frame, exactly the arguments the root method marks #\[SensitiveParameter\], and leaves harmless arguments visible. Add it to the facade — nothing else changes:

```php
use Illuminate\Support\Facades\Facade;
use RoundlyConsulting\PackageToolkit\Concerns\RedactsSensitiveArguments;

/**
 * @method static bool constantTimeEquals(string $known, string $userInput)
 *
 * @see CryptoManager
 */
final class Crypto extends Facade
{
    use RedactsSensitiveArguments;

    protected static function getFacadeAccessor(): string
    {
        return CryptoManager::class;
    }
}
```

There is no list of methods to maintain. The root’s #\[SensitiveParameter\] attributes are the only source, so a new secret parameter is redacted as soon as it is marked.

## What the trait covers

| Call | Facade frame shows |
| --- | --- |
| `Crypto::check($secret, $label)` | \['check', \[Object(SensitiveParameterValue), $label\]\] |
| `Crypto::check(label: $label, secret: $secret)` | \['label' => $label, 'secret' => Object(SensitiveParameterValue)\] |
| `Crypto::seal($label, ...$parts)` | A sensitive variadic: every collected part redacted, positional or named. |
| `Crypto::tag($secret, ...$labels)` | A harmless variadic next to a secret: the secret redacted, the labels visible. |
| `Crypto::check(secrett: $secret)` | A misspelt named argument: redacted (the call fails with Unknown named parameter anyway). |
| A method with no #\[SensitiveParameter\] | Every argument, as before. |
| The root fails to resolve (the container throws) | Redacted — the arguments are redacted before the root resolves. |

The trait replaces Facade::\_\_callStatic(). It wraps the sensitive entries of its own $args — a backtrace shows a parameter’s current value — and forwards an untouched copy, so the root receives the real values.

## Where the positions come from

The trait reflects the accessor type: the class or interface getFacadeAccessor() returns. The lookup runs once per facade class and method and is cached for the process.

- Interface accessors — mark the parameters on the interface. The facade sees the interface, not the class bound to it.
- Fakes and mocks — a fake swapped in with swap() / fake(), or a Mockery mock from shouldReceive(), receives the real arguments and records them for its assertions. The facade frame stays redacted even when the fake’s override drops the attribute; the fake’s own frame is the fake’s concern.
- Not covered — an accessor that is not a class-string (a container alias such as 'crypto') and a method the accessor type does not declare (a macro the root forwards through \_\_call) forward unredacted, exactly like a stock facade. Secrets inside objects passed as arguments — a DTO, a credentials object — show as the object itself; hiding their contents is the object’s concern.

## Behaviour it keeps

- Return values and exceptions are the root’s, unchanged.
- Scalar coercion matches Laravel. Facade::\_\_callStatic() lives in a file without strict\_types, so Crypto::base64Encode(123) from a strict file coerces 123 to '123', where the same call through dependency injection throws a TypeError. Strictness comes from the file a call is written in, so the trait’s file deliberately has no strict\_types and the same calls stay accepted.
- “A facade root has not been set.” is thrown as before when no application is set.
- Cost: about 170 ns more per call on a method with a secret and about 30 ns on one without, against about 250 ns for a stock facade call (PHP 8.4 CLI, opcache on).

## Testing the redaction

Read the facade frame with frame arguments switched on. zend.exception\_ignore\_args is on in the production ini — and on CI runners that use it — and a frame without arguments proves nothing:

```php
it('hides the secret in the facade frame', function (): void {
    $previous = ini_set('zend.exception_ignore_args', '0');

    try {
        Crypto::base64UrlDecode('*not-base64*');
    } catch (InvalidEncodingException $e) {
        $frame = collect($e->getTrace())->firstWhere('function', '__callStatic');
    } finally {
        ini_set('zend.exception_ignore_args', (string) $previous);
    }

    expect($frame['args'][0])->toBe('base64UrlDecode')
        ->and($frame['args'][1][0])->toBeInstanceOf(SensitiveParameterValue::class);
});
```

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