Skip to content

Exception Handling

Cloak provides fine-grained control over which exceptions to sanitize and how to sanitize them.

Force sanitization for specific exception types regardless of content:

'sanitize_exceptions' => [
\Illuminate\Database\QueryException::class,
\PDOException::class,
\Doctrine\DBAL\Exception::class,
\League\Flysystem\FilesystemException::class,
\Aws\Exception\AwsException::class,
],

These exceptions will always be sanitized, even if they don’t match any patterns.

Whitelist exceptions that are safe to display:

'allowed_exceptions' => [
\App\Exceptions\UserFacingException::class,
\App\Exceptions\ValidationException::class,
\Illuminate\Validation\ValidationException::class,
],

These exceptions will never be sanitized, even if they match patterns.

Replace entire exception messages with generic ones:

'generic_messages' => [
\Illuminate\Database\QueryException::class =>
'A database error occurred while processing your request.',
\PDOException::class =>
'A database connection error occurred.',
\Aws\Exception\AwsException::class =>
'An external service error occurred.',
],
  1. Zero information leakage - No details exposed at all
  2. User-friendly - Clear, non-technical messages
  3. Consistent - Same message for all instances

Use generic messages for:

  • Database exceptions - Never expose queries or connection details
  • External service errors - Hide API credentials and endpoints
  • File system errors - Prevent path disclosure
  • Authentication errors - Avoid user enumeration
'generic_messages' => [
// Database
QueryException::class => 'A database error occurred.',
PDOException::class => 'Database connection failed.',
// External services
AwsException::class => 'Cloud service error.',
GuzzleException::class => 'External API error.',
// File system
FilesystemException::class => 'File operation failed.',
UnreadableFileException::class => 'Cannot read file.',
// Authentication
AuthenticationException::class => 'Authentication failed.',
TooManyRequestsException::class => 'Too many requests.',
],

Cloak evaluates exceptions in this order:

  1. Is it an allowed exception? → Don’t sanitize
  2. Is debug mode on and sanitize_in_debug false? → Don’t sanitize
  3. Is it in sanitize_exceptions? → Sanitize with generic message or patterns
  4. Does message match sensitive patterns? → Sanitize with patterns
  5. Otherwise → Don’t sanitize
┌─────────────────────────┐
Exception Thrown
└──────────┬──────────────┘
┌─────────────────────────┐
Allowed Exception? │───Yes──→ Return Original
└──────────┬──────────────┘
No
┌─────────────────────────┐
Debug Mode & │───Yes──→ Return Original
!sanitize_in_debug?
└──────────┬──────────────┘
No
┌─────────────────────────┐
In sanitize_exceptions │───Yes──→ Generic Message
list? or Pattern Sanitize
└──────────┬──────────────┘
No
┌─────────────────────────┐
Matches sensitive │───Yes──→ Pattern Sanitize
patterns?
└──────────┬──────────────┘
No
┌─────────────────────────┐
Return Original
└─────────────────────────┘

Implement custom logic by creating your own sanitizer:

use Cline\Cloak\Contracts\ExceptionSanitizer;
use Throwable;
class CustomSanitizer implements ExceptionSanitizer
{
public function sanitize(Throwable $exception): Throwable
{
// Your custom logic
if ($exception instanceof SensitiveException) {
return new SanitizedException(
'Custom sanitized message',
$exception->getCode(),
$exception
);
}
return $exception;
}
public function shouldSanitize(Throwable $exception): bool
{
return $exception instanceof SensitiveException;
}
public function sanitizeMessage(string $message): string
{
return preg_replace('/sensitive-data/', '[REDACTED]', $message);
}
}

Register your custom sanitizer:

use Cline\Cloak\Contracts\ExceptionSanitizer;
$this->app->singleton(ExceptionSanitizer::class, function () {
return new CustomSanitizer();
});

Sanitize based on runtime conditions:

use Cline\Cloak\Facades\Cloak;
public function render($request, Throwable $e)
{
// Only sanitize for non-admin users
if (!$request->user()?->isAdmin()) {
$e = Cloak::sanitizeForRendering($e, $request);
}
return parent::render($request, $e);
}
$exceptions->render(function (Throwable $e, Request $request) {
// Different behavior per environment
return match (app()->environment()) {
'production' => Cloak::sanitizeForRendering($e, $request),
'staging' => $e, // Full details in staging
'local' => $e, // Full details locally
};
});
$exceptions->render(function (Throwable $e, Request $request) {
// Sanitize API routes more aggressively
if ($request->is('api/*')) {
return Cloak::sanitizeForRendering($e, $request);
}
return $e;
});

Sanitized exceptions wrap the original:

use Cline\Cloak\Exceptions\SanitizedException;
try {
// ...
} catch (Throwable $e) {
$sanitized = Cloak::sanitizeForRendering($e);
if ($sanitized instanceof SanitizedException) {
// Get original exception for logging
$original = $sanitized->getOriginalException();
Log::error('Original exception', [
'message' => $original->getMessage(),
'trace' => $original->getTraceAsString(),
]);
// Return sanitized to user
return response()->json([
'error' => $sanitized->getMessage(),
], 500);
}
}

Sanitized exceptions preserve the original code:

try {
throw new CustomException('Sensitive data: password123', 1234);
} catch (Throwable $e) {
$sanitized = Cloak::sanitizeForRendering($e);
// Code is preserved
assert($sanitized->getCode() === 1234); // ✅
}

Test your sanitization logic:

use Cline\Cloak\CloakManager;
use Illuminate\Database\QueryException;
test('sanitizes database exceptions', function () {
config(['cloak.enabled' => true]);
$pdo = new PDOException('SQLSTATE[HY000]: mysql://root:secret@localhost/db');
$exception = new QueryException('default', 'SELECT *', [], $pdo);
$manager = app(CloakManager::class);
$sanitized = $manager->sanitizeForRendering($exception);
expect($sanitized->getMessage())
->not->toContain('secret')
->not->toContain('mysql://');
});