Skip to main content

Secret

BetterData\Secret is a leak-proof container for sensitive strings — API keys, tokens, webhook secrets. The plaintext only escapes through the explicit reveal() call, so every leak path through serialization, dump, and string conversion is closed.

Construction

use BetterData\Secret;

$secret = new Secret('sk_live_abc123');

Access

$secret->reveal(): string

The single, deliberately explicit accessor for the plaintext. Every call site that needs the raw value calls reveal() — making it greppable in code review.

$secret->equals(Secret|string $other): bool

Constant-time comparison. Accepts either another Secret or a raw string, so callers don't need to reveal one Secret to compare against another.

$a = new Secret('abc');
$b = new Secret('abc');
$a->equals($b); // true
$a->equals('abc'); // true

What Secret blocks

PathBehavior
(string) $secretReturns '***'
json_encode($secret)Returns '"***"' (via jsonSerialize)
var_dump($secret)Shows value => '***' (via __debugInfo)
print_r($secret, true)Same — __debugInfo
serialize($secret)Throws SecretSerializationException
unserialize(...) of a serialized SecretThrows SecretSerializationException
Exception stack traces with Secret argsShows '***'
DataObject::toArray() for a Secret fieldReturns '***'
Presenter::toArray() / toJson() for a Secret fieldReturns '***' (even when included via includeSensitive)

serialize throws rather than redacting silently — silent lossy serialization of a credential is the worst of both outcomes (you lose the value AND don't notice the loss).

What Secret does not block

Secret defends against accidental leaks through ordinary code paths. It does not defend against:

  • PHP reflection. Reflection::getValue() on a private property always works. Reflection-based debuggers (Symfony VarDumper with custom casters, PsySH) can still see the plaintext.
  • Memory dumps. PHP strings are immutable; "zeroing" isn't meaningful from PHP. Operate at the OS level if this is in scope.
  • Xdebug. Step debugging shows everything.

Secret is a guardrail against accidental exposure (a var_dump left in production, an exception trace shipped to a log, a careless json_encode($context) in a controller). For determined introspection, no PHP-level container can help.

Composition with #[Encrypted]

#[Encrypted] public Secret $apiKey is the recommended pattern for at-rest encryption + leak-proof memory:

final readonly class ApiConfigDto extends DataObject {
public function __construct(
#[Encrypted]
#[MetaKey('_api_key')]
public ?Secret $apiKey = null,
) {}
}
  • Storage: bd:v1:<base64> envelope (encrypted)
  • In-memory: Secret instance (leak-proof)
  • Read: hydrator decrypts → TypeCoercer wraps in Secret
  • Write: sink reveals → encrypts → stores envelope
  • Presenter output: '***' regardless of context

To use the plaintext at runtime, reveal() it explicitly:

$key = $config->apiKey?->reveal();
wp_remote_post('https://api.example.com/charge', [
'headers' => ['Authorization' => "Bearer {$key}"],
'body' => json_encode($payload),
]);

Inside a Presenter::compute closure, this is the auditable place to expose the plaintext (e.g., for an admin "show key" UI):

->compute('apiKey', fn ($d) => $d->apiKey?->reveal())

Secret in with()

with() preserves Secret instances through the merge, because the snapshot bypasses toArray():

$dto = ApiConfigDto::fromArray(['apiKey' => 'sk_live_abc']);
$updated = $dto->with(['someOtherField' => 'changed']);
$updated->apiKey->reveal(); // still 'sk_live_abc' — Secret survived

If you replace a Secret field directly, pass either a string (which TypeCoercer wraps) or a Secret instance:

$dto->with(['apiKey' => 'new_key']);
$dto->with(['apiKey' => new Secret('new_key')]);

Common mistakes

  • Calling (string) $secret and using the result as a header value — gets '***', the request will fail. Always reveal() explicitly.
  • Logging $dto via error_log(json_encode($dto))Secret fields appear as '***'. Add a compute step or log specific fields explicitly.
  • Putting serialize($secret) in a session payload — throws. Store the encrypted envelope (or just the meta key reference) instead.
  • Forgetting that Presenter always redacts SecretincludeSensitive doesn't change this; only compute with reveal() does.