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
  • Conditional expressions: '{{ condition ? trueValue : falseValue }}' - Transform values based on conditions
  • Null coalescing: '{{ user.email ?? "default@example.com" }}' - Default value for null
  • Elvis operator: '{{ user.name ?: "Anonymous" }}' - Default value for falsy values

Conditional expressions allow you to transform values based on conditions. DataMapper supports three types of conditional operators:

Full conditional expression with explicit condition:

'{{ condition ? trueValue : falseValue }}'

Supported Operators:

  • Equality: ==, !=
  • Comparison: >, <, >=, <=
  • Membership: IN, NOT IN (with array literals)
  • Logical: &&, ||

Returns the left value if it’s not null, otherwise returns the right value:

'{{ user.email ?? "default@example.com" }}'

Behavior:

  • ✅ Triggers only on null
  • ❌ Does NOT trigger on false, 0, "", or []

Returns the left value if it’s truthy, otherwise returns the right value:

'{{ user.name ?: "Anonymous" }}'

Behavior:

  • ✅ Triggers on any falsy value: null, false, 0, "", []
  • ❌ Does NOT trigger on truthy values like 1, "text", [1, 2]
OperatorTriggers onExampleWhen right value?
??null only{{ email ?? "default" }}Only when null
?:Any falsy value{{ name ?: "Anonymous" }}When null, false, 0, "", []
? :Custom condition{{ age > 18 ? "adult" : "minor" }}When condition is false

Ternary Operator - Transform status to 0 or 1:

$source = [
'users' => [
['name' => 'Alice', 'status' => 'active'],
['name' => 'Bob', 'status' => 'inactive'],
],
];
$result = DataMapper::source($source)
->template([
'users.*' => [
'name' => '{{ users.*.name }}',
'active' => '{{ users.*.status == "active" ? 1 : 0 }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'users' => [
// ['name' => 'Alice', 'active' => 1],
// ['name' => 'Bob', 'active' => 0],
// ]
// ]

Age category (adult/minor):

$source = [
'people' => [
['name' => 'Alice', 'age' => 25],
['name' => 'Bob', 'age' => 17],
],
];
$result = DataMapper::source($source)
->template([
'people.*' => [
'name' => '{{ people.*.name }}',
'category' => '{{ people.*.age >= 18 ? "adult" : "minor" }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'people' => [
// ['name' => 'Alice', 'category' => 'adult'],
// ['name' => 'Bob', 'category' => 'minor'],
// ]
// ]

Price category with boolean flags:

$source = [
'products' => [
['name' => 'Laptop', 'price' => 1200],
['name' => 'Mouse', 'price' => 25],
],
];
$result = DataMapper::source($source)
->template([
'products.*' => [
'name' => '{{ products.*.name }}',
'price' => '{{ products.*.price }}',
'expensive' => '{{ products.*.price > 100 ? true : false }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'products' => [
// ['name' => 'Laptop', 'price' => 1200, 'expensive' => true],
// ['name' => 'Mouse', 'price' => 25, 'expensive' => false],
// ]
// ]

Multiple conditions in same template:

$source = [
'orders' => [
['id' => 1, 'total' => 150, 'status' => 'completed'],
['id' => 2, 'total' => 50, 'status' => 'pending'],
],
];
$result = DataMapper::source($source)
->template([
'orders.*' => [
'id' => '{{ orders.*.id }}',
'total' => '{{ orders.*.total }}',
'is_completed' => '{{ orders.*.status == "completed" ? true : false }}',
'is_large_order' => '{{ orders.*.total >= 100 ? true : false }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'orders' => [
// ['id' => 1, 'total' => 150, 'is_completed' => true, 'is_large_order' => true],
// ['id' => 2, 'total' => 50, 'is_completed' => false, 'is_large_order' => false],
// ]
// ]

Conditional expressions support all common value types:

  • Integers: {{ quantity < 10 ? 1 : 0 }}
  • Floats: {{ price >= 99.99 ? 1 : 0 }}
  • Strings: {{ status == "active" ? "Yes" : "No" }}
  • Booleans: {{ age >= 18 ? true : false }}
  • Null: {{ email != null ? 1 : 0 }}
  • Array literals: {{ status IN ["active","pending"] ? 1 : 0 }}

You can use both single and double quotes for string literals:

// Double quotes
'status_text' => '{{ user.status == "active" ? "Yes" : "No" }}'
// Single quotes
'status_text' => "{{ user.status == 'active' ? 'Yes' : 'No' }}"

Conditional expressions work with nested property access:

$source = [
'user' => [
'profile' => [
'age' => 25,
],
],
];
$result = DataMapper::source($source)
->template([
'adult' => '{{ user.profile.age >= 18 ? 1 : 0 }}',
])
->map()
->getTarget();
// Result: ['adult' => 1]

Null Coalescing - Default email addresses:

$source = [
'users' => [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => null],
['name' => 'Charlie'], // email missing
],
];
$result = DataMapper::source($source)
->template([
'users.*' => [
'name' => '{{ users.*.name }}',
'email' => '{{ users.*.email ?? "no-email@example.com" }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'users' => [
// ['name' => 'Alice', 'email' => 'alice@example.com'],
// ['name' => 'Bob', 'email' => 'no-email@example.com'],
// ['name' => 'Charlie', 'email' => 'no-email@example.com'],
// ]
// ]

Elvis Operator - Anonymous names:

$source = [
'users' => [
['name' => 'Alice'],
['name' => ''], // empty string
['name' => null], // null
],
];
$result = DataMapper::source($source)
->template([
'users.*' => [
'name' => '{{ users.*.name ?: "Anonymous" }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'users' => [
// ['name' => 'Alice'],
// ['name' => 'Anonymous'],
// ['name' => 'Anonymous'],
// ]
// ]

Difference between ?? and ?::

$source = [
'user' => [
'email' => '', // empty string
'quantity' => 0, // zero
'active' => false, // false
],
];
$result = DataMapper::source($source)
->template([
// ?? only triggers on null (NOT on empty string, 0, or false)
'email_coalescing' => '{{ user.email ?? "default@example.com" }}',
'quantity_coalescing' => '{{ user.quantity ?? 10 }}',
'active_coalescing' => '{{ user.active ?? true }}',
// ?: triggers on ANY falsy value (empty string, 0, false, null)
'email_elvis' => '{{ user.email ?: "default@example.com" }}',
'quantity_elvis' => '{{ user.quantity ?: 10 }}',
'active_elvis' => '{{ user.active ?: true }}',
])
->skipNull(false)
->map()
->getTarget();
// Result:
// [
// 'email_coalescing' => '', // NOT replaced (not null)
// 'quantity_coalescing' => 0, // NOT replaced (not null)
// 'active_coalescing' => false, // NOT replaced (not null)
// 'email_elvis' => 'default@example.com', // Replaced (empty string is falsy)
// 'quantity_elvis' => 10, // Replaced (0 is falsy)
// 'active_elvis' => true, // Replaced (false is falsy)
// ]

Conditional expressions integrate seamlessly with wildcard operators:

$source = [
'items' => [
['name' => 'Item A', 'quantity' => 10],
['name' => 'Item B', 'quantity' => 5],
['name' => 'Item C', 'quantity' => 15],
],
];
$result = DataMapper::source($source)
->template([
'items.*' => [
'name' => '{{ items.*.name }}',
'quantity' => '{{ items.*.quantity }}',
'low_stock' => '{{ items.*.quantity < 10 ? 1 : 0 }}',
'high_stock' => '{{ items.*.quantity >= 15 ? 1 : 0 }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'items' => [
// ['name' => 'Item A', 'quantity' => 10, 'low_stock' => 0, 'high_stock' => 0],
// ['name' => 'Item B', 'quantity' => 5, 'low_stock' => 1, 'high_stock' => 0],
// ['name' => 'Item C', 'quantity' => 15, 'low_stock' => 0, 'high_stock' => 1],
// ]
// ]

Check if a value is contained in an array of values:

$source = [
'equipment' => [
['name' => 'Mixer', 'status' => 'Ok'],
['name' => 'Oven', 'status' => 'Defekt'],
['name' => 'Grill', 'status' => null],
['name' => 'Blender', 'status' => 'Verkauft'],
],
];
$result = DataMapper::source($source)
->template([
'equipment.*' => [
'name' => '{{ equipment.*.name }}',
'item_inactive' => '{{ equipment.*.status IN ["Defekt","Verkauft","Verschrottet"] ? 1 : 0 }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'equipment' => [
// ['name' => 'Mixer', 'item_inactive' => 0],
// ['name' => 'Oven', 'item_inactive' => 1],
// ['name' => 'Grill', 'item_inactive' => 0], // null is not in the array
// ['name' => 'Blender', 'item_inactive' => 1],
// ]
// ]

Array literals support strings, numbers, booleans, and null:

// String values
'{{ status IN ["active","pending"] ? 1 : 0 }}'
// Numeric values
'{{ category_id IN [1,3,5,7] ? 1 : 0 }}'
// Include null in the array
'{{ status IN [null,"Ok"] ? 1 : 0 }}'
// NOT IN - inverse check
'{{ status NOT IN ["Defekt","Verkauft"] ? 1 : 0 }}'

Use parentheses to apply filters before comparing:

$source = [
'equipment' => [
['name' => 'Mixer', 'status' => 'Active'],
['name' => 'Oven', 'status' => 'INACTIVE'],
['name' => 'Grill', 'status' => null],
],
];
$result = DataMapper::source($source)
->template([
'equipment.*' => [
'name' => '{{ equipment.*.name }}',
'item_inactive' => '{{ (equipment.*.status | lower) == "active" ? 0 : 1 }}',
],
])
->map()
->getTarget();
// Result:
// [
// 'equipment' => [
// ['name' => 'Mixer', 'item_inactive' => 0], // "Active" | lower → "active" == "active"
// ['name' => 'Oven', 'item_inactive' => 1], // "INACTIVE" | lower → "inactive" != "active"
// ['name' => 'Grill', 'item_inactive' => 1], // null | lower → null != "active"
// ]
// ]

Combine filters with IN/NOT IN:

$template = [
'equipment.*' => [
'name' => '{{ equipment.*.name }}',
'item_inactive' => '{{ (equipment.*.status | lower) IN ["verkauft","defekt","verschrottet"] ? 1 : 0 }}',
],
];

💡 See the complete examples:

  • Run php examples/datamapper-conditional-expressions.php for ternary operator examples
  • Run php examples/datamapper-null-coalescing-elvis.php for ?? and ?: examples

You can use parentheses (...) to nest conditional operators and create complex fallback chains:

$source = [
'user' => [
'name' => null,
'surname' => null,
],
];
// Simple parentheses
$result = DataMapper::source($source)
->template([
'fullname' => '{{ user.name ?? (user.surname) }}',
])
->map()
->getTarget();
// Result: ['fullname' => null] (both are null)
// Nested null coalescing - multiple fallbacks
$result = DataMapper::source($source)
->template([
'fullname' => '{{ user.name ?? (user.surname ?? "UNKNOWN") }}',
])
->map()
->getTarget();
// Result: ['fullname' => 'UNKNOWN']

Parentheses with filters:

$source = [
'user' => [
'name' => null,
'surname' => 'doe',
],
];
// Apply filter inside parentheses
$result = DataMapper::source($source)
->template([
'fullname' => '{{ user.name ?? (user.surname | upper) }}',
])
->map()
->getTarget();
// Result: ['fullname' => 'DOE']

Complex nested expressions:

$source = [
'user' => [
'name' => null,
'surname' => null,
],
];
// Nested operators with filters at multiple levels
$result = DataMapper::source($source)
->template([
'fullname' => '{{ user.name ?? (user.surname ?? "UNKNOWN" | lower) | upper }}',
])
->map()
->getTarget();
// Result: ['fullname' => 'UNKNOWN']
// Execution order:
// 1. user.surname ?? "UNKNOWN" → "UNKNOWN"
// 2. "UNKNOWN" | lower → "unknown"
// 3. user.name ?? "unknown" → "unknown" (user.name is null)
// 4. "unknown" | upper → "UNKNOWN"

Operator precedence with parentheses:

  1. Parentheses (...) - Highest priority
  2. Null Coalescing ??
  3. Elvis ?:
  4. Ternary ? :
  5. Pipes/Filters | - Lowest priority
// Without parentheses - filters apply to the whole expression
'{{ user.name ?? "default" | upper }}'
// → (user.name ?? "default") | upper
// With parentheses - filters apply only inside
'{{ user.name ?? (user.surname | upper) }}'
// → user.name ?? (user.surname | upper)

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/datamapper-conditional-expressions.php
php examples/datamapper-null-coalescing-elvis.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