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

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:

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 valuesecret()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 whitespacenullThrows is required but missing.[]
','','','[]
[] or [' ', '']Throws [array] given.Throws [array] given.[]
An int, a float, a bool, an objectThrows [int] / [float] / [bool] / [ClassName] given.SameThrows must be a list of strings …, [int] given.
An array holding a non-stringThrows [array] given.SameThrows 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:

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:

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:

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:

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

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

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

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.