What you'll learn
Turn failures into explicit control flow without leaking internals. You will create meaningful exception types, preserve causes, guarantee cleanup, and centralize logging and user-safe responses.
By the end of this lesson, you'll be able to:
- Throw domain-specific exceptions
- Catch only where recovery or translation is possible
- Log diagnostic context while returning safe messages
Core mental model
Professional PHP is less about memorizing APIs and more about choosing a clear boundary for each responsibility. Use this table as a decision guide while reading the examples.
| Concept | What it protects | Decision rule |
|---|---|---|
| Exception | Exceptional failure path | Throw when the contract cannot return a valid result. |
| Translation | Stable abstraction | Wrap infrastructure errors at the layer boundary. |
| finally | Guaranteed cleanup | Release resources whether work succeeds or fails. |
Professional workflow
Build the feature in small, verifiable steps. Each step leaves the system in a state you can test.
- Describe the failure boundary boundary: its inputs, outputs, invariants, and expected failures.
- Implement the smallest happy path behind an explicit contract.
- Add validation and translate low-level failures into language the caller understands.
- Exercise the boundary with realistic data, then inspect output, logs, and resource cleanup.
- Refactor only after behavior is protected by a repeatable check.
Make the boundary visible
Guided code lab
Name a domain failure
The service exposes a failure the caller can understand and preserves the original database cause.
<?php
final class CourseIsFull extends RuntimeException {}
final readonly class EnrollmentService
{
public function __construct(private PDO $db) {}
public function enroll(int $courseId, int $studentId): void
{
try {
// check capacity and insert...
throw new PDOException('duplicate');
} catch (PDOException $e) {
throw new RuntimeException('Could not save enrollment', previous: $e);
}
}
}Handle errors at the application edge
One handler logs full context and sends a generic production response.
<?php
set_exception_handler(function (Throwable $error): void {
error_log(json_encode([
'type' => $error::class,
'message' => $error->getMessage(),
'trace_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null,
], JSON_THROW_ON_ERROR));
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unexpected server error']);
});Production practice
Contract
Document exceptions that callers are expected to recover from; do not use them for ordinary branching.
Verification
Test the exception type, safe public message, preserved previous cause, and cleanup behavior.
Operations
Attach request IDs and structured context, but never log passwords, tokens, or full personal data.
Common failure mode
Independent workshop
Add a failure strategy to a file importer that validates rows, continues recoverable errors, and aborts infrastructure failures.
Your finished workshop must include:
- Custom validation and import exceptions
- A finally block for file cleanup
- Safe CLI output plus a diagnostic log
Definition of done
Recap & quick check
Key takeaways
- Throw where a contract fails.
- Catch where you can recover or translate.
- Exception chaining preserves the root cause.
- The application edge owns the public error response.
Quick check
1. Where should an infrastructure exception become a domain-friendly one?
2. What always runs after try/catch?
3. Should a production response include a stack trace?
Keep the workshop: later phases deliberately build on these boundaries, so today's small example can become part of your portfolio architecture.