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.

PHPPublished
On this page

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:

FunctionCompares
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 empty

To 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 identical

null 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(), not isset(). isset() returns false for a key whose value is null, so a key set to null would be reported as removed.
  • It uses !==, so 1 and '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'],
11 'email' => '[email protected]',
12 'roles' => ['admin', 'editor'],
13];
14 
15foreach (diffArrays($before, $after) as $change) {
16 echo $change['type'], ' ', $change['path'], PHP_EOL;
17}
1modified address.city
2removed phone
3added roles.1
4added email

Every 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.iban change?";
  • consistent, serializable output for audit logs or webhooks;
  • rules such as "ignore every updated_at" or "redact every token";
  • 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-diff

The earlier example, with the same data:

1use Pharaonic\Diff\Diff;
2 
3$result = Diff::between($before, $after);
4 
5$result->wasModified('address.city'); // true
6$result->wasAdded('email'); // true
7$result->wasRemoved('phone'); // true
8$result->hasChanged('name'); // false

Values 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.city
2added email
3added roles.1
4removed 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 -> 3
2added 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

SituationApproach
Flat list of values, one directionarray_diff()
Flat map, keys and valuesarray_diff_assoc(), called in both directions
Small nested arrays, maps only, positions don't moveThe recursive function above
Lists with insertions, records with IDs, ignored or redacted fields, audit logsA 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.