Table of Contents
The Problem
You’re working with DTOs (Data Transfer Objects) that need to be deserialized from JSON or arrays. Every time you need to deserialize, you’re copy-pasting the same serializer setup code:
$serializer = SerializerBuilder::create()->build();
$config = $serializer->deserialize($jsonString, ConfigDTO::class, 'json');
This gets old fast. Let’s fix it.
The Solution: Static parse() Methods
Encapsulate the serialization logic inside the DTO itself with a static parse() method:
class ConfigDTO
{
public string $apiKey;
public int $timeout;
public bool $enableDebug;
public static function parse(array $data): self
{
$serializer = SerializerBuilder::create()->build();
$jsonString = json_encode($data);
return $serializer->deserialize($jsonString, self::class, 'json');
}
}
Now instead of:
$serializer = SerializerBuilder::create()->build();
$config = $serializer->deserialize(
json_encode($requestData),
ConfigDTO::class,
'json'
);
You write:
$config = ConfigDTO::parse($requestData);
Why This is Better
1. Single Source of Truth
Your DTO knows how to deserialize itself. If you need to change serializer configuration (add normalizers, change naming strategy, etc.), you change it in one place.
2. Cleaner Call Sites
Controllers, services, and tests become dramatically cleaner:
// Before
public function store(Request $request, SerializerInterface $serializer)
{
$config = $serializer->deserialize(
json_encode($request->all()),
ConfigDTO::class,
'json'
);
// ...
}
// After
public function store(Request $request)
{
$config = ConfigDTO::parse($request->all());
// ...
}
3. Easier to Test
Testing becomes straightforward because the DTO handles its own deserialization:
public function test_config_parsing()
{
$config = ConfigDTO::parse([
'api_key' => 'test-key',
'timeout' => 30,
'enable_debug' => true
]);
$this->assertEquals('test-key', $config->apiKey);
$this->assertEquals(30, $config->timeout);
$this->assertTrue($config->enableDebug);
}
4. Validation in One Place
You can add validation logic to the parse() method:
public static function parse(array $data): self
{
if (empty($data['api_key'])) {
throw new InvalidArgumentException('API key is required');
}
$serializer = SerializerBuilder::create()->build();
$jsonString = json_encode($data);
return $serializer->deserialize($jsonString, self::class, 'json');
}
Real-World Example: API Response DTO
class ApiResponseDTO
{
public bool $success;
public string $message;
public ?array $data;
public ?int $errorCode;
public static function parse(array $responseData): self
{
// Custom validation before deserialization
if (!isset($responseData['success'])) {
throw new MalformedResponseException('Missing success field');
}
$serializer = SerializerBuilder::create()
->setPropertyNamingStrategy(
new SerializedNameAnnotationStrategy(
new CamelCaseNamingStrategy()
)
)
->build();
return $serializer->deserialize(
json_encode($responseData),
self::class,
'json'
);
}
}
// Usage in an API client
$response = $client->get('/api/users');
$dto = ApiResponseDTO::parse($response->json());
if ($dto->success) {
return $dto->data;
}
throw new ApiException($dto->message, $dto->errorCode);
Bonus: Support Multiple Formats
You can add format-specific parse methods:
class ConfigDTO
{
// ... properties ...
public static function parse(array $data): self
{
// Same as before
}
public static function fromJson(string $json): self
{
$serializer = SerializerBuilder::create()->build();
return $serializer->deserialize($json, self::class, 'json');
}
public static function fromXml(string $xml): self
{
$serializer = SerializerBuilder::create()->build();
return $serializer->deserialize($xml, self::class, 'xml');
}
}
When NOT to Use This Pattern
This pattern works great for JMS Serializer or Symfony Serializer. If you’re using Laravel’s native JSON casting or Eloquent models, stick with Laravel’s conventions instead.
The Takeaway
Don’t scatter serializer instantiation across your codebase. Put it in the DTO where it belongs. Your future self will thank you when you need to change how deserialization works.
Leave a Reply