brahmic / clientdto
Requires
- php: ^8.4
- illuminate/collections: ^10.0|^11.0
- illuminate/http: ^10.0|^11.0
- illuminate/support: ^10.0|^11.0
- spatie/laravel-data: ^4.13
- spatie/temporary-directory: ^2.3
Requires (Dev)
- filp/whoops: ^2.17
- phpunit/phpunit: ^12.1@dev
README
What does it do?
- ✅ Integration with external API
- ✅ Error and state handling, always predictable response
- ✅ Request grouping
- ✅ Working with files and archives
- ✅ Support for queries with pagination
- ✅ Additional query attempts with flexible configuration
- ✅ HTTP Request Caching - intelligent caching with RAW/DTO modes
- ✅ Simplifies working with API
Using examples
// In the Controller // Get resource and sets the data from the Request to the Children request public function (Person $person, Request $request) { return $person->children()->set($request)->send(); } // The request data will be automatically filled in from the Request. public function (Children $children) { return $children->send(); }
// Returns CustomResponse (extended of ClientResponse, implements Illuminate\Contracts\Support\Responsable) $customClient->person()->children()->send() // Returns null or the received answer as DTO object - MyPageableResultDto in this case. $customClient->person()->children()->send()->resolved() /** @var FileResponse|null $fileResponse */ $fileResponse = $customClient->person()->report()->send()->->resolved() // Also we can get size, MIME type, set new name, etc. // If it is an archive, it will be automatically unpacked. $fileResponse->file() ->openInBrowser(true) ->saveAs('path/someReport'); $fileResponse->files()->saveTo('path/someReport');
Custom client example
class CustomClient extends ClientDTO { public function __construct(array $config = []) { $this ->cache(false) ->setBaseUrl('https://customapiservice.com/') ->setTimeout(60) ->requestCache() // Enable HTTP request caching ->requestCacheRaw() // Enable RAW response caching ->postIdempotent() // Allow POST request caching ->requestCacheTtl(3600) // Cache TTL: 1 hour ->requestCacheSize(5 * 1024 * 1024) // Cache size limit: 5MB ->onClearCache(function () { $this->report()->clearCache(); }) ->setDebug(app()->hasDebugModeEnabled()); } public function person(): Person { return new Person(); } //Primary processing of the response, all further resources along the chain receive the transformed response. public static function handle(array $data): mixed { if (array_key_exists('response', $data)) { return DefaultDto::from($data); } if (array_key_exists('query_type', $data) && array_key_exists('uuid', $data)) { return OtherDto::from($data); } return $data; } /** * @throws \Exception */ public function validation(mixed $data, AbstractRequest $abstractRequest, Response $response): mixed { if ($data instanceof DefaultDto) { $exceptionTitle = $data->statusTitle(); return match ($data->status) { SecondaryStatus::AnswerReceived->value => true, SecondaryStatus::WaitingForAResponse->value => throw new AttemptNeededException($exceptionTitle, 202), SecondaryStatus::SourceUnavailable->value => throw new AttemptNeededException($exceptionTitle, 523), SecondaryStatus::CriticalError->value => throw new Exception($exceptionTitle, 500), SecondaryStatus::UnknownError->value, => throw new Exception($exceptionTitle, 520), default => throw new \Exception($exceptionTitle, 500) }; } if ($abstractRequest instanceof Report) { return $data; } if ($response->getStatusCode() === 200) { if ($status = Arr::get($data, 'status')) { SecondaryStatus::check($status); } throw new UnresolvedResponseException("Response received but not recognized", $response); } throw new Exception("Unknown request. Check the request parameters, the request processing chain, including data processing via the `handle` method.", 500); } public function beforeExecute(AbstractRequest $request): void { if ($request instanceof CheckRequest) { $this->addQueryParam('token', '123bbd2113da3s3f1xc23c8b4927'); } } public function getResponseClass(): string { return CustomResponse::class; } }
Resource example
class Person extends AbstractResource { public const string NAME = 'Person'; //optional public function children(): Children { return new Children(); } public function report(): PersonReport { return new PersonReport(); } }
Request example
class Children extends GetRequest implements PaginableRequestInterface { use Uuid, Paginable; public const string NAME = "The person's children"; //optional #[Wrapped(CaseDto::class)] protected ?string $dto = MyPageableResultDto::class; public const string URI = 'person/{uuid}/children'; //Optional. This name will be used when sending a request to the remote API instead of "filterText" #[MapOutputName('filter_text')] //Optional. This information can be extracted later to create a registry of queries created. #[Filter(title: 'Search string', description: 'Enter text', note: 'Search by name')] public ?string $filterText = null; public function set( string $uuid, ?int $page = null, ?int $rows = null, ?string $filterText = null, ): static { return $this->assignSetValues(); } }
File Request example
class PersonReport extends GetRequest { use Uuid; public const string NAME = 'Get report'; //optional public const string URI = 'person/{uuid}/report'; protected ?string $dto = FileResponse::class; public Format $event = Format::Pdf; public function set(string $uuid, ?Format $event = null): static { return $this->assignSetValues(); } public function postProcess(FileResponse $fileResponse): void { $fileResponse->file()->openInBrowser(false)->prependFilename(self::NAME); } }
Resolved Data Handlers
ClientDTO allows you to register handlers that process resolved data after DTO creation. This provides a flexible way to modify or enhance response data before it's returned to the user.
Basic Usage
class MyClient extends ClientDTO { public function __construct() { $this->setBaseUrl('https://api.example.com'); // Handler for all resolved data $this->addResolvedHandler(function($dto, $request) { if (is_object($dto) && property_exists($dto, 'timestamp')) { $dto->timestamp = now(); } }); // Handler for specific DTO class only $this->addResolvedHandler( function(UserDto $dto, $request) { $dto->displayName = ucfirst($dto->firstName . ' ' . $dto->lastName); // Access request parameters for additional logic if ($request->includePermissions) { $dto->permissions = $this->loadUserPermissions($dto->id); } }, UserDto::class ); } }
Handler Types
Function Handlers:
// Simple function handler $client->addResolvedHandler(function($dto, $request) { // Process any resolved data }); // Type-specific handler with parameter hints $client->addResolvedHandler( function(TelegramResponseDto $dto, AbstractRequest $request) { $dto->processedAt = now(); // Access request properties if ($request instanceof SearchByPhoneRequest) { $dto->searchType = 'phone'; } }, TelegramResponseDto::class );
Class Handlers:
use Brahmic\ClientDTO\Contracts\ResolvedHandlerInterface; class UserDataEnhancer implements ResolvedHandlerInterface { public function handle(mixed $dto, AbstractRequest $request): void { if ($dto instanceof UserDto) { // Enhance user data $dto->avatar = $this->generateAvatarUrl($dto->email); $dto->lastSeen = $this->formatLastSeen($dto->lastSeenAt); // Use request context if ($request->isDebug()) { $dto->debug = ['request_id' => $request->getTrackingId()]; } } } } // Register class handler $client->addResolvedHandler(new UserDataEnhancer(), UserDto::class);
Caching Behavior
Resolved handlers work intelligently with ClientDTO's caching system:
RAW Cache Mode (requestCacheRaw(true)
):
- Handlers execute every time data is accessed (even from cache)
- Raw HTTP response is cached, DTO is rebuilt each time
- Handlers always have fresh context
DTO Cache Mode (default):
- Handlers execute once before caching
- Processed DTO is cached with handler modifications
- Subsequent cache hits return pre-processed data
class MyClient extends ClientDTO { public function __construct() { $this ->requestCache() // Enable caching ->requestCacheRaw() // RAW mode: handlers run each time // This handler will run every time in RAW mode // or once before caching in DTO mode ->addResolvedHandler( function(ApiResponseDto $dto) { $dto->processingTime = microtime(true) - $dto->startTime; }, ApiResponseDto::class ); } }
Handler Parameters
All handlers receive two parameters:
Parameter | Type | Description |
---|---|---|
$dto |
mixed |
The resolved data (DTO object, string, array, etc.) |
$request |
AbstractRequest |
The original request object with parameters and context |
Use Cases
Data Enhancement:
$client->addResolvedHandler( function(ProductDto $dto, $request) { $dto->discountedPrice = $dto->price * (1 - $dto->discountPercent / 100); $dto->currencySymbol = $this->getCurrencySymbol($dto->currency); }, ProductDto::class );
Conditional Processing:
$client->addResolvedHandler( function(OrderDto $dto, $request) { // Only process for admin requests if ($request->userRole === 'admin') { $dto->internalNotes = $this->loadInternalNotes($dto->id); $dto->profitMargin = $dto->revenue - $dto->cost; } }, OrderDto::class );
Debug Information:
$client->addResolvedHandler(function($dto, $request) { if ($request->isDebug() && is_object($dto)) { $dto->_debug = [ 'request_class' => get_class($request), 'response_time' => $request->getResponseTime(), 'cache_hit' => $request->wasCacheHit() ]; } });
Human-Readable Labels for DTO Fields
ClientDTO provides automatic transformation of DTO fields into human-readable labeled format, perfect for frontend display and API responses.
Basic Usage
Add labels to DTO properties and enable automatic transformation:
use Brahmic\ClientDTO\Attributes\Label; use Brahmic\ClientDTO\Attributes\WithLabels; use Brahmic\ClientDTO\Support\Data; #[WithLabels(autoTransform: true)] class UserProfileDto extends Data { #[Label('User ID')] public ?int $id; #[Label('Full Name')] public ?string $name; #[Label('Email Address')] public ?string $email; #[Label('Registration Date')] public ?Carbon $created_at; #[Label('Profile Status')] public ?string $status; }
Automatic Transformation
With #[WithLabels(autoTransform: true)]
, all labeled fields are automatically transformed when converting DTO to array:
$user = UserProfileDto::from([ 'id' => 123, 'name' => 'John Doe', 'email' => 'john@example.com', 'created_at' => '2023-01-15', 'status' => null // null values are also handled ]); // Automatic labeled transformation via toArray() $result = $user->toArray(); /* Result: [ 'id' => [ 'key' => 'id', 'name' => 'User ID', 'value' => 123 ], 'name' => [ 'key' => 'name', 'name' => 'Full Name', 'value' => 'John Doe' ], 'email' => [ 'key' => 'email', 'name' => 'Email Address', 'value' => 'john@example.com' ], 'created_at' => [ 'key' => 'created_at', 'name' => 'Registration Date', 'value' => '2023-01-15' ], 'status' => [ 'key' => 'status', 'name' => 'Profile Status', 'value' => null // null values included with labels ] ] */
Manual Labeled Access
Get labeled data without automatic transformation:
#[WithLabels(autoTransform: false)] // Disable auto transformation class ProductDto extends Data { #[Label('Product Name')] public ?string $name; #[Label('Price (USD)')] public ?float $price; } $product = ProductDto::from(['name' => 'iPhone', 'price' => 999.99]); // Manual labeled access $labeled = $product->asLabeled(); /* [ [ 'key' => 'name', 'name' => 'Product Name', 'value' => 'iPhone' ], [ 'key' => 'price', 'name' => 'Price (USD)', 'value' => 999.99 ] ] */ // Regular object access still works echo $product->name; // 'iPhone' echo $product->price; // 999.99 // Regular toArray() without labels (since autoTransform: false) $regular = $product->toArray(); // ['name' => 'iPhone', 'price' => 999.99]
Working with Existing Transformers
Label functionality respects existing #[WithTransformer]
attributes:
use Spatie\LaravelData\Attributes\WithTransformer; use Spatie\LaravelData\Transformers\DateTimeInterfaceTransformer; #[WithLabels(autoTransform: true)] class EventDto extends Data { #[Label('Event Name')] public ?string $title; #[Label('Event Date')] #[WithTransformer(DateTimeInterfaceTransformer::class, format: 'Y-m-d H:i')] public ?Carbon $event_date; // Custom transformer takes priority #[Label('Participant Count')] public ?int $participants; }
Frontend Integration Examples
Vue.js Template:
<template> <div v-for="field in userFields" :key="field.key"> <label>{{ field.name }}:</label> <span>{{ field.value || 'N/A' }}</span> </div> </template> <script> export default { data() { return { userFields: [] // Will be populated from API } }, async mounted() { const response = await api.getUser(123); this.userFields = Object.values(response.data); // Labeled format } } </script>
React Component:
function UserProfile({ userId }) { const [userFields, setUserFields] = useState([]); useEffect(() => { api.getUser(userId).then(response => { setUserFields(Object.values(response.data)); }); }, [userId]); return ( <div> {userFields.map(field => ( <div key={field.key}> <strong>{field.name}:</strong> <span>{field.value ?? 'N/A'}</span> </div> ))} </div> ); }
Performance and Caching
Label functionality includes intelligent caching for optimal performance:
- Reflection caching - Class and property metadata cached automatically
- Production optimization - Full caching in production environment
- Development friendly - Automatic cache clearing in testing
- Process-scoped - Cache lives for request lifecycle
// First call: Reflection + caching $user = UserDto::from($data); $labeled1 = $user->toArray(); // ← Reflection analysis // Subsequent calls: From cache (instant) $labeled2 = $user->toArray(); // ← From cache $labeled3 = $user->toArray(); // ← From cache
Cache Management
For rare edge cases, manual cache clearing is available:
use Brahmic\ClientDTO\Support\LabelUtility; // Clear all label caches (rarely needed) LabelUtility::clearAllCaches();
Use Cases
API Responses:
// Transform DTO for consistent API responses return response()->json([ 'user' => $userDto->toArray(), // Automatic labeled format 'status' => 'success' ]);
Form Generation:
// Generate dynamic forms from DTO structure $fields = $userDto->asLabeled(); foreach ($fields as $field) { echo "<label>{$field['name']}</label>"; echo "<input name='{$field['key']}' value='{$field['value']}'>"; }
Data Tables:
// Create table headers from DTO labels $users = collect($apiResponse)->map(fn($user) => UserDto::from($user)); $headers = array_keys($users->first()->toArray()); $data = $users->map(fn($user) => $user->toArray())->toArray();
Property-level WithLabels (Composite DTOs)
For complex DTOs containing other DTOs, you can control labeling behavior at the property level using #[WithLabels]
attributes:
use Brahmic\ClientDTO\Attributes\WithLabels; class OrderDto extends Data { #[Label('Order Number')] public ?string $number; #[Label('Order Date')] public ?Carbon $created_at; // Force labels ON for this property (overrides CustomerDto class-level setting) #[WithLabels(autoTransform: true, withKey: false)] public ?CustomerDto $customer; // Force labels OFF for this property #[WithLabels(autoTransform: false)] public ?DeliveryDto $delivery; } #[WithLabels(autoTransform: false, withKey: true)] // Class-level default: no labels class CustomerDto extends Data { #[Label('Customer Name')] public ?string $name; #[Label('Customer Email')] public ?string $email; } class DeliveryDto extends Data { // No labels defined - regular DTO public ?string $method; public ?string $address; }
Result:
$order = OrderDto::from([...]); $result = $order->toArray(); /* [ 'number' => [ 'name' => 'Order Number', 'value' => 'ORD-123' ], 'created_at' => [ 'name' => 'Order Date', 'value' => '2023-01-15' ], 'customer' => [ 'name' => [ // ← CustomerDto with labels (property-level override) 'name' => 'Customer Name', 'value' => 'John Doe' ], 'email' => [ 'name' => 'Customer Email', 'value' => 'john@example.com' ] ], 'delivery' => [ // ← DeliveryDto without labels 'method' => 'express', 'address' => '123 Main St' ] ] */
Priority System:
- Property-level
#[WithLabels]
on parent DTO properties (HIGHEST priority) - Class-level
#[WithLabels]
on child DTO classes - Default behavior (no labels)
Key Features:
- Hierarchical control: Parent DTOs control how their child DTOs are transformed
- Automatic inheritance: Child DTOs inherit context from their parents
- Override capability: Property-level settings always override class-level settings
- Zero boilerplate: Just add attributes, no additional code needed
⚠️ Thread Safety Note: Property-level WithLabels uses static context and is not thread-safe in async PHP environments (Swoole, ReactPHP).
GroupedRequest - Composite API Calls
ClientDTO provides GroupedRequest
functionality to combine multiple API calls into a single logical operation. This is useful when you need data from multiple endpoints to build a complete picture.
Basic GroupedRequest
use Brahmic\ClientDTO\Contracts\GroupedRequest; use Illuminate\Support\Collection; class HouseCompleteInfoRequest extends GetRequest implements GroupedRequest { public const string NAME = 'Complete house information'; // Final DTO that will be returned to user protected ?string $dto = HouseCompleteInfoDto::class; protected bool $groupedWithKeys = true; public ?string $house_id = null; public function set(string $house_id): static { return $this->assignSetValues(); } // Define which requests to execute public function getRequestClasses(): Collection { return collect([ HouseInfoRequest::class, HousePassportRequest::class, ]); } }
Composite DTO Structure
class HouseCompleteInfoDto extends Data { /** * House basic information (from HouseInfoRequest) */ public ?HouseInfoDto $houseInfoDto; /** * House passport data (from HousePassportRequest) */ public ?HousePassportDto $housePassportDto; }
Advanced: Data Processing with handle()
For cases where you need to process the collected data before creating the final DTO, use the handle()
method:
class HouseAnalyticsRequest extends GetRequest implements GroupedRequest { public const string NAME = 'House analytics with processing'; // Final DTO (what user gets) protected ?string $dto = HouseAnalyticsDto::class; // Intermediate DTO (for data collection) protected ?string $groupedDto = HouseCompleteInfoDto::class; protected bool $groupedWithKeys = true; public ?string $house_id = null; public function set(string $house_id): static { return $this->assignSetValues(); } public function getRequestClasses(): Collection { return collect([ HouseInfoRequest::class, HousePassportRequest::class, ]); } /** * Process the intermediate DTO before final transformation * Returns array for creating HouseAnalyticsDto::from() */ public static function handle(HouseCompleteInfoDto $intermediateDto): array { // Process the collected data and return array for final DTO creation return [ 'condition_score' => static::calculateScore($intermediateDto), 'investment_rating' => static::calculateRating($intermediateDto), 'recommendations' => static::generateRecommendations($intermediateDto), 'analysis_date' => now()->toDateString(), 'processed_data_count' => 2, // HouseInfo + HousePassport ]; } private static function calculateScore(HouseCompleteInfoDto $data): int { $score = 100; if ($data->houseInfoDto?->deterioration > 50) { $score -= 30; } return max(0, $score); } }
Analytics DTO
class HouseAnalyticsDto extends Data { public ?int $condition_score; public ?float $investment_rating; public ?array $recommendations; }
Execution Flow
Standard GroupedRequest:
- Execute child requests →
HouseInfoDto
+HousePassportDto
- Collect data into composite DTO →
HouseCompleteInfoDto
- Return result →
HouseCompleteInfoDto
GroupedRequest with handle():
- Execute child requests →
HouseInfoDto
+HousePassportDto
- Collect into intermediate DTO →
HouseCompleteInfoDto
- Call handle() method → Process intermediate data, return array
- Create final DTO →
HouseAnalyticsDto::from(array)
- Return result →
HouseAnalyticsDto
Usage Examples
// Standard composite request $response = $client->houses() ->getCompleteInfo() ->set('house-guid-123') ->send(); if ($response->success()) { $data = $response->resolved; // HouseCompleteInfoDto echo $data->houseInfoDto->address; echo $data->housePassportDto->total_area; } // Analytics request with processing $analytics = $client->houses() ->getAnalytics() ->set('house-guid-123') ->send(); if ($analytics->success()) { $result = $analytics->resolved; // HouseAnalyticsDto echo "Score: {$result->condition_score}/100"; print_r($result->recommendations); }
Key Features
- No URI required - Virtual request that doesn't hit a single endpoint
- Automatic parameter passing - Public properties are passed to child requests
- Structured results -
$groupedWithKeys = true
creates properly keyed DTOs - Data processing - Optional
handle()
method for custom data transformation - Type safety - Full IDE support and type hints throughout the process
⚠️ Performance Note: GroupedRequest executes multiple HTTP requests. Consider API rate limits and use caching when appropriate.
HTTP Request Caching
ClientDTO provides intelligent HTTP request caching with support for both RAW and DTO modes.
Basic Configuration
class MyClient extends ClientDTO { public function __construct() { $this ->requestCache() // Enable HTTP request caching ->requestCacheRaw() // Enable RAW response caching (optional) ->postIdempotent() // Allow POST request caching (optional) ->requestCacheTtl(3600) // Cache TTL: 1 hour (optional) ->requestCacheSize(5 * 1024 * 1024); // Cache size limit: 5MB (optional) } }
Caching Methods
Method | Description | Default |
---|---|---|
requestCache() |
Enable HTTP request caching | false |
requestCacheRaw() |
Cache raw HTTP responses instead of DTOs | false |
postIdempotent() |
Allow POST requests to be cached | false |
requestCacheTtl(int $seconds) |
Set cache TTL in seconds | null (no limit) |
requestCacheSize(int $bytes) |
Set max cache entry size | 1MB |
Caching Modes
DTO Caching (default):
- Caches resolved DTO objects
- Smaller memory footprint
- Faster access to structured data
RAW Caching:
- Caches original HTTP response body
- Preserves exact server response
- Useful for debugging or when raw data is needed
Per-Request Control
Use the #[Cacheable]
attribute to control caching for specific requests:
use Brahmic\ClientDTO\Attributes\Cacheable; #[Cacheable(enabled: true, ttl: 7200)] // Cache for 2 hours class GetUserRequest extends GetRequest { // This request will be cached regardless of global settings } #[Cacheable(enabled: false)] // Never cache class CreateUserRequest extends PostRequest { // This request will never be cached }
Cache Behavior
Default Behavior:
- GET requests: Cached if
requestCache()
is enabled - POST requests: Not cached unless
postIdempotent()
is called - Cache keys include request class, method, URL, and parameters
- RAW and DTO caches are separate (different cache keys)
Priority Order:
#[Cacheable]
attribute on request classpostIdempotent()
setting for POST requests- Global
requestCache()
setting
Cache Management
// Clear all ClientDTO caches $client->clearRequestCache(); // Access cached response info $response = $client->users()->get()->send(); if ($response->getMessage() === 'Successful (cached)') { // Response came from cache }