Simplifying Data Mapping with PHP 8 Attributes
In modern PHP development, specific data structures (DTOs) are preferred over loose associative arrays. For this blog, we convert Markdown "frontmatter" (YAML) into a structured Post object.
A common (and slightly messy) way to do this is passing an array to a constructor and manually assigning keys. Today, let's explore a cleaner approach using PHP 8 Attributes.
The Problem: Array Spaghetti
// Old way
class Post {
public function __construct(array $data) {
$this->title = $data['title'] ?? null;
$this->date = isset($data['date']) ? Carbon::parse($data['date']) : null;
// ... and so on
}
}
This works, but the mapping logic is hardcoded inside the constructor.
The Solution: Attributes
We can define an attribute #[MapFrom] to tell our hydrator where to find the data.
1. Define the Attribute
namespace App\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
class MapFrom
{
public function __construct(
public string $key
) {}
}
2. Annotate the DTO
Now our Post class becomes purely declarative.
namespace App\DTO;
use App\Attributes\MapFrom;
use Carbon\Carbon;
class PostDTO
{
#[MapFrom('title')]
public string $title;
// Use a different key in YAML vs Property name
#[MapFrom('description')]
public string $summary;
#[MapFrom('date')]
public ?Carbon $publishedAt;
public function __construct() {}
}
3. The Mapper Service
We need a flexible mapper that reads these attributes using Reflection.
namespace App\Services;
use ReflectionClass;
use ReflectionProperty;
use App\Attributes\MapFrom;
class DataMapper
{
public static function map(string $class, array $source): object
{
$reflection = new ReflectionClass($class);
$instance = $reflection->newInstance();
foreach ($reflection->getProperties() as $property) {
$attributes = $property->getAttributes(MapFrom::class);
if (empty($attributes)) {
continue;
}
$attribute = $attributes[0]->newInstance();
$key = $attribute->key;
if (array_key_exists($key, $source)) {
$value = $source[$key];
// Optional: add type casting logic here based on property type
// e.g., converting string dates to Carbon
$property->setValue($instance, $value);
}
}
return $instance;
}
}
Advanced Handling: Casting
You might notice the date complication. We can extend our attribute or mapping logic to handle types.
// Enhanced mapping loop
$type = $property->getType();
if ($type && $type->getName() === 'Carbon\Carbon' && is_string($value)) {
$value = \Carbon\Carbon::parse($value);
}
Why do this?
- Decoupling: The logic for how to construct an object is separated from the object definition itself.
- Reusability: The
DataMappercan be used for any DTO in your application (Markdown frontmatter, API responses, CSV rows). - Readability: Looking at the
PostDTO, you instantly see where data comes from without reading parsing code.
This pattern is heavily used in libraries like spatie/laravel-data, but implementing a lightweight version yourself is a great way to understand the power of PHP Reflection.