
How to Compare Nested Arrays in PHP: Beyond array_diff()
Learn how to compare nested PHP arrays, report added, removed, and modified values at exact paths, and know when array_diff() is still enough.
On this page
- What array_diff() Does Well
- Where It Breaks Down
- It Only Looks One Way
- It Compares Values as Strings
- It Does Not Look Inside Nested Arrays
- Three Questions, Not One
- Why Paths Matter
- A Recursive Comparison
- Edge Cases to Decide Up Front
- Lists Are Compared by Position
- Records Often Have an Identity
- Missing Is Not the Same as Null
- Strict or Loose Equality
- Keys That Contain Dots
- Order of Keys in Maps
- Objects, Dates, and Secrets
- When a Dedicated Diff Abstraction Pays Off
- Using pharaonic/php-diff
- A Practical Example: Order Lines
- When to Use Which Approach
- Summary
Comparing two arrays sounds like a solved problem: PHP ships array_diff() and a whole family of related functions. They work well for flat lists of values. Once your data is nested, such as a user profile with an address, an order with line items, or a decoded JSON payload, they stop answering the question you are actually asking: what changed, and where?
This guide starts with what the native functions do well, shows exactly where they break down, builds a small recursive comparison you can use today, and then covers the edge cases that decide whether a homemade solution is enough.
Quick Answer / TL;DR: For flat PHP arrays,
array_diff()is usually enough. For nested structures where you need added, removed, and modified values with their exact paths, use a recursive comparison or a dedicated diff abstraction.
#What array_diff() Does Well
array_diff() returns the values of the first array that do not appear in any of the others:
1$installed = ['php', 'laravel', 'pest'];2$required = ['php', 'pest'];3 4array_diff($installed, $required);5// [1 => 'laravel']For a flat list of strings, this is exactly right. It is fast, built in, and easy to read. Its siblings cover the common variations:
| Function | Compares |
|---|---|
array_diff() | Values only |
array_diff_key() | Keys only |
array_diff_assoc() | Keys and values together |
array_udiff() / array_diff_ukey() | Values or keys, using your own callback |
If you only need to know which tags were removed from a list, stop here. You do not need anything else.
#Where It Breaks Down
Problems appear as soon as the data has structure.
#It Only Looks One Way
array_diff() answers "what is in the first array but not the second?" Nothing more. Values that were added are invisible:
1array_diff(['php', 'pest'], ['php', 'pest', 'pint']);2// [] — 'pint' was added, but the result is emptyTo see both sides you have to call it twice, with the arguments swapped, and merge the results yourself.
#It Compares Values as Strings
Two values are considered equal when their string representations are equal ((string) $a === (string) $b). That hides real type changes:
1array_diff(['id' => 1, 'active' => true], ['id' => '1', 'active' => '1']);2// [] — the integer 1 and the string '1' look identicalnull and '' are also treated as equal. For audit logs or API payloads, a change from 1 to "1" can be exactly the bug you are looking for.
#It Does Not Look Inside Nested Arrays
This is the big one. Here a user moved from Cairo to Giza:
1$before = ['name' => 'Raggi', 'address' => ['city' => 'Cairo', 'zip' => '11511']];2$after = ['name' => 'Raggi', 'address' => ['city' => 'Giza', 'zip' => '11511']];3 4array_diff($before, $after);5// Warning: Array to string conversion (twice)6// []Both nested arrays are converted to the string "Array", so they compare as equal. You get two warnings and an empty result, and the change is lost. array_diff_assoc() behaves the same way. None of the native functions recurse.
#Three Questions, Not One
When you compare two versions of structured data, you usually want three separate answers:
- Added: a key exists in the new data but not in the old.
- Removed: a key existed in the old data but not in the new.
- Modified: a key exists in both, but its value is different.
Mixing them together, as a one-sided "difference" does, makes it impossible to say whether a value disappeared or was replaced.
#Why Paths Matter
In nested data, where a change happened is as important as what changed. "The city changed" is useful; "something inside address changed" is not, especially when address has ten keys.
A path describes the location of a value as a list of keys. In dot notation, address.city means $data['address']['city'], and roles.1 means the second item of the roles list. Paths make a diff readable for humans and usable by code: you can check if the path is billing.iban, alert finance without walking the structure again.
#A Recursive Comparison
Here is a small function that walks both arrays, recurses into nested arrays, compares strictly, and records every change with its path:
1function diffArrays(array $before, array $after, string $path = ''): array 2{ 3 $changes = []; 4 5 foreach ($before as $key => $oldValue) { 6 $keyPath = $path === '' ? (string) $key : $path.'.'.$key; 7 8 if (! array_key_exists($key, $after)) { 9 $changes[] = ['type' => 'removed', 'path' => $keyPath, 'old' => $oldValue];10 11 continue;12 }13 14 $newValue = $after[$key];15 16 if (is_array($oldValue) && is_array($newValue)) {17 array_push($changes, ...diffArrays($oldValue, $newValue, $keyPath));18 } elseif ($oldValue !== $newValue) {19 $changes[] = ['type' => 'modified', 'path' => $keyPath, 'old' => $oldValue, 'new' => $newValue];20 }21 }22 23 foreach ($after as $key => $newValue) {24 if (! array_key_exists($key, $before)) {25 $keyPath = $path === '' ? (string) $key : $path.'.'.$key;26 $changes[] = ['type' => 'added', 'path' => $keyPath, 'new' => $newValue];27 }28 }29 30 return $changes;31}Two details matter here:
- It uses
array_key_exists(), notisset().isset()returnsfalsefor a key whose value isnull, so a key set tonullwould be reported as removed. - It uses
!==, so1and'1'are different. See comparison operators for how loose comparison would treat them.
Using it on a slightly bigger example:
1$before = [ 2 'name' => 'Raggi', 3 'address' => ['city' => 'Cairo', 'zip' => '11511'], 4 'phone' => '0100', 5 'roles' => ['admin'], 6]; 7 8$after = [ 9 'name' => 'Raggi',10 'address' => ['city' => 'Giza', 'zip' => '11511'],12 'roles' => ['admin', 'editor'],13];14 15foreach (diffArrays($before, $after) as $change) {16 echo $change['type'], ' ', $change['path'], PHP_EOL;17}1modified address.city2removed phone3added roles.14added emailEvery change is reported once, at its exact location, with its type. For many applications, such as comparing configuration arrays or showing what a form submission changed, this function is all you need.
#Edge Cases to Decide Up Front
The recursive function is short because it quietly makes decisions. Before relying on it, check that they match your data.
#Lists Are Compared by Position
The function treats 0, 1, 2… keys like any other key. Inserting one item at the start of a list shifts every later item, so everything looks modified:
1diffArrays(['tags' => ['php', 'laravel']], ['tags' => ['go', 'php', 'laravel']]);The function reports three changes:
1modified tags.0 ("php" -> "go")2modified tags.1 ("laravel" -> "php")3added tags.2 ("laravel")The real change is a single insertion. Fixing this needs sequence alignment, the same idea git diff uses to align lines, which is considerably more code.
#Records Often Have an Identity
A list of order lines or users is rarely about positions. If two records swap places, nothing meaningful changed; if a record with sku B-200 changed its quantity, you want to see that one field, wherever the record now sits. Comparing by position cannot express this. You need to match records by a key such as id or sku.
#Missing Is Not the Same as Null
['note' => null] and [] are different: in one the key exists, in the other it does not. Using array_key_exists() keeps them apart, and the reported change should say which case you are in. A null "old value" alone cannot.
#Strict or Loose Equality
The function uses strict comparison. That is usually right for change detection, but floats are awkward: 0.1 + 0.2 !== 0.3. If your data contains computed floats, you may need a tolerance.
#Keys That Contain Dots
A key named a.b and the nested path a → b both produce the path string a.b, so two different changes become indistinguishable. A robust path format must escape the separator or store paths as lists of segments.
#Order of Keys in Maps
['a' => 1, 'b' => 2] === ['b' => 2, 'a' => 1] is false, because === on arrays also compares order. The recursive function compares maps key by key, so key order is correctly ignored, but be careful anywhere you shortcut with $old === $new on whole arrays.
#Objects, Dates, and Secrets
Real payloads often contain DateTimeImmutable values, JsonSerializable objects, or fields like password and token that must never end up in a log. Each needs a rule: compare dates as instants, serialize objects, and redact sensitive values before storing the diff.
#When a Dedicated Diff Abstraction Pays Off
Each edge case above is solvable in a few lines. Together they are not. Consider a dedicated library when you need several of these at once:
- lists that should be aligned, compared unordered, or matched by identity;
- results you query by path, such as "did
billing.ibanchange?"; - consistent, serializable output for audit logs or webhooks;
- rules such as "ignore every
updated_at" or "redact everytoken"; - the same behavior across many places in a codebase.
At that point you are maintaining a diff engine, and a tested one is cheaper than a growing helper function.
#Using pharaonic/php-diff
PHP Diff is a dependency-free library from Pharaonic for exactly this problem. It supports PHP 8.0 and later; Composer installs the release that matches your PHP version:
1composer require pharaonic/php-diffThe earlier example, with the same data:
1use Pharaonic\Diff\Diff;2 3$result = Diff::between($before, $after);4 5$result->wasModified('address.city'); // true6$result->wasAdded('email'); // true7$result->wasRemoved('phone'); // true8$result->hasChanged('name'); // falseValues are compared strictly, a missing key is reported differently from a null one, and keys containing dots are escaped in paths (a\.b). The result also reports every parent array above a change as an aggregate modification. Skip those when you only want the values that really changed:
1foreach ($result as $change) {2 if ($change->isAggregate()) {3 continue;4 }5 6 echo $change->type(), ' ', $change->path()->toDot(), PHP_EOL;7}1modified address.city2added email3added roles.14removed phone#A Practical Example: Order Lines
Order lines are the case the recursive function handled badly. Here a line was reordered, one quantity changed, and a line was added:
1use Pharaonic\Diff\Diff; 2use Pharaonic\Diff\DiffOptions; 3 4$before = ['lines' => [ 5 ['sku' => 'A-100', 'qty' => 1], 6 ['sku' => 'B-200', 'qty' => 2], 7]]; 8 9$after = ['lines' => [10 ['sku' => 'B-200', 'qty' => 3],11 ['sku' => 'A-100', 'qty' => 1],12 ['sku' => 'C-300', 'qty' => 1],13]];14 15$options = DiffOptions::create()16 ->identify('lines', 'sku')17 ->ignore('lines.*.updated_at')18 ->redact('customer.card_number');19 20$result = Diff::between($before, $after, $options);21 22foreach ($result as $change) {23 if ($change->isAggregate()) {24 continue;25 }26 27 printf(28 "%s %s %s -> %s\n",29 $change->type(),30 $change->path()->toDot(),31 json_encode($change->oldValue()),32 json_encode($change->newValue()),33 );34}1modified lines.0.qty 2 -> 32added lines.2 null -> {"sku":"C-300","qty":1}identify('lines', 'sku') matches records by their sku, so the reordering reports nothing, and the quantity change is reported for B-200 even though it moved from index 1 to index 0. $change->oldPath() still returns lines.1.qty when you need the original position. ignore() and redact() take paths with * wildcards, so noisy or sensitive fields never reach your logs. $result->toJson() turns the whole result into a stable structure you can store.
The full documentation covers the other list strategies, move detection, objects, and line-by-line text diffs.
#When to Use Which Approach
| Situation | Approach |
|---|---|
| Flat list of values, one direction | array_diff() |
| Flat map, keys and values | array_diff_assoc(), called in both directions |
| Small nested arrays, maps only, positions don't move | The recursive function above |
| Lists with insertions, records with IDs, ignored or redacted fields, audit logs | A dedicated library such as pharaonic/php-diff |
Native PHP is still the right choice when the data is flat, when you only need to know whether two arrays differ ($before === $after, keeping in mind that it also compares key order), or when adding a dependency is not justified for a single comparison.
#Summary
array_diff() compares flat values, one way, as strings. Nested data needs a recursive walk that compares strictly, uses array_key_exists(), and reports added, removed, and modified values with their paths. That walk fits in thirty lines until lists, identities, and sensitive fields enter the picture. When they do, reach for a dedicated diff library instead of growing your own.