agentsclimarketplace

Php error handling

Skill ekut/claude-code-php-toolkit/skills/php-error-handling

Structured error handling and logging for PHP — exception hierarchies, RFC 9457 Problem Details, PSR-3 logging, retry patterns, Sentry integration.From its SKILL.md

Install
npx -y skills add ekut/claude-code-php-toolkit --skill php-error-handling

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 5 stars5 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

13.6 KB, as published. Nobody here has run it

PHP Error Handling & Structured Logging

Patterns for consistent error handling, API error responses, structured logging, and error reporting in PHP applications. Covers both Laravel and Symfony.

When to Activate

  • Designing exception hierarchies for a domain
  • Implementing API error responses (JSON error envelopes)
  • Setting up structured logging (Monolog channels, correlation IDs)
  • Adding retry logic or circuit breaker patterns
  • Integrating error reporting (Sentry, Bugsnag)
  • Reviewing error handling for anti-patterns

1. Exception Hierarchy

Base Domain Exception

abstract class DomainException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly string $errorCode,
        public readonly int $httpStatus = 500,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Typed Exceptions with HTTP Status Mapping

final class EntityNotFoundException extends DomainException
{
    public function __construct(string $entity, string|int $id, ?\Throwable $previous = null)
    {
        parent::__construct(
            message: sprintf('%s with ID "%s" not found.', $entity, $id),
            errorCode: 'ENTITY_NOT_FOUND',
            httpStatus: 404,
            previous: $previous,
        );
    }
}

final class BusinessRuleViolation extends DomainException
{
    public function __construct(string $message, string $errorCode, ?\Throwable $previous = null)
    {
        parent::__construct($message, $errorCode, httpStatus: 422, previous: $previous);
    }
}

final class InsufficientPermissions extends DomainException
{
    public function __construct(string $action, ?\Throwable $previous = null)
    {
        parent::__construct(
            message: sprintf('Insufficient permissions to %s.', $action),
            errorCode: 'INSUFFICIENT_PERMISSIONS',
            httpStatus: 403,
            previous: $previous,
        );
    }
}

final class ExternalServiceFailure extends DomainException
{
    public function __construct(string $service, ?\Throwable $previous = null)
    {
        parent::__construct(
            message: sprintf('External service "%s" is unavailable.', $service),
            errorCode: 'EXTERNAL_SERVICE_FAILURE',
            httpStatus: 502,
            previous: $previous,
        );
    }
}

2. API Error Responses — RFC 9457 Problem Details

Response Envelope

final readonly class ProblemDetails implements \JsonSerializable
{
    public function __construct(
        public string $type,
        public string $title,
        public int $status,
        public string $detail,
        public ?string $instance = null,
        /** @var array<string, mixed> */
        public array $extensions = [],
    ) {}

    public function jsonSerialize(): array
    {
        return array_filter([
            'type' => $this->type,
            'title' => $this->title,
            'status' => $this->status,
            'detail' => $this->detail,
            'instance' => $this->instance,
            ...$this->extensions,
        ], fn ($v) => $v !== null);
    }
}

Laravel Exception Handler

// bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (DomainException $e, Request $request) {
        if (! $request->expectsJson()) {
            return null;  // Let default handler render HTML
        }

        $problem = new ProblemDetails(
            type: 'https://api.example.com/errors/' . strtolower($e->errorCode),
            title: class_basename($e),
            status: $e->httpStatus,
            detail: $e->getMessage(),
            instance: $request->getPathInfo(),
        );

        return response()->json($problem, $e->httpStatus)
            ->header('Content-Type', 'application/problem+json');
    });

    $exceptions->render(function (ValidationException $e, Request $request) {
        if (! $request->expectsJson()) {
            return null;
        }

        $problem = new ProblemDetails(
            type: 'https://api.example.com/errors/validation',
            title: 'Validation Error',
            status: 422,
            detail: 'One or more fields failed validation.',
            instance: $request->getPathInfo(),
            extensions: ['errors' => $e->errors()],
        );

        return response()->json($problem, 422)
            ->header('Content-Type', 'application/problem+json');
    });
})

Symfony Exception Listener

#[AsEventListener(event: ExceptionEvent::class, priority: -10)]
final readonly class ProblemDetailsListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        $request = $event->getRequest();

        if (! in_array('application/json', $request->getAcceptableContentTypes(), true)) {
            return;
        }

        if ($exception instanceof DomainException) {
            $problem = new ProblemDetails(
                type: 'https://api.example.com/errors/' . strtolower($exception->errorCode),
                title: (new \ReflectionClass($exception))->getShortName(),
                status: $exception->httpStatus,
                detail: $exception->getMessage(),
                instance: $request->getPathInfo(),
            );

            $response = new JsonResponse($problem, $exception->httpStatus);
            $response->headers->set('Content-Type', 'application/problem+json');
            $event->setResponse($response);
        }
    }
}

3. Structured Logging

PSR-3 / Monolog Best Practices

Log levels guide:

LevelUse ForExample
emergencySystem unusableDatabase server down
criticalImmediate action neededPayment gateway timeout
errorRuntime error, operation failedFailed to create order
warningUnusual but recoverableCache miss, deprecated API called
noticeNormal but significantUser login, config change
infoInformationalRequest processed, job dispatched
debugDetailed debuggingSQL queries, API payloads (dev only)

Request ID Correlation

final class RequestIdMiddleware
{
    public function handle(Request $request, \Closure $next): Response
    {
        $requestId = $request->header('X-Request-Id', (string) Str::uuid());
        $request->headers->set('X-Request-Id', $requestId);

        // Push to Monolog context — all logs in this request get the ID
        Log::shareContext(['request_id' => $requestId]);

        /** @var Response $response */
        $response = $next($request);
        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

Monolog Channels

// config/logging.php (Laravel)
'channels' => [
    'orders' => [
        'driver' => 'daily',
        'path' => storage_path('logs/orders.log'),
        'days' => 14,
    ],
    'payments' => [
        'driver' => 'daily',
        'path' => storage_path('logs/payments.log'),
        'days' => 30,
    ],
],

// Usage
Log::channel('orders')->info('Order created', [
    'order_id' => $order->id,
    'total' => $order->total,
    'items_count' => $order->items->count(),
]);

Symfony Monolog Channels

# config/packages/monolog.yaml
monolog:
    channels: ['orders', 'payments']
    handlers:
        orders:
            type: rotating_file
            path: '%kernel.logs_dir%/orders.log'
            max_files: 14
            channels: ['orders']
        payments:
            type: rotating_file
            path: '%kernel.logs_dir%/payments.log'
            max_files: 30
            channels: ['payments']
// Inject named logger
public function __construct(
    #[Target('orders')]
    private readonly LoggerInterface $logger,
) {}

$this->logger->info('Order created', ['order_id' => $order->getId()]);

4. Retry & Circuit Breaker

Exponential Backoff

final class RetryableOperation
{
    /**
     * @template T
     * @param callable(): T $operation
     * @param int $maxAttempts
     * @param list<class-string<\Throwable>> $retryOn
     * @return T
     */
    public static function execute(
        callable $operation,
        int $maxAttempts = 3,
        array $retryOn = [\RuntimeException::class],
        int $baseDelayMs = 100,
    ): mixed {
        $attempt = 0;

        while (true) {
            try {
                return $operation();
            } catch (\Throwable $e) {
                $attempt++;
                $isRetryable = false;
                foreach ($retryOn as $class) {
                    if ($e instanceof $class) {
                        $isRetryable = true;
                        break;
                    }
                }

                if (! $isRetryable || $attempt >= $maxAttempts) {
                    throw $e;
                }

                $delayMs = $baseDelayMs * (2 ** ($attempt - 1)) + random_int(0, 50);
                usleep($delayMs * 1000);
            }
        }
    }
}

// Usage
$response = RetryableOperation::execute(
    fn () => $httpClient->request('GET', 'https://api.example.com/data'),
    maxAttempts: 3,
    retryOn: [TransportExceptionInterface::class],
);

Laravel Job Retries

final class ProcessPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 10;  // Seconds, or array [10, 30, 60] for progressive
    public int $maxExceptions = 2;

    public function handle(PaymentGateway $gateway): void
    {
        $gateway->charge($this->order);
    }

    public function failed(\Throwable $exception): void
    {
        Log::channel('payments')->error('Payment failed permanently', [
            'order_id' => $this->order->id,
            'exception' => $exception->getMessage(),
        ]);
        // Notify admin, refund, etc.
    }
}

Symfony Messenger Retry

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000        # 1s initial delay
                    multiplier: 2      # Exponential backoff
                    max_delay: 60000   # Cap at 60s
        failure_transport: failed

        routing:
            App\Message\ProcessPayment: async

5. Error Reporting — Sentry Integration

Laravel (sentry/sentry-laravel)

composer require sentry/sentry-laravel
php artisan sentry:publish --dsn=https://[email protected]/0
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->reportable(function (\Throwable $e) {
        if (app()->bound('sentry')) {
            app('sentry')->captureException($e);
        }
    });

    // Don't report expected domain exceptions
    $exceptions->dontReport([
        EntityNotFoundException::class,
        BusinessRuleViolation::class,
    ]);
})

Symfony (sentry/sentry-symfony)

composer require sentry/sentry-symfony
# config/packages/sentry.yaml
sentry:
    dsn: '%env(SENTRY_DSN)%'
    options:
        environment: '%kernel.environment%'
        release: '%env(APP_VERSION)%'
        send_default_pii: false
        traces_sample_rate: 0.2

6. Decision Table

Throw vs Return (Result Pattern)

ScenarioApproachWhy
Entity not foundThrow EntityNotFoundExceptionExceptional — caller expects entity to exist
Validation failureReturn validation errorsExpected — user input is often invalid
Business rule violationThrow BusinessRuleViolationCaller should not have reached this state
External service downThrow ExternalServiceFailureRetry or circuit-break at a higher level
Parse error on optional dataReturn null or defaultCaller can handle absence gracefully

Log Level Selection

SituationLevelWhy
Unhandled exceptionerrorOperation failed, needs investigation
Expected failure (invalid input)warningInteresting but not broken
External service retry succeededwarningDegraded but recovered
External service failed after retriescriticalDownstream dependency broken
User action (login, order)infoBusiness audit trail
Cache missdebugOnly useful when diagnosing performance

7. Anti-Patterns

Anti-PatternProblemFix
catch (\Exception $e) {} (swallow)Hides bugs, makes debugging impossibleCatch specific types, always log or rethrow
catch (\Exception $e) { Log::error($e); throw $e; } (double log)Exception logged twice (handler + catch)Catch only if you handle it, otherwise let it bubble
Returning HTTP status codes from service layerCouples domain logic to HTTP transportUse domain exceptions, map to HTTP in controller/handler
Using error codes as strings without constantsTypos, inconsistencyUse backed enums or class constants for error codes
Logging $e->getMessage() onlyMissing stack trace and contextLog $e as context: Log::error('Failed', ['exception' => $e])
Same log message at multiple levelsConfusing alertsOne log per event, choose the right level

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.