Redact Secrets from Stack Traces with PHP's #[\SensitiveParameter]

The PHP SensitiveParameter attribute redacts passwords and API keys from stack traces, so a thrown exception never ships your credentials to the error tracker.

Steven Richardson
Steven Richardson
· 6 min read

A failed database connect once put a plaintext production password into our error tracker. Not in a log line I wrote — in the stack trace PHP generated, because a trace records every argument passed to every frame. The PHP SensitiveParameter attribute is the one-line fix, and it has been sitting in the language since 8.2.

How secrets leak into stack traces#

Every frame in a PHP stack trace carries the arguments that were passed to it. Throw an exception three calls deep and PHP renders the whole chain, values included. The classic example is password_hash() with a bad algorithm:

password_hash($password, 'unknown-algo');
Fatal error: Uncaught ValueError: password_hash(): Argument #2 ($algo) must be
a valid password hashing algorithm in ...:...
Stack trace:
#0 example.php(4): password_hash('hunter2', 'unknown-algo')
#1 {main}

hunter2 is now in the trace. That trace goes wherever your exceptions go — laravel.log, Sentry, Nightwatch, a Slack alert channel. debug_backtrace() behaves the same way, so any profiler or logging middleware that captures a backtrace captures the arguments too.

This got broader in PHP 8.5, which added full backtraces for fatal errors like timeouts and memory exhaustion. More traces is better debugging, and a wider surface for credentials to escape through.

Marking a parameter with the SensitiveParameter attribute#

Put #[\SensitiveParameter] on the parameter. That is the whole API — it takes no arguments, it lives in the global namespace, and it targets parameters only.

<?php

function defaultBehavior(string $secret, string $normal): never
{
    throw new Exception('Error!');
}

function withAttribute(
    #[\SensitiveParameter]
    string $secret,
    string $normal,
): never {
    throw new Exception('Error!');
}

Note the leading backslash. SensitiveParameter is a global class, so inside a namespaced Laravel class #[SensitiveParameter] without the backslash resolves to App\Services\SensitiveParameter and fails. If you would rather not repeat the slash, import it: use SensitiveParameter;.

If you are new to attribute syntax in general, I covered the mechanics — including reading them back with reflection — in writing your own PHP attributes.

What the redacted trace looks like#

The argument is replaced with a SensitiveParameterValue object in the trace. Calling both functions above gives you:

Exception: Error! in example.php:7
Stack trace:
#0 example.php(19): defaultBehavior('hunter2', 'normal')
#1 {main}

Exception: Error! in example.php:15
Stack trace:
#0 example.php(25): withAttribute(Object(SensitiveParameterValue), 'normal')
#1 {main}

The unmarked $normal parameter still prints. Only the annotated one is swapped.

In the structured form from debug_backtrace(), the args entry becomes an object rather than a string:

[0]=> object(SensitiveParameterValue)#1 (0) {
}

It reports zero properties because SensitiveParameterValue::__debugInfo() returns an empty array — a var_dump() of the trace shows the wrapper and nothing else. The real value is still in there, behind a private readonly property, if you deliberately need it:

$trace = debug_backtrace();

// Only do this somewhere you actually want the secret.
$secret = $trace[0]['args'][0]->getValue();

Inside the function itself, nothing changes. $secret is still the string you passed. The attribute only alters how the argument is represented in a trace.

Where to use the SensitiveParameter attribute in Laravel#

Anywhere a credential, token, or piece of PII crosses a function boundary. In practice that is four places for me:

namespace App\Services;

use Illuminate\Support\Facades\Http;

final class PaymentGateway
{
    public function __construct(
        #[\SensitiveParameter]
        private readonly string $apiKey,
    ) {}

    public function charge(
        string $customerId,
        #[\SensitiveParameter]
        string $cardToken,
    ): array {
        // If this throws, the trace shows the customer ID but not the token.
        return Http::withToken($this->apiKey)
            ->post('https://api.example.com/charges', [
                'customer' => $customerId,
                'source' => $cardToken,
            ])
            ->throw()
            ->json();
    }
}

The four I always annotate:

  • Service constructors that receive an API key or secret from config.
  • Webhook verification methods taking a raw signing secret.
  • Custom auth flows — anything handling a plaintext password before hashing, or a one-time token.
  • Queued job methods that receive tokens, since a failed job serialises straight into failed_jobs.

If the payoff matters to you, it is worth pairing this with a deliberate approach to what your app records at all. I set that up with structured, request-scoped context in logs and jobs so the useful metadata is explicit and the secrets are not there by accident.

Gotchas and Edge Cases#

Interfaces and abstract methods do not propagate it. This is the big one. Declaring #[\SensitiveParameter] on an interface method redacts nothing when the implementation omits it — the attribute is read from the concrete declaration that actually ran. Put it on both.

interface Server
{
    public function connect(#[\SensitiveParameter] string $password): void;
}

final class TestServer implements Server
{
    // No attribute here, so the password prints in full.
    public function connect(string $password): void
    {
        throw new Exception('Guess what?');
    }
}

Serializing a redacted trace throws. SensitiveParameterValue::__serialize() raises Serialization of 'SensitiveParameterValue' is not allowed. That is deliberate, but it bites any logger or cache layer that calls serialize() on a backtrace array. Log $e->getTraceAsString() instead of serializing raw frames.

It only touches trace arguments. A var_dump($secret) inside the function still prints the value. So does dumping an object whose property holds it — the attribute annotates a parameter, not the data. Constructor property promotion is the trap: the trace is clean, but dd($gateway) still shows apiKey unless you also keep that property out of your dumps.

Secrets nested inside another argument survive. Pass a config array or DTO containing a token and the trace renders the array. Marking the parameter that holds it works; hoping PHP finds the token inside does not.

Nothing to redact before 8.2. You can polyfill both classes on older versions to keep code parsing, but the engine won't perform the substitution. If you're still upgrading, treat it as a reason to move.

Wrapping Up#

Grep your codebase for parameters named $password, $token, $secret, $apiKey, or $signature, and annotate them — it's a fifteen-minute pass with a real payoff the next time something throws at 2am. Then check what your tracker actually receives: I walk through that side of it in Laravel production observability with Pulse, Nightwatch and OpenTelemetry, and in centralised logging with Grafana Loki if your traces land in a log aggregator rather than an error tracker.

FAQ#

What does #[\SensitiveParameter] do in PHP?

It marks a function or method parameter as sensitive so PHP redacts its value from stack traces. Instead of the real argument, the trace shows Object(SensitiveParameterValue) — a wrapper object that hides the value. It changes the trace representation only; your code receives the parameter exactly as passed.

Which PHP version added SensitiveParameter?

PHP 8.2. Both the SensitiveParameter attribute and the SensitiveParameterValue class were added to core in that release, in the global namespace, with no extension or Composer package required. On PHP 8.1 and earlier you can polyfill the classes so code still parses, but the engine will not redact anything.

How do I hide passwords from stack traces in PHP?

Add #[\SensitiveParameter] directly before the parameter in the function signature. Any exception thrown inside that function, or any debug_backtrace() call that captures the frame, will show the redacted wrapper instead of the password. Remember to repeat the attribute on every concrete implementation, because declaring it on an interface alone has no effect.

Does SensitiveParameter change the value inside my function?

No. Inside the function body the parameter is the original value, and you can hash it, send it, or compare it as normal. The substitution happens only when PHP builds a stack trace for that frame. If you ever need the value back out of a trace, SensitiveParameterValue::getValue() returns it.

Where should I use SensitiveParameter in a Laravel app?

On any method that receives credentials or PII: service constructors taking an API key from config, webhook handlers verifying a signing secret, custom authentication code handling a plaintext password, and queued jobs carrying tokens. Failed jobs serialise their payload into the database, so annotated parameters keep those records clean too.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.