Skip to content

DataMapper

DataMapper provides a modern, fluent API for transforming data between different structures. It supports template-based mapping, queries with SQL-like operators, property-specific filters, and much more.

use Event4u\DataHelpers\DataMapper;
$source = [
'user' => [
'name' => 'John Doe',
'email' => 'john@example.com',
],
'orders' => [
['id' => 1, 'total' => 100, 'status' => 'shipped'],
['id' => 2, 'total' => 200, 'status' => 'pending'],
['id' => 3, 'total' => 150, 'status' => 'shipped'],
],
];
// Approach 1: Fluent API with query builder
$result = DataMapper::from($source)
->query('orders.*')
->where('status', '=', 'shipped')
->orderBy('total', 'DESC')
->end()
->template([
'customer_name' => '{{ user.name }}',
'customer_email' => '{{ user.email }}',
'shipped_orders' => [
'*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
],
],
])
->map()
->getTarget();
// Approach 2: Template-based with WHERE/ORDER BY operators (recommended)
$template = [
'customer_name' => '{{ user.name }}',
'customer_email' => '{{ user.email }}',
'shipped_orders' => [
'WHERE' => [
'{{ orders.*.status }}' => 'shipped',
],
'ORDER BY' => [
'{{ orders.*.total }}' => 'DESC',
],
'*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
],
],
];
$result = DataMapper::from($source)
->template($template)
->map()
->getTarget();
// Both approaches produce the same result:
// [
// 'customer_name' => 'John Doe',
// 'customer_email' => 'john@example.com',
// 'shipped_orders' => [
// ['id' => 3, 'total' => 150],
// ['id' => 1, 'total' => 100],
// ],
// ]

The template-based approach (Approach 2) has a significant advantage: templates can be stored in a database and created with a drag-and-drop editor, enabling no-code data mapping:

// Store templates in database
$source = [
'user' => [
'name' => 'John Doe',
'email' => 'john@example.com',
],
'orders' => [
['id' => 1, 'total' => 100, 'status' => 'shipped'],
['id' => 2, 'total' => 200, 'status' => 'pending'],
['id' => 3, 'total' => 150, 'status' => 'shipped'],
],
];
// Load template from database (created with drag-and-drop editor)
$template = Mappings::find(3)->template;
$result = DataMapper::from($source)
->template($template)
->map()
->getTarget();

This makes it possible to map import files, API responses, etc. without any programming.

Use cases:

  • Import Wizards - Let users map CSV/Excel columns to your data structure
  • API Integration - Store API response mappings in database
  • Multi-Tenant Systems - Each tenant can have custom mappings
  • Dynamic ETL - Build data transformation pipelines without code
  • Form Builders - Map form submissions to different data structures

The DataMapper uses a fluent, chainable API:

DataMapper::from($source) // Start with source data
->target($target) // Optional: Set target object/array
->template($template) // Define mapping template
->query($path) // Start query builder
->where($field, $op, $val) // Add WHERE condition
->orderBy($field, $dir) // Add ORDER BY
->limit($n) // Add LIMIT
->end() // End query builder
->property($name) // Access property API
->setFilter($filter) // Set property filter
->end() // End property API
->pipeline($filters) // Set global filters
->skipNull() // Skip null values
->map() // Execute mapping
->getTarget(); // Get result
$result = DataMapper::from($source)
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
'age' => '{{ user.profile.age }}',
])
->map()
->getTarget();

Templates use {{ }} for dynamic values:

  • Dynamic values: '{{ user.name }}' - Fetches value from source
  • Static values: 'admin' - Used as literal string (no {{ }})
  • Dot-notation: '{{ user.profile.address.street }}' - Nested access
  • Wildcards: '{{ users.*.email }}' - Array operations
class UserDTO
{
public string $name;
public string $email;
}
$result = DataMapper::from($source)
->target(UserDTO::class)
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
])
->map()
->getTarget(); // Returns UserDTO instance
$result = DataMapper::from($source)
->template([
'customer' => [
'name' => '{{ user.name }}',
'contact' => [
'email' => '{{ user.email }}',
'phone' => '{{ user.phone }}',
],
],
'orders' => [
'*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
],
],
])
->map()
->getTarget();

The query builder provides SQL-like operators for filtering and transforming data during mapping.

// Fluent API approach
$result = DataMapper::from($source)
->query('orders.*')
->where('total', '>', 100)
->orderBy('total', 'DESC')
->limit(5)
->end()
->template([
'items' => [
'*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
],
],
])
->map()
->getTarget();
// Template-based approach (same result)
$result = DataMapper::from($source)
->template([
'items' => [
'WHERE' => [
'{{ orders.*.total }}' => ['>', 100],
],
'ORDER BY' => [
'{{ orders.*.total }}' => 'DESC',
],
'LIMIT' => 5,
'*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
],
],
])
->map()
->getTarget();
// Simple comparison
->where('status', '=', 'active')
->where('price', '>', 100)
->where('stock', '<=', 10)
// Multiple conditions (AND logic)
->where('status', '=', 'active')
->where('price', '>', 100)
// BETWEEN
->where('price', 'BETWEEN', [50, 150])
// IN
->where('status', 'IN', ['active', 'pending'])
// LIKE (pattern matching)
->where('name', 'LIKE', 'John%')
// NULL checks
->where('deleted_at', 'IS NULL')
->where('email', 'IS NOT NULL')
// Single field
->orderBy('price', 'DESC')
// Multiple fields
->orderBy('category', 'ASC')
->orderBy('price', 'DESC')
// Limit results
->limit(10)
// Skip items
->offset(20)
// Pagination
->offset(20)
->limit(10)
// Remove duplicates
->distinct('email')
// Group and aggregate
->groupBy('category', [
'total' => 'SUM(price)',
'count' => 'COUNT(*)',
'avg_price' => 'AVG(price)',
])

Apply filters to all mapped values globally.

use Event4u\DataHelpers\DataMapper\Pipeline\Filters\TrimStrings;
use Event4u\DataHelpers\DataMapper\Pipeline\Filters\UppercaseStrings;
$result = DataMapper::from($source)
->pipeline([
new TrimStrings(),
new UppercaseStrings(),
])
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
])
->map()
->getTarget();
// All string values are trimmed and uppercased
$mapper = DataMapper::from($source)
->template($template);
// Add single filter
$mapper->addPipelineFilter(new TrimStrings());
// Add multiple filters
$mapper->pipeline([
new TrimStrings(),
new UppercaseStrings(),
]);

Data Helpers includes 40+ built-in filters:

  • String Filters: TrimStrings, UppercaseStrings, LowercaseStrings, etc.
  • Number Filters: RoundNumbers, FormatCurrency, etc.
  • Date Filters: FormatDate, ParseDate, etc.
  • Array Filters: FlattenArray, UniqueValues, etc.
  • Validation Filters: ValidateEmail, ValidateUrl, etc.

See Filters Documentation for complete list.

Apply filters to specific properties only.

$result = DataMapper::from($source)
->setFilter('name', new TrimStrings(), new UppercaseStrings())
->setFilter('email', new TrimStrings(), new LowercaseStrings())
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
'bio' => '{{ user.bio }}',
])
->map()
->getTarget();
// Only 'name' and 'email' are filtered, 'bio' is not
$result = DataMapper::from($source)
->property('name')
->setFilter(new TrimStrings(), new UppercaseStrings())
->end()
->property('email')
->setFilter(new TrimStrings(), new LowercaseStrings())
->end()
->template($template)
->map()
->getTarget();
// Works with dot-notation
->setFilter('user.profile.bio', new TrimStrings())
// Works with wildcards
->setFilter('items.*.name', new TrimStrings())

The Property API provides focused access to individual properties.

$mapper = DataMapper::from($source)
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
]);
// Get mapping target for property
$target = $mapper->property('name')->getTarget();
// Returns: '{{ user.name }}'
$mapper->setFilter('name', new TrimStrings());
$filters = $mapper->property('name')->getFilter();
// Returns: [TrimStrings]
// Execute mapping and get value for specific property
$value = $mapper->property('name')->getMappedValue();
// Returns: 'John Doe' (after applying filters)
$mapper->property('name')
->setFilter(new TrimStrings())
->resetFilter() // Remove all filters
->setFilter(new UppercaseStrings()) // Set new filter
->end();

Automatically select target class based on a discriminator field (Liskov Substitution Principle).

abstract class Animal
{
public string $name;
public int $age;
}
class Dog extends Animal
{
public string $breed;
}
class Cat extends Animal
{
public int $lives;
}
$source = [
'type' => 'dog',
'name' => 'Rex',
'age' => 5,
'breed' => 'Golden Retriever',
];
$result = DataMapper::from($source)
->target(Animal::class)
->discriminator('type', [
'dog' => Dog::class,
'cat' => Cat::class,
])
->template([
'name' => '{{ name }}',
'age' => '{{ age }}',
'breed' => '{{ breed }}',
])
->map()
->getTarget();
// Returns Dog instance (because type='dog')
// Discriminator field can be nested
->discriminator('meta.classification.type', [
'premium' => PremiumUser::class,
'basic' => BasicUser::class,
])
// If discriminator value not found, falls back to original target
$result = DataMapper::from(['type' => 'unknown'])
->target(Animal::class)
->discriminator('type', [
'dog' => Dog::class,
'cat' => Cat::class,
])
->template($template)
->map()
->getTarget();
// Returns Animal instance (fallback)

Create independent copies of mapper configurations.

$baseMapper = DataMapper::from($source)
->target(User::class)
->template([
'name' => '{{ name }}',
]);
// Create independent copy
$extendedMapper = $baseMapper->copy()
->extendTemplate([
'email' => '{{ email }}',
])
->addPipelineFilter(new TrimStrings());
// $baseMapper is unchanged
// $extendedMapper has extended config
$mapper = DataMapper::from($source)
->template([
'name' => '{{ user.name }}',
]);
// Extend with additional fields
$mapper->extendTemplate([
'email' => '{{ user.email }}',
'phone' => '{{ user.phone }}',
]);
// Template now has all three fields

Manage template operators dynamically.

$mapper = DataMapper::from($source)
->template([
'items' => [
'WHERE' => ['{{ products.*.status }}' => 'active'],
'ORDER BY' => ['{{ products.*.price }}' => 'DESC'],
'*' => ['id' => '{{ products.*.id }}'],
],
]);
// Modify with query
$mapper->query('products.*')
->where('price', '>', 75)
->orderBy('price', 'ASC')
->end();
// Reset WHERE to original template value
$mapper->reset()->where();
// Reset entire template
$mapper->reset()->all();
// Delete specific operator
$mapper->delete()->where();
// Delete all operators
$mapper->delete()->all();
// Chain multiple operations
$mapper->reset()->where()->orderBy();
$mapper->delete()->limit()->offset();

DataMapper is optimized for performance:

  • 3.7x faster than Symfony Serializer for DTO mapping
  • Zero reflection overhead for template-based mapping
  • Efficient caching for path resolution and reflection
  • Minimal overhead (7.1%) for Fluent API wrapper

See Performance Benchmarks for detailed comparison.

The following working examples demonstrate DataMapper in action:

All examples are fully tested and can be run directly:

Terminal window
php examples/main-classes/data-mapper/simple-mapping.php
php examples/main-classes/data-mapper/template-based-queries.php
php examples/main-classes/data-mapper/with-hooks.php

The functionality is thoroughly tested. Key test files:

Run the tests:

Terminal window
# Run all DataMapper tests
task test:unit -- --filter=DataMapper
# Run specific test file
vendor/bin/pest tests/Unit/DataMapper/DataMapperTest.php