Search by

zahran / mapper

zahran

Compile a JSON mapping template once, map many payloads with it.

Package info

github.com/mozahran/mapper

pkg:composer/zahran/mapper

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

1.0.0 2026-09-13 14:48 UTC

This package is auto-updated.

Last update: 2026-09-17 19:30:05 UTC


README

Compile a JSON mapping template once, map many payloads with it.

A mapping template is data, not code: it says where each output field comes from, how to narrow and order lists, and how to transform values on the way out. Templates are validated when they compile, so a broken template fails once, up front, with the path to the problem — never silently, one payload at a time.

use Zahran\Mapper\Mapper;

$mapper  = Mapper::default();
$mapping = $mapper->compile($template);   // validate once
$mapping->map($payload);                  // reuse for every payload

Installation

composer require zahran/mapper

Requires PHP 8.0 or newer and ext-json. Tested on 8.0 through 8.5.

A first mapping

use Zahran\Mapper\Mapper;

$payload = '{
    "order_id": 91,
    "customer": {"first": "Ada", "last": "Lovelace"},
    "lines": [
        {"sku": "A-1", "qty": "2", "price": "40.50"},
        {"sku": "B-7", "qty": "1", "price": "9.99"}
    ]
}';

$template = '{
    "attributes": [
        {"name": "Reference", "path": ["order_id"], "cast": {"type": "string"}},
        {
            "name": "Customer",
            "paths": [["customer", "first"], "$ ", ["customer", "last"]],
            "mutators": [{"name": "implode", "arguments": ["", "__value__"]}]
        },
        {
            "name": "Lines",
            "type": "array",
            "path": ["lines"],
            "attributes": [
                {"name": "Sku", "path": ["sku"]},
                {"name": "Quantity", "path": ["qty"], "cast": {"type": "integer"}},
                {"name": "Price", "path": ["price"], "cast": {"type": "float"}}
            ]
        }
    ]
}';

Mapper::default()->map($payload, $template);
[
    'Reference' => '91',
    'Customer'  => 'Ada Lovelace',
    'Lines'     => [
        ['Sku' => 'A-1', 'Quantity' => 2, 'Price' => 40.5],
        ['Sku' => 'B-7', 'Quantity' => 1, 'Price' => 9.99],
    ],
]

Payloads and templates may be given as JSON strings or as already-decoded PHP arrays.

The template

A template is an attribute. Most templates declare attributes and yield an object, but a template that declares a path or a type of its own yields whatever that describes.

Each attribute takes:

Key Meaning
name the key it writes in the output — required, non-empty
path where to read it from, as a list of segments
paths several sources gathered into one value, instead of path
default what to use when the payload holds nothing for it
required reject the payload when it holds nothing for it — see validating
conditions rewrite the value when it matches
mutators transform the value
cast convert the value
type "array" — see lists
attributes the shape of each item, on an "array" attribute
where, sort, distinct, offset, limit narrow a list

Anything else is rejected when the template compiles, naming the key and where it sat:

Mapper::default()->compile('{"attributes": [{"name": "A", "path": ["a"], "transform": "x"}]}');
// InvalidTemplateException: Invalid mapping template at "attributes.0":
//   "transform" is not a supported key here, expected one of: name, type, path, …

Paths

A path is a list of segments walked one at a time.

Keys and indexes

// {"order": {"customer": {"name": "Ada"}}}
{"name": "Customer", "path": ["order", "customer", "name"]}   // "Ada"

// {"rows": [{"id": 7}, {"id": 8}]}
{"name": "First", "path": ["rows", 0, "id"]}                  // 7
{"name": "Last",  "path": ["rows", -1, "id"]}                 // 8

A negative index counts from the end of a list, but only after a literal lookup misses — a payload that really does hold the key -1 still reads the way it always did.

* — every value one level down

// {"items": [{"sku": "A"}, {"sku": "B"}]}
{"name": "Skus", "path": ["items", "*", "sku"]}      // ["A", "B"]

// {"prices": {"skirt": 40, "shirt": 30}}
{"name": "Prices", "path": ["prices", "*"]}          // [40, 30]

Wildcards nest: ["orders", "*", "lines", "*", "sku"] reads every sku of every line of every order. An element that lacks the key contributes nothing rather than a null.

** — every descendant

// {"order": {"lines": [{"sku": "A"}], "gift": {"sku": "B"}}}
{"name": "Skus", "path": ["**", "sku"]}              // ["A", "B"]

** matches the scope it stands on and everything nested anywhere beneath it, so ["kept", "**", "id"] searches only under kept.

{"where": …} — every child that matches

// {"items": [{"sku": "A", "active": true}, {"sku": "B", "active": false}]}
{
    "name": "Skus",
    "path": ["items", {"where": {"path": ["active"], "condition_type": "eq", "value": true}}, "sku"]
}
// ["A"]

A clause reads path (optional — without it the element itself is tested), condition_type (any registered condition) and value. Give where a list of clauses and every one must hold:

{"where": [
    {"path": ["active"], "condition_type": "eq", "value": true},
    {"path": ["stock"],  "condition_type": "gt", "value": 0}
]}

A key the element lacks tests as null, so notnull says what you would expect about a key that is not there at all.

@ — the element itself

Inside a list of scalars there is no key to reach the element by:

// {"scores": [10, 55, 3]}
{
    "name": "Scores",
    "type": "array",
    "path": ["scores"],
    "where": {"condition_type": "gte", "value": 10},
    "attributes": [{"name": "Value", "path": ["@"]}]
}
// [{"Value": 10}, {"Value": 55}]

Collection-shaped paths

A path holding *, ** or a filter resolves to the list of every match — and to an empty list when nothing matched, rather than to null:

// {}
{"name": "Skus", "path": ["items", "*", "sku"]}                     // []
{"name": "Skus", "path": ["items", "*", "sku"], "default": "none"}  // "none"

Picking positions

A trailing list of indexes reads fixed positions out of whatever the path resolved to. $-prefixed entries are hard-coded values rather than positions:

// {"categories": [10, 55, 3, 20]}
{"name": "Categories", "path": ["categories", [0, 1]]}
// [10, 55]

// {"categories": [10, 55]}
{"name": "Categories", "path": ["categories", [0, "$fixed", "$7", "$true", "$null"]]}
// [10, "fixed", 7, true, null]

A position the payload has nothing at reads as null, so the result always has as many entries as the template asked for. To take the first three items of a list and have the result shorten when there are fewer, use limit instead.

When the payload holds nothing

// {}
{"name": "Name", "path": ["fullname"], "default": "John Doe"}   // "John Doe"

// {"value": null}
{"name": "Value", "path": ["value"], "default": "fallback"}     // null

An explicit null is a value; only a key that is not there at all falls back on the default. An attribute with no path at all is a hard-coded value:

{"name": "Source", "default": "static"}
{"name": "Flags",  "default": ["$true", "$null", "$7", "plain"]}   // [true, null, 7, "plain"]

Gathering several paths

paths reads several sources into one list, so a mutator can combine them:

// {"first": "Ada", "last": "Lovelace"}
{
    "name": "Name",
    "paths": [["first"], "$ ", ["last"]],
    "mutators": [{"name": "implode", "arguments": ["", "__value__"]}]
}
// "Ada Lovelace"

// {"net": 40, "tax": 8}
{"name": "Total", "paths": [["net"], ["tax"]], "mutators": [{"name": "array_sum"}]}
// 48

Without a mutator the gathered list is the value: {"name": "Both", "paths": [["a"], ["b"]]} yields [1, 2]. A $-prefixed entry is a hard-coded value sitting between the paths.

A path the payload has no value for contributes null and keeps the others in place (["Ada", null]). Only when every path is missing does the attribute fall back on its default.

Values gathered this way are piped whole rather than one element at a time, which is what lets implode and array_sum see the lot.

Lists

An attribute of "type": "array" maps each element of the array its path resolves to, using its own attributes as the shape of each item:

// {"items": [{"name": "Skirt"}, {"name": "Shirt"}]}
{
    "name": "Items",
    "type": "array",
    "path": ["items"],
    "attributes": [{"name": "ItemName", "path": ["name"]}]
}
// [{"ItemName": "Skirt"}, {"ItemName": "Shirt"}]

Lists nest to any depth, and each level's paths are relative to its own element:

// {"orders": [{"id": 1, "lines": [{"sku": "A"}, {"sku": "B"}]}]}
{
    "name": "Orders",
    "type": "array",
    "path": ["orders"],
    "attributes": [
        {"name": "Id", "path": ["id"]},
        {
            "name": "Lines",
            "type": "array",
            "path": ["lines"],
            "attributes": [{"name": "Sku", "path": ["sku"]}]
        }
    ]
}
// [{"Id": 1, "Lines": [{"Sku": "A"}, {"Sku": "B"}]}]

A list whose source is absent, or is not an array, yields [] — unless the attribute is required, or the mapper is strict. A list attribute may omit its path to map the scope it is already standing on. default, cast, conditions and mutators belong on the nested attributes, not on the list itself.

Narrowing a list

where, sort, distinct, offset and limit decide which elements are mapped and in what order. They run in that order, and they run before anything is mapped, so a limit caps the work as well as the output.

Given {"items": [{"sku": "A", "price": 30, "active": true}, {"sku": "B", "price": 10, "active": false}, {"sku": "C", "price": 20, "active": true}]}:

"where": {"path": ["active"], "condition_type": "eq", "value": true}   // A, C
"sort":  {"path": ["price"]}                                          // B, C, A
"sort":  {"path": ["price"], "direction": "desc"}                     // A, C, B
"limit": 2                                                            // A, B
"offset": 1, "limit": 1                                               // B
"offset": -1                                                          // C

All five together read as you would expect — keep the active ones, dearest first, take one:

{
    "name": "Items",
    "type": "array",
    "path": ["items"],
    "where": {"path": ["active"], "condition_type": "eq", "value": true},
    "sort":  {"path": ["price"], "direction": "desc"},
    "limit": 1,
    "attributes": [{"name": "Sku", "path": ["sku"]}]
}
// [{"Sku": "A"}]

where takes the same clauses as a filter segment: one clause, or a list of clauses that must all hold.

sort takes one sort key or a list of them, weighed in turn so the second only speaks where the first ties. direction is "asc" (the default) or "desc", and path is optional — without it the element itself is compared. Elements the payload has no value for gather at one end rather than scattering, and elements that tie on every key keep the order the payload gave them.

"sort": [{"path": ["group"]}, {"path": ["price"]}]

distinct keeps the first of each group. Give it the path — or paths — that identify an element, or true to compare whole elements. Identity is exact, so 1 and "1" stay apart.

// {"items": [{"sku": "A", "price": 1}, {"sku": "A", "price": 9}, {"sku": "B", "price": 2}]}
"distinct": ["sku"]        // the A at price 1, and B
"distinct": [["sku"], ["size"]]
"distinct": true

Because distinct runs after sort, sorting first decides which duplicate survives.

offset and limit take a window of what is left. offset may be negative to count from the end; limit must be zero or more.

Narrowing works on any list, including one reached by a wildcard:

// {"orders": [{"lines": [{"q": 1}, {"q": 9}]}, {"lines": [{"q": 5}]}]}
{
    "name": "Lines",
    "type": "array",
    "path": ["orders", "*", "lines", "*"],
    "where": {"path": ["q"], "condition_type": "gte", "value": 5},
    "attributes": [{"name": "Qty", "path": ["q"]}]
}
// [{"Qty": 9}, {"Qty": 5}]

The pipeline

conditions, mutators and cast transform a value on its way out. They always run in that order — conditions, then mutators, then the cast — however the template is written, and each step feeds the next.

// {"qty": "2"}
{
    "name": "Total",
    "path": ["qty"],
    "cast": {"type": "string"},
    "mutators": [{"name": "multiply", "arguments": [3]}],
    "conditions": [{"condition_type": "is_string", "then": 10}]
}
// "30"  — the condition saw the raw string, multiply saw 10, the cast saw 30

When the value is an array, each step applies to every element of it:

// {"tags": ["red", "green"]}
{"name": "Tags", "path": ["tags"], "mutators": [{"name": "strtoupper"}]}
// ["RED", "GREEN"]

Values gathered with paths are the exception: they are piped whole, so that array_sum and implode can combine them.

The pipeline is skipped entirely when the payload holds nothing for the path — the default stands as written, rather than being cast or mutated.

Conditions

A condition rewrites the value to then when it matches, and to otherwise when it does not. Without otherwise a value that does not match is left alone.

// {"completed": true}
{"name": "Status", "path": ["completed"],
 "conditions": [{"condition_type": "eq", "value": true, "then": "done", "otherwise": "pending"}]}
// "done"

Conditions run in declaration order and feed each other, so a later one sees what an earlier one wrote:

// {"score": 95}
"conditions": [
    {"condition_type": "gte", "value": 90, "then": "A"},
    {"condition_type": "eq",  "value": "A", "then": "excellent"}
]
// "excellent"
condition_type Matches when
eq the value loosely equals value ("1" equals 1)
neq the value is not identical to value
gt, gte, lt, lte the value compares that way against value
in the value is in value — a list, or a comma-separated string
not_in it is not
contains every needle in value appears in the value, case-insensitively
missing the negation of contains
null, notnull the value is, or is not, null
is_numeric, is_string, is_boolean, is_float (is_double) the value is of that type

Mutators

A mutator transforms the value. arguments is optional; when it is omitted the value is passed as the sole argument, and when it is given the literal "__value__" marks where the value goes.

// {"name": "Ada"}
"mutators": [{"name": "strtoupper"}]                                        // "ADA"

// {"title": "hello world"}
"mutators": [{"name": "str_replace", "arguments": [" ", "-", "__value__"]}]  // "hello-world"

// {"views": 10}
"mutators": [{"name": "multiply", "arguments": [5]}]                         // 50

Mutators run in declaration order:

// {"name": "  adalovelace  "}
"mutators": [
    {"name": "trim"},
    {"name": "strtoupper"},
    {"name": "substr", "arguments": ["__value__", 0, 3]}
]
// "ADA"

Registered mutators: add, subtract, multiply, divide, modulo, power. Each takes one argument and coerces both sides to numbers; anything PHP would refuse to do arithmetic on yields null rather than raising.

Beyond those, a mutator may name a PHP function — but only one on the allow list, because a template is data and data must never reach arbitrary code. Every listed function is side-effect free and takes no callable, so none can be used to invoke another:

abs array_flip array_keys array_product array_reverse array_slice array_sum array_unique
array_values base64_decode base64_encode bin2hex boolval ceil count date dechex explode
floatval floor gettype gmdate hexdec htmlspecialchars implode intdiv intval json_decode
json_encode lcfirst ltrim max mb_strlen mb_strtolower mb_strtoupper mb_substr md5 min
nl2br number_format pow preg_quote preg_replace preg_split rawurlencode round rtrim sha1
sprintf sqrt str_contains str_ends_with str_pad str_repeat str_replace str_split
str_starts_with str_word_count strip_tags strlen strrev strtolower strtotime strtoupper
strtr strval substr substr_count trim ucfirst ucwords urlencode vsprintf wordwrap

Anything else is refused when the template compiles:

Mapper::default()->compile('{"attributes": [{"name": "A", "path": ["a"], "mutators": [{"name": "shell_exec"}]}]}');
// InvalidTemplateException: … "shell_exec" is neither a registered mutator nor an allowed PHP function.

Add your own with withMutator() or withFunctions().

Casts

// {"price": "40.5"}
{"name": "Price", "path": ["price"], "cast": {"type": "float"}}     // 40.5

// {"when": "2024-03-09T11:30:00+00:00"}
{"name": "Day", "path": ["when"], "cast": {"type": "date", "format": "d/m/Y"}}
// "09/03/2024"
type Result
integer "40" → 40, "40.9" → 40, null → 0
float "40.5" → 40.5, 40 → 40.0
string 40 → "40", true → "1", false and null → ""
boolean PHP truthiness: "0", "", null, [] → false
date reformatted with the required format; an empty value stays null

format is required for date, and the template is rejected when it is absent. These conversions are deliberately forgiving; strict mapping makes them refuse values they would have to invent an answer for.

The shape of the result

The root is an attribute like any other, so a mapping is not forced to end in an object.

// {"items": [{"sku": "A"}, {"sku": "B"}]}
'{"type": "array", "path": ["items"], "attributes": [{"name": "Sku", "path": ["sku"]}]}'
// [["Sku" => "A"], ["Sku" => "B"]]

// [{"sku": "A"}, {"sku": "B"}]  — a root list with no path maps the payload itself
'{"type": "array", "attributes": [{"name": "Sku", "path": ["sku"]}]}'
// [["Sku" => "A"], ["Sku" => "B"]]

// {"order": {"id": 7}}
'{"path": ["order", "id"]}'                                  // 7

// {"order": {"total": "40.5"}}
'{"path": ["order", "total"], "cast": {"type": "float"}}'    // 40.5

A root list narrows with where, sort and the rest like any other, and a root value is piped like any other. map() therefore returns mixed.

Validating the payload

Mapping is forgiving by default: a missing path falls back on its default, and a list whose source is not an array yields []. Two things make it strict where you want it.

required

{"name": "Sku", "path": ["sku"], "required": true}
Mapper::default()->map('{}', $template);
// InvalidPayloadException: Cannot map the payload at "Sku":
//   is required, but the payload holds no value for it.

An explicit null satisfies required — only absence does not. On a list attribute, required also rejects a source that is not an array, but accepts an empty one. A failure names the attribute by its full dotted path in the output:

// Cannot map the payload at "Orders.Lines.Sku": is required, but the payload holds no value for it.

required cannot be combined with default, which already stands in for a missing value; the template is rejected when it compiles.

Strict mapping

$mapper = Mapper::default()->strict();

A strict mapper refuses to invent values rather than coercing them:

// {"count": "abc"}
{"name": "Count", "path": ["count"], "cast": {"type": "integer"}}

Mapper::default()->map($payload, $template);              // ['Count' => 0]
Mapper::default()->strict()->map($payload, $template);
// InvalidPayloadException: Cannot map the payload at "Count":
//   "abc" (string) cannot be cast to "integer" without losing its meaning.

The bar is faithfulness, not convertibility — (bool) "false" is true in PHP, and that is exactly the silent answer strictness is there to spare you. A strict integer takes an int, a bool, a whole float or a whole numeric string; a strict boolean takes a bool, 0/1 or "0"/"1", and refuses "false".

Null is carried through casts untouched rather than becoming 0, "" or false, and a path the payload has no value for still falls back on its default: absence is not an error unless the attribute says required. A list attribute whose source is present but is not a list fails instead of yielding []:

Mapper::default()->strict()->map('{"items": "nope"}', $template);
// InvalidPayloadException: Cannot map the payload at "Items":
//   expects a list, but the payload holds string.

strict() returns a new mapper and survives registering more conditions, casts, mutators or functions. Custom casts stay trusted unless they implement Cast\Validating.

Errors

Everything the library throws implements Exception\MappingException, so one catch covers the lot.

Exception Extends Thrown
InvalidTemplateException InvalidArgumentException compiling — the template is wrong
InvalidJsonException InvalidArgumentException decoding — the JSON is wrong
InvalidPayloadException RuntimeException mapping — the payload is wrong

Whatever a cast or a mutator throws is carried out as an InvalidPayloadException naming the attribute, with the original kept as previous:

// {"when": "the day before never"} with a date cast
// InvalidPayloadException: Cannot map the payload at "When":
//   DateTimeImmutable::__construct(): Failed to parse time string …

Extending the mapper

Every with*() method returns a new mapper and leaves the original alone.

use Zahran\Mapper\Condition\Predicate;

$mapper = Mapper::default()->withCondition('starts_with', new class implements Predicate {
    public function matches(mixed $value, mixed $compare): bool
    {
        return is_string($value) && is_string($compare) && str_starts_with($value, $compare);
    }
});

// {"sku": "AB-1"}
{"name": "Kind", "path": ["sku"],
 "conditions": [{"condition_type": "starts_with", "value": "AB-", "then": "internal", "otherwise": "external"}]}
// "internal"

Custom conditions work anywhere a condition does, including where clauses.

use Zahran\Mapper\Cast\Cast;
use Zahran\Mapper\Cast\Validating;

// Implementing Validating as well is optional: it is what a strict mapper asks before
// handing the cast a value. A cast that does not implement it is trusted with anything.
$mapper = Mapper::default()->withCast('cents', new class implements Cast, Validating {
    public function cast(mixed $value, ?string $format): mixed
    {
        return (int) round(((float) $value) * 100);
    }

    public function accepts(mixed $value): bool
    {
        return is_numeric($value);
    }
});

// {"price": "40.50"} with {"cast": {"type": "cents"}}  =>  4050
use Zahran\Mapper\Mutator\Mutator;

$mapper = Mapper::default()->withMutator('suffix', new class implements Mutator {
    public function apply(mixed $value, array $arguments): mixed
    {
        return $value . ($arguments[0] ?? '');
    }
});

// {"sku": "A"} with {"mutators": [{"name": "suffix", "arguments": ["-EU"]}]}  =>  "A-EU"

To widen the allow list instead of writing a mutator:

$mapper = Mapper::default()->withFunctions('addslashes', 'nl2br');

Reusing a compiled mapping

Compiling validates the template and builds the nodes; mapping walks them. When the same template serves many payloads, compile once:

$mapping = Mapper::default()->compile($template);

$mapping->map($first);
$mapping->map($second);

mapMany() maps lazily and preserves keys — nothing is read until the generator is consumed:

$mapping->mapMany(['a' => '{"name": "Ada"}', 'b' => ['name' => 'Linus']]);
// ['a' => ['Name' => 'Ada'], 'b' => ['Name' => 'Linus']]

Mapper::map($data, $template) compiles and maps in one call, which is convenient for a one-off and wasteful in a loop.

What this library does not do

  • It does not map back. The transform is lossy by construction — conditions collapse ranges into labels, casts discard precision, default erases the fact that a key was absent — so a round trip means writing a second template by hand.
  • It does not hydrate objects. Nodes emit plain arrays and casts produce scalars. Build your DTOs from the result.

Testing

composer install
composer test

Or against every supported PHP version, with Docker:

docker compose run --rm tests

License

MIT.