Attributes System
Attributes (also known as PHP 8 Attributes or Annotations) provide a powerful way to add metadata and behavior to your REST API methods. This reference architecture includes two custom attributes that integrate with the ByJG RestServer framework.
Table of Contents
- Overview
- ValidateRequest Attribute
- RequireRole Attribute
- Combining Attributes
- Creating Custom Attributes
- Error Handling
Overview
Attributes are applied directly to REST controller methods using PHP 8 attribute syntax. They execute before your method runs, allowing you to:
- Validate incoming request data
- Enforce authentication and authorization
- Transform request payloads
- Apply cross-cutting concerns
ValidateRequest Attribute
The ValidateRequest attribute automatically validates incoming requests against your OpenAPI schema definition.
Location
src/Attributes/ValidateRequest.php
Usage
use RestReferenceArchitecture\Attributes\ValidateRequest;
#[ValidateRequest]
public function postDummy(HttpResponse $response, HttpRequest $request): void
{
// Get the validated payload
$payload = ValidateRequest::getPayload();
$dummyService = Config::get(DummyService::class);
$model = $dummyService->create($payload);
$response->write(["id" => $model->getId()]);
}
How It Works
- Automatic Validation: Validates the request body against the OpenAPI schema for this endpoint
- Content-Type Awareness: Returns different formats based on the request content-type:
- XML: Returns
XmlDocumentobject - JSON/Other: Returns associative array
- XML: Returns
- Error Response: Throws
Error400Exceptionif validation fails
Constructor Parameters
#[ValidateRequest(preserveNullValues: true)]
preserveNullValues(bool, default:false): Iffalse, null values are removed from the payload. Iftrue, null values are preserved.
Retrieving the Validated Payload
After validation, retrieve the payload using the static method:
$payload = ValidateRequest::getPayload();
Example: JSON Request
#[OA\Post(path: "/dummy", tags: ["Dummy"])]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ["field"],
properties: [
new OA\Property(property: "field", type: "string"),
new OA\Property(property: "optional", type: "integer")
]
)
)]
#[ValidateRequest]
public function postDummy(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
// $payload = ['field' => 'value', 'optional' => 123]
$field = $payload['field']; // Guaranteed to exist due to validation
}
Example: XML Request
#[OA\Post(path: "/dummy", tags: ["Dummy"])]
#[OA\RequestBody(
required: true,
content: new OA\XmlContent(
xml: new OA\Xml(name: "DummyRequest")
)
)]
#[ValidateRequest]
public function postDummyXml(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
// $payload = XmlDocument object
$field = $payload->xpath("//field")[0]->nodeValue;
}
Example: Partial Update (Default - Recommended)
#[OA\Put(path: "/clients/{id}", tags: ["Clients"])]
#[ValidateRequest] // preserveNullValues defaults to false (recommended for updates)
public function updateClient(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
// Client sends partial update:
// {"name": "Updated Name", "email": null, "phone": "555-1234"}
// With preserveNullValues: false (default)
// Payload becomes: ["name" => "Updated Name", "phone" => "555-1234"]
// Note: "email" with null is REMOVED
// Add primary key to payload (service will use it to fetch the record)
$payload['id'] = $request->param('id');
// Service update() does: getOrFail() + ObjectCopy::copy() + save()
$clientService = Config::get(ClientService::class);
$client = $clientService->update($payload);
// Result: name and phone updated, email unchanged (not set to null)
$response->write($client);
}
Example: Full Update with Explicit Nulls
#[OA\Put(path: "/clients/{id}", tags: ["Clients"])]
#[ValidateRequest(preserveNullValues: true)] // Keep null values
public function updateClient(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
// Client sends:
// {"name": "Updated Name", "email": null, "phone": "555-1234"}
// With preserveNullValues: true
// Payload stays: ["name" => "Updated Name", "email" => null, "phone" => "555-1234"]
// Note: "email" with null is KEPT
// Add primary key to payload (service will use it to fetch the record)
$payload['id'] = $request->param('id');
// Service update() does: getOrFail() + ObjectCopy::copy() + save()
$clientService = Config::get(ClientService::class);
$client = $clientService->update($payload);
// Result: name and phone updated, email set to null (cleared)
$response->write($client);
}
When to Use Each
Use preserveNullValues: false (default) - Partial updates:
// Client wants to update only name and phone, leave email as-is
PUT /clients/123
{"name": "John Doe", "phone": "555-1234"}
// With preserveNullValues: false
// Only name and phone are updated, other fields unchanged ✓
Use preserveNullValues: true - Explicit field clearing:
// Client wants to clear the email field
PUT /clients/123
{"name": "John Doe", "email": null, "phone": "555-1234"}
// With preserveNullValues: true
// Email is explicitly set to null (cleared) ✓
// With preserveNullValues: false
// Email would be ignored, not cleared ✗
What Gets Validated
The attribute validates:
- Required fields: Ensures all required properties are present
- Data types: Verifies types match the schema (string, integer, boolean, etc.)
- Format constraints: Validates formats like email, date-time, uuid
- Enums: Checks values against allowed enum values
- Nested objects: Recursively validates nested structures
- Arrays: Validates array items against schema
Validation Error Response
If validation fails, the client receives a 400 error:
{
"error": "Bad Request",
"message": "Validation failed: field 'email' must be a valid email address"
}
RequireRole Attribute
The RequireRole attribute enforces role-based access control (RBAC) for protected endpoints.
Location
src/Attributes/RequireRole.php
Usage
use RestReferenceArchitecture\Attributes\RequireRole;
use RestReferenceArchitecture\Model\User;
#[RequireRole(User::ROLE_ADMIN)]
public function postDummy(HttpResponse $response, HttpRequest $request): void
{
// This method is only accessible to users with ROLE_ADMIN
}
How It Works
- JWT Token Required: User must be authenticated with a valid JWT token
- Role Extraction: Extracts the
roleclaim from the JWT payload - Role Comparison: Compares against the required role
- Access Denied: Throws
Error403Exceptionif role doesn't match
Constructor Parameters
#[RequireRole("admin")]
role(string, required): The role required to access this endpoint
Predefined Roles
The User model defines standard roles:
namespace RestReferenceArchitecture\Model;
class User
{
const ROLE_ADMIN = 'admin';
const ROLE_USER = 'user';
}
Example: Admin-Only Endpoint
#[OA\Delete(path: "/dummy/{id}", tags: ["Dummy"])]
#[RequireRole(User::ROLE_ADMIN)]
public function deleteDummy(HttpResponse $response, HttpRequest $request): void
{
// Only admins can delete
$dummyService = Config::get(DummyService::class);
$dummyService->delete($request->param('id'));
}
Example: Custom Roles
class MyUser extends User
{
const ROLE_MODERATOR = 'moderator';
const ROLE_GUEST = 'guest';
}
// In your controller
#[RequireRole(MyUser::ROLE_MODERATOR)]
public function moderateContent(HttpResponse $response, HttpRequest $request): void
{
// Only moderators can access
}
JWT Token Structure
The JWT token must contain a role claim:
{
"userid": 1,
"name": "John Doe",
"role": "admin"
}
See JWT Advanced Guide for customizing JWT claims.
Error Responses
403 Forbidden (wrong role):
{
"error": "Forbidden",
"message": "Insufficient permissions"
}
401 Unauthorized (no token):
{
"error": "Unauthorized",
"message": "Authentication required"
}
Combining Attributes
Attributes can be combined to create powerful authorization and validation chains.
Execution Order
Attributes execute in this order:
- Authentication checks (
RequireAuthenticated,RequireRole) - Request validation (
ValidateRequest) - Your method logic
Example: Admin-Only with Validation
#[OA\Post(path: "/users", tags: ["Users"])]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ["email", "name"],
properties: [
new OA\Property(property: "email", type: "string", format: "email"),
new OA\Property(property: "name", type: "string")
]
)
)]
#[RequireRole(User::ROLE_ADMIN)]
#[ValidateRequest]
public function createUser(HttpResponse $response, HttpRequest $request): void
{
// 1. User is authenticated and has admin role
// 2. Request body is validated
// 3. Now execute your logic
$payload = ValidateRequest::getPayload();
// Create user...
}
Example: Multiple Authorization Checks
use RestReferenceArchitecture\Attributes\RequireAuthenticated;
#[RequireAuthenticated] // Must be logged in
#[RequireRole(User::ROLE_ADMIN)] // Must be admin
#[ValidateRequest] // Request must be valid
public function sensitiveOperation(HttpResponse $response, HttpRequest $request): void
{
// Triple protection
}
Creating Custom Attributes
You can create custom attributes to implement your own cross-cutting concerns.
Step 1: Create the Attribute Class
<?php
namespace RestReferenceArchitecture\Attributes;
use Attribute;
use ByJG\RestServer\Attributes\BeforeRouteInterface;
use ByJG\RestServer\HttpRequest;
use ByJG\RestServer\HttpResponse;
use ByJG\RestServer\Exception\Error429Exception;
#[Attribute(Attribute::TARGET_METHOD)]
class RateLimit implements BeforeRouteInterface
{
protected int $maxRequests;
protected int $windowSeconds;
public function __construct(int $maxRequests = 100, int $windowSeconds = 60)
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
}
public function processBefore(HttpResponse $response, HttpRequest $request): void
{
$clientId = $request->server('REMOTE_ADDR');
// Check rate limit (pseudo-code)
if ($this->isRateLimited($clientId)) {
throw new Error429Exception("Rate limit exceeded");
}
$this->recordRequest($clientId);
}
protected function isRateLimited(string $clientId): bool
{
// Implement your rate limiting logic
// Could use Redis, Memcached, or database
return false;
}
protected function recordRequest(string $clientId): void
{
// Record the request timestamp
}
}
Step 2: Use Your Custom Attribute
use RestReferenceArchitecture\Attributes\RateLimit;
#[OA\Post(path: "/api/heavy", tags: ["API"])]
#[RateLimit(maxRequests: 10, windowSeconds: 60)]
public function heavyOperation(HttpResponse $response, HttpRequest $request): void
{
// This endpoint is rate-limited to 10 requests per minute
}
Custom Attribute with Payload
<?php
namespace RestReferenceArchitecture\Attributes;
use Attribute;
use ByJG\RestServer\Attributes\BeforeRouteInterface;
use ByJG\RestServer\HttpRequest;
use ByJG\RestServer\HttpResponse;
#[Attribute(Attribute::TARGET_METHOD)]
class SanitizeInput implements BeforeRouteInterface
{
protected static ?array $sanitizedData = null;
public function processBefore(HttpResponse $response, HttpRequest $request): void
{
$payload = $request->payload();
self::$sanitizedData = $this->sanitize($payload);
}
protected function sanitize(array $data): array
{
// Strip HTML tags, trim whitespace, etc.
return array_map(function($value) {
if (is_string($value)) {
return htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8');
}
return $value;
}, $data);
}
public static function getData(): ?array
{
return self::$sanitizedData;
}
}
Usage:
#[SanitizeInput]
#[ValidateRequest]
public function createPost(HttpResponse $response, HttpRequest $request): void
{
$sanitized = SanitizeInput::getData();
$validated = ValidateRequest::getPayload();
// Use sanitized and validated data
}
Error Handling
Validation Errors
When ValidateRequest fails:
try {
// ValidateRequest attribute runs automatically
} catch (Error400Exception $e) {
// {
// "error": "Bad Request",
// "message": "Validation failed: ..."
// }
}
Authorization Errors
When RequireRole fails:
try {
// RequireRole attribute runs automatically
} catch (Error403Exception $e) {
// {
// "error": "Forbidden",
// "message": "Insufficient permissions"
// }
}
Customizing Error Messages
Override the error handling in your custom attributes:
public function processBefore(HttpResponse $response, HttpRequest $request): void
{
if (!$this->validateSomething()) {
throw new Error400Exception("Custom error message with details");
}
}
Global Error Handler
Configure global error handling in config/03-api/01-rest.php:
use ByJG\RestServer\ErrorHandler\ErrorHandler;
$errorHandler = new ErrorHandler();
$errorHandler->addHandler(function(\Throwable $ex, $request, $response) {
// Custom error logging
error_log($ex->getMessage());
// Custom error response format
return [
'status' => 'error',
'code' => $ex->getCode(),
'message' => $ex->getMessage(),
'timestamp' => date('c')
];
});
Best Practices
1. Always Validate User Input
// Good
#[ValidateRequest]
public function createResource(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
// Guaranteed valid data
}
// Bad - No validation
public function createResource(HttpResponse $response, HttpRequest $request): void
{
$payload = $request->payload();
// Could be malicious or malformed
}
2. Use Role Constants
// Good
#[RequireRole(User::ROLE_ADMIN)]
// Bad - Magic strings
#[RequireRole("admin")]
3. Order Attributes Logically
// Good - Authentication before validation
#[RequireRole(User::ROLE_ADMIN)]
#[ValidateRequest]
public function createResource(...) { }
// Works but less efficient - validates before checking auth
#[ValidateRequest]
#[RequireRole(User::ROLE_ADMIN)]
public function createResource(...) { }
4. Document Required Roles in OpenAPI
#[OA\Post(
path: "/admin/users",
security: [["jwt-token" => []]],
tags: ["Admin"]
)]
#[OA\Response(
response: 403,
description: "Requires admin role"
)]
#[RequireRole(User::ROLE_ADMIN)]
public function manageUsers(...) { }