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::source($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::source($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::source($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. You can start with DataMapper::from($source) (alias: DataMapper::source($source)).

DataMapper::from($source) // Start with source data (alias: DataMapper::source())
->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
$source = ['user' => ['name' => 'John', 'email' => 'john@example.com', 'profile' => ['age' => 30]]];
$result = DataMapper::source($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

DataMapper can load data directly from JSON and XML files using sourceFile():

// Load from JSON file
$result = DataMapper::sourceFile('/path/to/data.json')
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
])
->map()
->getTarget();
// Load from XML file
$result = DataMapper::sourceFile('/path/to/data.xml')
->template([
'name' => '{{ company.name }}',
'email' => '{{ company.email }}',
])
->map()
->getTarget();

⚠️ Important: XML Root Element Preservation

Section titled “⚠️ Important: XML Root Element Preservation”

When loading XML files, the root element name is always preserved and must be included in your mapping paths:

// XML file content:
// <?xml version="1.0"?>
// <company>
// <name>TechCorp</name>
// <email>info@techcorp.com</email>
// </company>
// ✅ Correct: Include root element in path
$mapping = [
'company_name' => '{{ company.name }}',
'company_email' => '{{ company.email }}',
];
// ❌ Wrong: Missing root element (will return null)
$mapping = [
'company_name' => '{{ name }}',
'company_email' => '{{ email }}',
];

Different root elements require different paths:

// For <VitaCost>...</VitaCost>
'number' => '{{ VitaCost.ConstructionSite.nr_lv }}'
// For <Datafields>...</Datafields>
'salutation' => '{{ Datafields.contact_persons.contact_person.salutation }}'
// For <company>...</company>
'name' => '{{ company.name }}'

Example with nested XML arrays:

// XML: <company><departments><department>...</department></departments></company>
$mapping = [
'company_name' => '{{ company.name }}',
'departments' => [
'*' => [
'name' => '{{ company.departments.department.*.name }}',
'code' => '{{ company.departments.department.*.code }}',
],
],
];

In some cases, you may need to work with XML files that have multiple root elements (technically invalid XML, but sometimes necessary):

// XML file with multiple roots:
// <LVDATA><LV>...</LV></LVDATA>
// <POSDATA><POS>...</POS></POSDATA>
// Both root elements are preserved and accessible
$mapping = [
'lv_id' => '{{ LVDATA.LV.ID_LV }}',
'lv_number' => '{{ LVDATA.LV.NR_LV }}',
'positions' => [
'*' => [
'position_id' => '{{ POSDATA.POS.*.ID_POSITION }}',
'lv_id' => '{{ POSDATA.POS.*.ID_LV }}',
],
],
];
$result = DataMapper::sourceFile('/path/to/multi-root.xml')
->template($mapping)
->map()
->getTarget();

The FileLoader automatically detects and handles multiple root elements by wrapping them in a temporary container during parsing.

💡 See the complete example: Run php examples/data-mapper/xml-file-mapping.php for a comprehensive demonstration of XML file loading with different root elements.

class UserDto
{
public string $name;
public string $email;
}
$result = DataMapper::source($source)
->target(UserDto::class)
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
])
->map()
->getTarget(); // Returns UserDto instance

PHP 8.1+ introduced readonly properties that can only be initialized once. The DataMapper provides the modifyReadOnly() method to handle these properties intelligently.

Default Behavior (modifyReadOnly disabled)

Section titled “Default Behavior (modifyReadOnly disabled)”

By default, readonly properties that are already initialized will be skipped:

class UserDto
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {}
}
// Create instance with initialized readonly properties
$dto = new UserDto(999, 'Original');
$source = ['id' => 123, 'name' => 'John'];
// Map to existing object - readonly properties are skipped
$result = DataMapper::source($source)
->target($dto)
->template([
'id' => '{{ id }}',
'name' => '{{ name }}',
])
->map()
->getTarget();
// Result: id=999, name='Original' (unchanged)

When modifyReadOnly(true) is enabled, the mapper will create a new instance to allow setting readonly properties:

class UserDto
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {}
}
$dto = new UserDto(999, 'Original');
$source = ['id' => 123, 'name' => 'John'];
// Enable readonly modification
$result = DataMapper::source($source)
->target($dto)
->modifyReadOnly(true) // Creates new instance
->template([
'id' => '{{ id }}',
'name' => '{{ name }}',
])
->map()
->getTarget();
// Result: id=123, name='John' (new instance created)
// Original $dto remains unchanged: id=999, name='Original'

When the target is a class name (string) instead of an object instance, readonly properties can always be set:

class UserDto
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {}
}
$source = ['id' => 123, 'name' => 'John'];
// Pass class name as target
$result = DataMapper::source($source)
->target(UserDto::class) // Class name, not instance
->modifyReadOnly(true)
->template([
'id' => '{{ id }}',
'name' => '{{ name }}',
])
->map()
->getTarget();
// Result: id=123, name='John' (new instance created via constructor)

The mapper only creates a new instance when necessary:

  • Class name target: Uses constructor when possible (best performance)
  • Object target + no readonly properties to modify: Reuses existing object (preserves reference)
  • Object target + readonly properties to modify: Creates new instance via reflection
class UserDto
{
public int $id = 0; // Mutable property
public string $name = ''; // Mutable property
}
$dto = new UserDto();
$source = ['id' => 123, 'name' => 'John'];
// No readonly properties - reuses existing object
$result = DataMapper::source($source)
->target($dto)
->modifyReadOnly(false)
->template([
'id' => '{{ id }}',
'name' => '{{ name }}',
])
->map()
->getTarget();
// $result === $dto (same object reference)
$source = [
'user' => ['name' => 'John', 'email' => 'john@example.com', 'phone' => '555-1234'],
'orders' => [['id' => 1, 'total' => 100], ['id' => 2, 'total' => 200]],
];
$result = DataMapper::source($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::source($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::source($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::source($source)
->pipeline([
new TrimStrings(),
new UppercaseStrings(),
])
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
])
->map()
->getTarget();
// All string values are trimmed and uppercased
$mapper = DataMapper::source($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::source($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::source($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.

$source = ['user' => ['name' => 'John', 'email' => 'john@example.com']];
$mapper = DataMapper::source($source)
->template([
'name' => '{{ user.name }}',
'email' => '{{ user.email }}',
]);
// Get mapping target for property
$target = $mapper->property('name')->getTarget();
// $target = '{{ user.name }}'
use event4u\DataHelpers\DataMapper\Pipeline\Filters\TrimStrings;
$mapper->setFilter('name', new TrimStrings());
$filters = $mapper->property('name')->getFilter();
// $filters = [TrimStrings]
// Execute mapping and get value for specific property
$value = $mapper->property('name')->getMappedValue();
// $value = '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::source($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::source(['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::source($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
$source = ['user' => ['name' => 'John', 'email' => 'john@example.com', 'phone' => '555-1234']];
$mapper = DataMapper::source($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.

$source = ['products' => [['id' => 1, 'status' => 'active', 'price' => 100], ['id' => 2, 'status' => 'inactive', 'price' => 50]]];
$mapper = DataMapper::source($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