phpdot / error
Structured error codes with context, translatable messages, and uniform output across every channel.
Requires
- php: >=8.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
README
Structured error codes with context, translatable messages, and uniform output across every channel. Zero dependencies.
Table of Contents
- Install
- Quick Start
- Architecture
- Defining Error Codes
- ErrorEntry (The DTO)
- ErrorBag (The Collector)
- ErrorType (9 Categories)
- HttpStatus (Typed Enum)
- Context — What the Error Relates To
- Translation (i18n)
- Output Formats
- Real-World Usage
- API Reference
- License
Install
composer require phpdot/error
| Requirement | Version |
|---|---|
| PHP | >= 8.3 |
| Dependencies | None |
Quick Start
1. Define errors for your module:
enum UserErrors: string implements ErrorCodeInterface { use ErrorCodeTrait; case NOT_FOUND = '00010001'; case EMAIL_TAKEN = '00010002'; case INVALID_EMAIL = '00010003'; case WEAK_PASSWORD = '00010004'; public function getDetails(): array { return match ($this) { self::NOT_FOUND => [ 'message' => 'User not found', 'description' => 'errors.user.not_found', 'type' => ErrorType::NOT_FOUND, 'httpStatus' => HttpStatus::NOT_FOUND->value, ], self::EMAIL_TAKEN => [ 'message' => 'Email is already taken', 'description' => 'errors.user.email_taken', 'type' => ErrorType::CONFLICT, 'httpStatus' => HttpStatus::CONFLICT->value, ], // ... }; } }
2. Collect errors:
$errors = new ErrorBag(); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors->add(UserErrors::INVALID_EMAIL, 'email'); } if (strlen($password) < 8) { $errors->add(UserErrors::WEAK_PASSWORD, 'password', ['min' => 8]); } if ($errors->hasErrors()) { return $errors; // same structure for JSON, HTML, WebSocket, CLI }
3. Same output everywhere:
{
"errors": [
{
"code": "00010003",
"message": "Invalid email address",
"description": "errors.user.invalid_email",
"type": "validation",
"httpStatus": 422,
"context": "email",
"params": []
}
]
}
Architecture
Flow
Module enum (UserErrors, OrderErrors, etc.)
implements ErrorCodeInterface
uses ErrorCodeTrait
│
│ provides: code, message, description (i18n key), type, httpStatus
▼
ErrorBag::add(UserErrors::EMAIL_TAKEN, 'email', ['email' => $email])
│
│ creates ErrorEntry DTO
▼
ErrorBag collects ErrorEntry objects
│
├──► JSON API: $bag->toArray() → uniform JSON
├──► HTML form: $bag->forContext('email') → show next to field
├──► CLI: $bag->all() → formatted output
└──► WebSocket: $bag->toArray() → same JSON
One error. One code. One structure. Every channel. Every language.
Package Structure
src/
├── ErrorCodeInterface.php # Interface for module error enums
├── ErrorCodeTrait.php # Default implementation via getDetails()
├── ErrorEntry.php # Readonly DTO — single error
├── ErrorBag.php # Collector — add, filter, merge, serialize
├── ErrorType.php # 9 error categories
└── HttpStatus.php # HTTP status codes enum
6 files. 409 lines. Zero dependencies.
Defining Error Codes
ErrorCodeInterface
Every module defines a backed string enum implementing this interface:
interface ErrorCodeInterface { public function getCode(): string; public function getMessage(): string; public function getDescription(): string; public function getType(): ErrorType; public function getHttpStatus(): int; public function getDetails(): array; }
ErrorCodeTrait
Provides the default implementation. The trait reads from getDetails() — you only implement one method:
trait ErrorCodeTrait { public function getCode(): string { return $this->value; } public function getMessage(): string { return $this->getDetails()['message']; } public function getDescription(): string { return $this->getDetails()['description']; } public function getType(): ErrorType { return $this->getDetails()['type']; } public function getHttpStatus(): int { return $this->getDetails()['httpStatus']; } }
Module Error Enums
Each module owns its errors. No central error file.
// User module enum UserErrors: string implements ErrorCodeInterface { use ErrorCodeTrait; case NOT_FOUND = '00010001'; case EMAIL_TAKEN = '00010002'; case INVALID_EMAIL = '00010003'; case WEAK_PASSWORD = '00010004'; case LOCKED = '00010005'; public function getDetails(): array { return match ($this) { self::NOT_FOUND => [ 'message' => 'User not found', 'description' => 'errors.user.not_found', 'type' => ErrorType::NOT_FOUND, 'httpStatus' => HttpStatus::NOT_FOUND->value, ], self::EMAIL_TAKEN => [ 'message' => 'Email is already taken', 'description' => 'errors.user.email_taken', 'type' => ErrorType::CONFLICT, 'httpStatus' => HttpStatus::CONFLICT->value, ], self::INVALID_EMAIL => [ 'message' => 'Invalid email address', 'description' => 'errors.user.invalid_email', 'type' => ErrorType::VALIDATION, 'httpStatus' => HttpStatus::UNPROCESSABLE_ENTITY->value, ], self::WEAK_PASSWORD => [ 'message' => 'Password must be at least 8 characters', 'description' => 'errors.user.weak_password', 'type' => ErrorType::VALIDATION, 'httpStatus' => HttpStatus::UNPROCESSABLE_ENTITY->value, ], self::LOCKED => [ 'message' => 'Account is locked', 'description' => 'errors.user.account_locked', 'type' => ErrorType::AUTHORIZATION, 'httpStatus' => HttpStatus::FORBIDDEN->value, ], }; } } // Order module — separate file, separate team, no conflicts enum OrderErrors: string implements ErrorCodeInterface { use ErrorCodeTrait; case NOT_FOUND = '00020001'; case ALREADY_SHIPPED = '00020002'; case PAYMENT_FAILED = '00020003'; public function getDetails(): array { /* ... */ } }
Error Code Convention
Format: MMMMNNNN (8 digits)
^^^^ = module ID (0001-9999)
^^^^ = error number within module (0001-9999)
Assignments:
0001 = User / Auth
0002 = Order
0003 = Product
0004 = Payment
0005 = Event
...
ErrorEntry (The DTO)
Pure data. No translation, no escaping. Immutable.
final readonly class ErrorEntry { public string $code; // '00010003' public string $message; // 'Invalid email address' (English fallback) public string $description; // 'errors.user.invalid_email' (i18n key) public ErrorType $type; // ErrorType::VALIDATION public int $httpStatus; // 422 public ?string $context; // 'email' (field, param, header, service, path) public array $params; // ['min' => 8] (ICU interpolation params) } $entry->toArray(); // serializable array
ErrorBag (The Collector)
Adding Errors
$bag = new ErrorBag(); // From module enum $bag->add(UserErrors::INVALID_EMAIL, 'email'); $bag->add(UserErrors::WEAK_PASSWORD, 'password', ['min' => 8]); // Raw entry $bag->addEntry(new ErrorEntry('CUSTOM', 'msg', 'desc', ErrorType::SERVER, 500)); // Chainable $bag->add(UserErrors::INVALID_EMAIL, 'email') ->add(UserErrors::WEAK_PASSWORD, 'password');
Checking Errors
$bag->hasErrors(); // bool $bag->hasError(UserErrors::EMAIL_TAKEN); // check specific code $bag->count(); // int $bag->first(); // ?ErrorEntry $bag->all(); // list<ErrorEntry> $bag->codes(); // list<string> — unique codes
Filtering Errors
// By context (field, param, header, etc.) $bag->forContext('email'); // list<ErrorEntry> $bag->forContext('password'); // list<ErrorEntry> $bag->forContext('Authorization'); // list<ErrorEntry> // By error type $bag->ofType(ErrorType::VALIDATION); // list<ErrorEntry> $bag->ofType(ErrorType::NOT_FOUND); // list<ErrorEntry> $bag->ofType(ErrorType::AUTHENTICATION); // list<ErrorEntry>
Merging Bags
Combine errors from sub-operations:
$userErrors = $userService->validate($data); $orderErrors = $orderService->validate($data); $combined = new ErrorBag(); $combined->merge($userErrors)->merge($orderErrors);
HTTP Status
Derived from the first error. If empty, returns 500.
$bag->getHttpStatus(); // 422 (from first error)
Serialization
$bag->toArray(); // [ // ['code' => '00010003', 'message' => '...', 'description' => '...', 'type' => 'validation', 'httpStatus' => 422, 'context' => 'email', 'params' => []], // ['code' => '00010004', 'message' => '...', 'description' => '...', 'type' => 'validation', 'httpStatus' => 422, 'context' => 'password', 'params' => ['min' => 8]], // ] json_encode(['errors' => $bag->toArray()]);
Clear and reset:
$bag->clear(); // remove all errors, returns self
ErrorType (9 Categories)
enum ErrorType: string { case VALIDATION = 'validation'; // input is wrong case AUTHENTICATION = 'authentication'; // who are you? case AUTHORIZATION = 'authorization'; // you can't do this case NOT_FOUND = 'not_found'; // doesn't exist case CONFLICT = 'conflict'; // duplicate, version mismatch case RATE_LIMIT = 'rate_limit'; // too many requests case TIMEOUT = 'timeout'; // took too long case UNAVAILABLE = 'unavailable'; // service down case SERVER = 'server'; // unexpected internal error }
The frontend uses the type to decide presentation (red badge for server, yellow for validation, etc.). The error code gives the specific problem.
HttpStatus (Typed Enum)
IDE autocompletion and compile-time safety for HTTP status codes:
enum HttpStatus: int { case OK = 200; case CREATED = 201; case NO_CONTENT = 204; case BAD_REQUEST = 400; case UNAUTHORIZED = 401; case FORBIDDEN = 403; case NOT_FOUND = 404; case CONFLICT = 409; case UNPROCESSABLE_ENTITY = 422; case TOO_MANY_REQUESTS = 429; case INTERNAL_SERVER_ERROR = 500; case SERVICE_UNAVAILABLE = 503; // ... 25 codes total } // In error enums 'httpStatus' => HttpStatus::NOT_FOUND->value, // 404
Context
Context is what the error relates to. Not just form fields.
$errors->add(UserErrors::INVALID_EMAIL, 'email'); // form field $errors->add(UserErrors::NOT_FOUND, 'user_id'); // route parameter $errors->add(AuthErrors::INVALID_TOKEN, 'Authorization'); // HTTP header $errors->add(PaymentErrors::GATEWAY_DOWN, 'stripe'); // service name $errors->add(OrderErrors::INVALID_ADDRESS, 'address.city'); // nested path $errors->add(SystemErrors::MAINTENANCE); // no context — global
Filter by context to show errors next to the right element:
$errors->forContext('email'); // errors for the email field $errors->forContext('Authorization'); // errors for the auth header
Translation (i18n)
How Translation Works
The error package stores translation keys, not translated text. Translation happens at render time.
Error created → description = 'errors.user.email_taken'
(this is a translation key, not text)
│
┌───────────────────────────────┤
│ │
▼ ▼
JSON API HTML (server-rendered)
returns the key → $i18n->trans($error->description)
frontend translates → "البريد الإلكتروني مستخدم"
ICU Params
Dynamic values for translation interpolation. Works with any ICU-compatible i18n library — phpdot/i18n is one option, but not required.
$errors->add(ProductErrors::INSUFFICIENT_STOCK, 'quantity', [ 'available' => 5, 'requested' => 10, ]); // Translation files: // en: 'errors.product.insufficient_stock' → 'Only {available} items in stock, you requested {requested}' // ar: 'errors.product.insufficient_stock' → 'يتوفر {available} عناصر فقط، طلبت {requested}'
Frontend Translation
The frontend receives the error code and description key, translates in its own i18n system:
response.errors.forEach(error => { const translated = i18n.t(error.description, error.params); showError(error.context, translated); });
Server-Side Translation
With any translation library:
foreach ($bag->all() as $error) { $translated = $i18n->trans($error->description, $error->params); // Show next to the form field identified by $error->context }
Output Formats
JSON API
{
"errors": [
{
"code": "00010003",
"message": "Invalid email address",
"description": "errors.user.invalid_email",
"type": "validation",
"httpStatus": 422,
"context": "email",
"params": []
},
{
"code": "00010004",
"message": "Password must be at least 8 characters",
"description": "errors.user.weak_password",
"type": "validation",
"httpStatus": 422,
"context": "password",
"params": {"min": 8}
}
]
}
HTML Forms
foreach ($bag->forContext('email') as $error) { echo '<span class="error">' . $i18n->trans($error->description, $error->params) . '</span>'; }
CLI
[00010003] Invalid email address (context: email)
[00010004] Password must be at least 8 characters (context: password)
WebSocket
Same $bag->toArray() serialized as JSON. Identical structure to the API.
Real-World Usage
Service Validation
final class UserService { public function register(string $email, string $password): User|ErrorBag { $errors = new ErrorBag(); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors->add(UserErrors::INVALID_EMAIL, 'email'); } if (strlen($password) < 8) { $errors->add(UserErrors::WEAK_PASSWORD, 'password', ['min' => 8]); } if ($errors->hasErrors()) { return $errors; } if ($this->users->emailExists($email)) { $errors->add(UserErrors::EMAIL_TAKEN, 'email'); return $errors; } return $this->users->create($email, $password); } }
Cross-Module Merge
$userErrors = $userService->validate($data); $addressErrors = $addressService->validate($data['address']); $errors = new ErrorBag(); $errors->merge($userErrors)->merge($addressErrors); if ($errors->hasErrors()) { return response()->json(['errors' => $errors->toArray()], $errors->getHttpStatus()); }
Frontend Grouping
$grouped = []; foreach ($bag->all() as $error) { $key = $error->context ?? '_global'; $grouped[$key][] = $error->toArray(); } // $grouped['email'] → [{code: '00010003', ...}, {code: '00010002', ...}] // $grouped['password'] → [{code: '00010004', ...}] // $grouped['_global'] → [{code: '00060001', ...}]
API Reference
ErrorCodeInterface API
interface ErrorCodeInterface
getCode(): string
getMessage(): string
getDescription(): string
getType(): ErrorType
getHttpStatus(): int
getDetails(): array{message: string, description: string, type: ErrorType, httpStatus: int}
ErrorCodeTrait API
trait ErrorCodeTrait
getCode(): string // returns $this->value
getMessage(): string // returns getDetails()['message']
getDescription(): string // returns getDetails()['description']
getType(): ErrorType // returns getDetails()['type']
getHttpStatus(): int // returns getDetails()['httpStatus']
ErrorEntry API
final readonly class ErrorEntry
__construct(
public string $code,
public string $message,
public string $description,
public ErrorType $type,
public int $httpStatus,
public ?string $context = null,
public array<string, mixed> $params = [],
)
toArray(): array{code, message, description, type, httpStatus, context, params}
ErrorBag API
final class ErrorBag
add(ErrorCodeInterface $error, ?string $context = null, array $params = []): self
addEntry(ErrorEntry $entry): self
hasErrors(): bool
hasError(ErrorCodeInterface $error): bool
all(): list<ErrorEntry>
first(): ?ErrorEntry
forContext(string $context): list<ErrorEntry>
ofType(ErrorType $type): list<ErrorEntry>
count(): int
merge(self $other): self
clear(): self
getHttpStatus(): int
codes(): list<string>
toArray(): list<array{code, message, description, type, httpStatus, context, params}>
ErrorType API
enum ErrorType: string
VALIDATION = 'validation'
AUTHENTICATION = 'authentication'
AUTHORIZATION = 'authorization'
NOT_FOUND = 'not_found'
CONFLICT = 'conflict'
RATE_LIMIT = 'rate_limit'
TIMEOUT = 'timeout'
UNAVAILABLE = 'unavailable'
SERVER = 'server'
HttpStatus API
enum HttpStatus: int
| Case | Value |
|---|---|
OK |
200 |
CREATED |
201 |
ACCEPTED |
202 |
NO_CONTENT |
204 |
MOVED_PERMANENTLY |
301 |
FOUND |
302 |
NOT_MODIFIED |
304 |
TEMPORARY_REDIRECT |
307 |
PERMANENT_REDIRECT |
308 |
BAD_REQUEST |
400 |
UNAUTHORIZED |
401 |
FORBIDDEN |
403 |
NOT_FOUND |
404 |
METHOD_NOT_ALLOWED |
405 |
CONFLICT |
409 |
GONE |
410 |
PAYLOAD_TOO_LARGE |
413 |
UNSUPPORTED_MEDIA |
415 |
UNPROCESSABLE_ENTITY |
422 |
TOO_MANY_REQUESTS |
429 |
INTERNAL_SERVER_ERROR |
500 |
BAD_GATEWAY |
502 |
SERVICE_UNAVAILABLE |
503 |
GATEWAY_TIMEOUT |
504 |
License
MIT