Error Wrapping
Wrap lower-level exceptions with domain-specific exceptions while preserving the original error for debugging. This pattern maintains clean error boundaries between application layers.
Overview
Section titled “Overview”The WrapsErrors trait provides the wrap() method to:
- Catch low-level exceptions (PDOException, RequestException, etc.)
- Re-throw as domain-specific exceptions
- Preserve the original exception in the exception chain
- Maintain all context, tags, and metadata
Basic Usage
Section titled “Basic Usage”Wrapping Database Exceptions
Section titled “Wrapping Database Exceptions”use Cline\Throw\Exceptions\InfrastructureException;
final class DatabaseException extends InfrastructureException{ public static function queryFailed(): self { return new self('Database query failed'); }}
try { $db->query($sql);} catch (PDOException $e) { throw DatabaseException::queryFailed()->wrap($e);}Wrapping API Exceptions
Section titled “Wrapping API Exceptions”use Cline\Throw\Exceptions\InfrastructureException;
final class PaymentGatewayException extends InfrastructureException{ public static function requestFailed(): self { return new self('Payment gateway request failed'); }}
try { $response = $client->post('/charge', $data);} catch (RequestException $e) { throw PaymentGatewayException::requestFailed()->wrap($e);}Why Wrap Exceptions?
Section titled “Why Wrap Exceptions?”Clean Layer Boundaries
Section titled “Clean Layer Boundaries”// ❌ Bad - Exposes infrastructure details to application layerpublic function chargeCustomer(Customer $customer, Money $amount): void{ try { $this->stripe->charges->create([/* ... */]); } catch (ApiErrorException $e) { // Application layer now depends on Stripe SDK exception throw $e; }}
// ✅ Good - Application layer sees domain exceptionpublic function chargeCustomer(Customer $customer, Money $amount): void{ try { $this->stripe->charges->create([/* ... */]); } catch (ApiErrorException $e) { throw PaymentFailedException::gatewayError()->wrap($e); }}Preserve Original Exception
Section titled “Preserve Original Exception”The wrapped exception is preserved in two ways:
try { $db->query($sql);} catch (PDOException $e) { $wrapped = DatabaseException::queryFailed()->wrap($e);
$wrapped->getWrapped(); // Returns the PDOException $wrapped->getPrevious(); // Also returns the PDOException}Combining with Context
Section titled “Combining with Context”Wrap exceptions and add context in one fluent chain:
try { $db->query($sql);} catch (PDOException $e) { throw DatabaseException::queryFailed() ->wrap($e) ->withContext([ 'query' => $sql, 'bindings' => $bindings, ]) ->withTags(['database', 'critical']) ->withMetadata([ 'connection' => config('database.default'), 'execution_time' => $executionTime, ]);}Real-World Patterns
Section titled “Real-World Patterns”Database Layer
Section titled “Database Layer”namespace App\Exceptions;
use Cline\Throw\Exceptions\InfrastructureException;
final class DatabaseException extends InfrastructureException{ public static function queryFailed(): self { return new self('Database query failed'); }
public static function connectionFailed(): self { return new self('Failed to connect to database'); }
public static function transactionFailed(): self { return new self('Database transaction failed'); }}
// Usage in repositoryclass OrderRepository{ public function save(Order $order): void { try { DB::table('orders')->insert($order->toArray()); } catch (QueryException $e) { throw DatabaseException::queryFailed() ->wrap($e) ->withContext(['order_id' => $order->id]) ->withTags(['database', 'orders']); } }}HTTP Client Layer
Section titled “HTTP Client Layer”namespace App\Exceptions;
use Cline\Throw\Exceptions\InfrastructureException;
final class ExternalApiException extends InfrastructureException{ public static function requestFailed(string $service): self { return new self("Request to {$service} failed"); }
public static function timeout(string $service): self { return new self("{$service} request timed out"); }}
// Usage in serviceclass PaymentGatewayService{ public function charge(Money $amount, string $token): PaymentIntent { try { $response = $this->client->post('/charge', [ 'amount' => $amount->getAmount(), 'token' => $token, ]); } catch (ConnectException $e) { throw ExternalApiException::timeout('Stripe') ->wrap($e) ->withTags(['payment', 'stripe', 'timeout']); } catch (RequestException $e) { throw ExternalApiException::requestFailed('Stripe') ->wrap($e) ->withContext(['amount' => $amount->getAmount()]) ->withMetadata([ 'response_status' => $e->getResponse()?->getStatusCode(), 'response_body' => $e->getResponse()?->getBody()?->getContents(), ]); } }}File System Layer
Section titled “File System Layer”namespace App\Exceptions;
use Cline\Throw\Exceptions\InfrastructureException;
final class FileSystemException extends InfrastructureException{ public static function cannotRead(string $path): self { return new self("Cannot read file: {$path}"); }
public static function cannotWrite(string $path): self { return new self("Cannot write to file: {$path}"); }}
// Usageclass FileStorage{ public function read(string $path): string { try { return file_get_contents($path); } catch (ErrorException $e) { throw FileSystemException::cannotRead($path) ->wrap($e) ->withContext(['path' => $path, 'permissions' => fileperms($path)]); } }}Cache Layer
Section titled “Cache Layer”namespace App\Exceptions;
use Cline\Throw\Exceptions\InfrastructureException;
final class CacheException extends InfrastructureException{ public static function connectionFailed(string $driver): self { return new self("Failed to connect to {$driver} cache"); }
public static function operationFailed(string $operation): self { return new self("Cache {$operation} operation failed"); }}
// Usageclass CacheService{ public function remember(string $key, callable $callback, int $ttl): mixed { try { return Cache::remember($key, $ttl, $callback); } catch (RedisException $e) { throw CacheException::operationFailed('remember') ->wrap($e) ->withContext(['key' => $key, 'ttl' => $ttl]) ->withTags(['cache', 'redis']); } }}Accessing Wrapped Exceptions
Section titled “Accessing Wrapped Exceptions”Check if Exception is Wrapped
Section titled “Check if Exception is Wrapped”if ($exception->hasWrapped()) { $original = $exception->getWrapped(); // Handle original exception}Type Checking Wrapped Exceptions
Section titled “Type Checking Wrapped Exceptions”try { // ... some operation} catch (DatabaseException $e) { if ($e->getWrapped() instanceof PDOException) { // Handle PDO-specific errors $pdoError = $e->getWrapped(); $errorCode = $pdoError->getCode(); }}Full Exception Chain
Section titled “Full Exception Chain”try { // ... some operation} catch (DatabaseException $e) { $current = $e;
// Walk the entire exception chain while ($current !== null) { echo $current->getMessage() . PHP_EOL; $current = $current->getPrevious(); }}Exception Handler Integration
Section titled “Exception Handler Integration”Laravel Exception Handler
Section titled “Laravel Exception Handler”namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;use Cline\Throw\Concerns\WrapsErrors;
class Handler extends ExceptionHandler{ public function report(Throwable $exception) { // Log wrapped exception details if (method_exists($exception, 'getWrapped') && $exception->hasWrapped()) { $wrapped = $exception->getWrapped();
Log::error('Wrapped exception detected', [ 'domain_exception' => get_class($exception), 'domain_message' => $exception->getMessage(), 'original_exception' => get_class($wrapped), 'original_message' => $wrapped->getMessage(), 'context' => method_exists($exception, 'getContext') ? $exception->getContext() : [], ]); }
parent::report($exception); }}Sentry/Bugsnag Integration
Section titled “Sentry/Bugsnag Integration”public function report(Throwable $exception){ if (method_exists($exception, 'getWrapped') && $exception->hasWrapped()) { // Report both exceptions to Sentry app('sentry')->captureException($exception->getWrapped(), [ 'extra' => [ 'wrapped_by' => get_class($exception), 'context' => $exception->getContext() ?? [], ], ]); }
parent::report($exception);}Best Practices
Section titled “Best Practices”- Wrap at boundaries - Catch low-level exceptions at layer boundaries (repository, service, etc.)
- Add context - Include relevant data about the operation that failed
- Use domain exceptions - Wrap with exceptions that make sense in your domain
- Preserve original - Always wrap rather than replacing the original exception
- Tag for routing - Use tags to route wrapped exceptions to appropriate handlers
- Check wrapped type - Use type checking on wrapped exceptions for specific handling
Testing Wrapped Exceptions
Section titled “Testing Wrapped Exceptions”use Tests\Fixtures\TestInfrastructureException;
test('wraps PDOException', function () { $pdo = new PDOException('Connection failed'); $wrapped = TestInfrastructureException::databaseFailure()->wrap($pdo);
expect($wrapped->getWrapped()) ->toBeInstanceOf(PDOException::class) ->and($wrapped->getWrapped()->getMessage()) ->toBe('Connection failed') ->and($wrapped->hasWrapped()) ->toBeTrue();});Next Steps
Section titled “Next Steps”- Learn about Error Context for adding debugging data
- See Base Exceptions for choosing the right exception type
- Explore Assertions for the
ensure()helper pattern