Simplifying Data Mapping with PHP 8 Attributes

· 3 min read

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?

  1. Decoupling: The logic for how to construct an object is separated from the object definition itself.
  2. Reusability: The DataMapper can be used for any DTO in your application (Markdown frontmatter, API responses, CSV rows).
  3. 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.

Comments