PHP Packagev8.2.0MIT License

Diff

Diff tells you what changed between two PHP values. It does two kinds of comparison:

  • Structured diffs compare arrays, scalars and objects, and report each value that was added, removed, modified or moved, at its exact path.
  • Text diffs compare two texts line by line, the way Git and GitHub show them: hunks, highlighted words, unified patches and HTML.
1use Pharaonic\Diff\Diff;
2 
3$result = Diff::between(
4 ['name' => 'Raggi', 'email' => '[email protected]'],
5 ['name' => 'Raggi', 'email' => '[email protected]'],
6);
7 
8$result->wasModified('email'); // true
9 
10echo Diff::text("alpha\nbeta\n", "alpha\nbeta changed\n")->toUnified();

Changes at Exact Paths

Each change has a type, a path such as user.email, and its old and new values, so you can query, store or display it.

Records Matched by ID

Lists of records can be compared by one or more keys, and records that changed order can be reported as moved.

Redaction Built In

Secrets are still compared, but the result stores [REDACTED] instead of them, even inside parent and removed values.

Strict and Predictable

1, "1" and 1.0 differ, a missing key is not null, map key order is ignored, and the same input always gives the same result.

GitHub-Style Text Diffs

Line diffs with hunks, word or character highlighting, and unified patches that git apply and patch accept.

HTML Ready

An escaped HTML table of the hunks, styled by an optional stylesheet with light and dark colors.

Quick Tip

Fields such as updated_at change on every save. Leave them out with DiffOptions::create()->ignore('updated_at').

#Installation

Install the package with Composer. There is nothing to register or configure.

#Requirements

  • PHP 8.2

There are no runtime dependencies and no required PHP extensions.

#Composer Installation

Terminal
composer require pharaonic/php-diff

Composer autoloads the Pharaonic\Diff namespace.

Installation Complete

You're all set! Try Pharaonic\Diff\Diff::between(['a' => 1], ['a' => 2])->wasModified('a'), which returns true.

#Quick Start

Everything starts from the Diff class. It has two methods, one for each kind of comparison.

#Compare Two Values

Diff::between() compares two values and returns a DiffResult:

1use Pharaonic\Diff\Diff;
2 
3$before = ['name' => 'Raggi', 'email' => '[email protected]', 'roles' => ['admin'], 'nickname' => 'R'];
4$after = ['name' => 'Raggi', 'email' => '[email protected]', 'roles' => ['admin', 'editor'], 'age' => 33];
5 
6$result = Diff::between($before, $after);
7 
8$result->hasChanges(); // true
9$result->wasModified('email'); // true
10$result->wasAdded('roles.1'); // true
11$result->wasRemoved('nickname'); // true
12$result->hasChanged('name'); // false

Each change is a Change with a type, a path, and the old and new values:

1foreach ($result as $change) {
2 echo $change->type(), ' ', $change->path()->toDot(), PHP_EOL;
3}
4 
5// modified email
6// modified roles
7// added roles.1
8// added age
9// removed nickname

roles is reported as modified because something inside it changed. Such a change is an aggregate: $change->isAggregate() returns true.

#Compare Two Texts

Diff::text() compares two texts line by line and returns a TextDiffResult:

1$result = Diff::text("alpha\nbeta\ngamma\n", "alpha\nbeta changed\ngamma\ndelta");
2 
3$result->stats(); // ['added' => 2, 'removed' => 1, 'modified' => 1, 'unchanged' => 2]
4 
5echo $result->toUnified('a/notes.txt', 'b/notes.txt');
Output
1--- a/notes.txt
2+++ b/notes.txt
3@@ -1,3 +1,4 @@
4 alpha
5-beta
6+beta changed
7 gamma
8+delta
9\ No newline at end of file

echo $result->toHtml() renders the same hunks as a GitHub-style HTML table instead.

#Add Options

Both methods take optional settings. DiffOptions and TextDiffOptions are immutable: each method returns new options.

1use Pharaonic\Diff\DiffOptions;
2use Pharaonic\Diff\TextDiffOptions;
3 
4$options = DiffOptions::create()
5 ->ignore('updated_at')
6 ->redact('password')
7 ->identify('addresses', 'id');
8 
9Diff::between($before, $after, $options);
10 
11Diff::text($old, $new, TextDiffOptions::create()->context(5)->ignoreTrailingWhitespace());

#How Values Are Compared

Diff::between() accepts scalars, null, arrays and objects at any depth. It follows the rules below, and the same input always gives the same result, in the same order.

#Scalars Are Compared Strictly

Two scalars are equal only when they have the same type and value. There is no loose comparison:

1Diff::between(['qty' => '1'], ['qty' => 1])->wasModified('qty'); // true
2Diff::between(['total' => 1.0], ['total' => 1])->hasChanges(); // true
3Diff::between(['flag' => 0], ['flag' => false])->hasChanges(); // true

Floats have two exceptions, so that a value is always equal to itself: NAN equals NAN, and -0.0 equals 0.0. To accept small rounding differences, set an absolute tolerance:

1$options = DiffOptions::create()->floatTolerance(0.001);
2 
3Diff::between(['total' => 10.0], ['total' => 10.0000001], $options)->hasChanges(); // false

#Maps and Lists

An array whose keys are 0, 1, 2… in order is a list. Any other array, including a sparse one such as [0 => 'a', 2 => 'b'], is a map.

  • Maps are compared key by key, and the order of their keys is ignored.
  • Lists are aligned like lines of text by default, so an insertion or a removal does not shift every later item. Other strategies can be chosen per path.

An empty array matches the kind of the array on the other side, so [] becoming ['k' => 1] reports one added key, not a type change.

#A Missing Key Is Not Null

A key that does not exist is different from a key whose value is null:

1Diff::between(['middle_name' => null], [])->wasRemoved('middle_name'); // true

Change::hasOldValue() and hasNewValue() tell the two cases apart, because oldValue() and newValue() return null for both.

#Type Changes

When a value changes type, for example from an array to a string, the diff reports one direct modified change at that path and does not look inside the old or new value:

1foreach (Diff::between(['a' => [1]], ['a' => '1']) as $change) {
2 echo $change->type(), ' ', $change->path()->toDot(); // modified a
3}

#Aggregate Changes

When something inside an array changes, every array above it is also reported as modified, with its full old and new values, and isAggregate() returns true. Parents always come before their descendants:

1$result = Diff::between(['user' => ['email' => 'a']], ['user' => ['email' => 'b']]);
2 
3// modified user (aggregate)
4// modified user.email

The root itself is never reported as an aggregate, so Diff::between([], ['a' => 1]) returns a single added a change. Comparing two different scalars reports one change at the root, whose path is empty.

To list only the values that really changed, skip the aggregates: array_filter($result->modified(), fn ($change) => ! $change->isAggregate()).

#Supported Values

Value How it is compared
null, bool, int, float, string Strictly, with the float rules above
Arrays As maps or lists, recursively
DateTimeInterface Equal when they are the same instant, to the microsecond, whatever their time zones
JsonSerializable Through jsonSerialize()
stdClass As a map of its public properties
Other objects Through a comparator or normalizer you register
Resources, closures, generators Not supported: an UnsupportedValueException is thrown

Values may nest up to 64 levels by default.

#Reading Results

Diff::between() returns a DiffResult. You can ask it questions, list its changes, or turn it into an array or JSON.

#Asking Questions

Every method that takes a path accepts dot notation or a Path object:

1$result = Diff::between(
2 ['user' => ['email' => 'a', 'nickname' => 'R'], 'username' => 'x'],
3 ['user' => ['email' => 'b', 'age' => 1], 'username' => 'x'],
4);
5 
6$result->hasChanges(); // true
7$result->hasChanged('user'); // true: something at or below user changed
8$result->hasChanged('username'); // false
9$result->hasChanged('use'); // false: paths match whole segments
10$result->wasModified('user.email'); // true
11$result->wasModified('user'); // true: aggregate modifications count
12$result->wasAdded('user.age'); // true
13$result->wasRemoved('user.nickname'); // true
14$result->wasMoved('user.email'); // false
Method True when
hasChanges() Anything changed
hasChanged($path) Something changed at this path or below it, at its old or its new path
wasAdded($path) A value was added at exactly this path
wasRemoved($path) A value was removed from exactly this path
wasModified($path) The value at exactly this path was modified, directly or through its descendants
wasMoved($path) A value moved from or to exactly this path

#Listing Changes

1$result->changes(); // every Change, parents before their descendants
2$result->added(); // only added changes
3$result->removed();
4$result->modified(); // direct and aggregate modifications
5$result->moved();
6$result->at('user'); // changes whose path is exactly user
7$result->under('user'); // changes at user or below it
8count($result); // 4
9$result->stats(); // ['total' => 4, 'added' => 1, 'removed' => 1, 'modified' => 2, 'moved' => 0]

A DiffResult is also iterable: foreach ($result as $change). The arrays it returns are copies, so changing them never changes the result.

#The Change Object

Method Returns
type() One of the ChangeType constants: added, removed, modified or moved
path() The canonical Path: the old path for a removed value, the new path otherwise
oldPath() Where the value was, or null when it did not exist before
newPath() Where the value is, or null when it no longer exists
hasOldValue() / hasNewValue() Whether the value existed before / exists after
oldValue() / newValue() The values, or null when they did not exist
isAggregate() Whether this is a container reported because something inside it changed
isRedacted() Whether the values were replaced with Change::REDACTED as a whole
toArray() The change as an array, shown below

When a change has both an old and a new path, they are the same path, unless the value is found at another index after the change.

#Arrays and JSON

toArray(), jsonSerialize() and toJson() give you a list of changes in this shape:

1Diff::between(['a' => 1], ['a' => 2])->toArray();
2 
3// [[
4// 'type' => 'modified',
5// 'path' => 'a',
6// 'old_path' => 'a',
7// 'new_path' => 'a',
8// 'old_exists' => true,
9// 'old' => 1,
10// 'new_exists' => true,
11// 'new' => 2,
12// 'aggregate' => false,
13// 'redacted' => false,
14// ]]

toJson() takes the usual json_encode() flags and always adds JSON_THROW_ON_ERROR, so a value JSON cannot encode, such as invalid UTF-8 or NAN, throws a JsonException:

1$result->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
2$result->toJson(JSON_INVALID_UTF8_SUBSTITUTE); // replaces invalid UTF-8 instead of throwing

#Paths & Wildcards

A path is the location of a value inside the compared data: a list of keys and list indexes. You write it in dot notation, and the package gives it back as a Path object.

#Dot Notation

Path Points to
'' (empty string) The root value
user.email $data['user']['email']
users.4.email $data['users'][4]['email']
tags.a\.b $data['tags']['a.b'], a key with a dot in it
files.\* $data['files']['*'], a key that is a star
items.*.price price in every item of items (options only)

Use \., \* and \\ for a literal dot, star or backslash inside a key. In PHP single-quoted strings, write them as 'tags.a\.b' and 'path\\\\to'.

A path with an empty segment, such as users..1, is rejected with an InvalidArgumentException.

#Wildcards

In options, * matches any one key or list index:

1DiffOptions::create()
2 ->ignore('items.*.cached_at')
3 ->redact('accounts.*.token')
4 ->identify('orders.*.lines', 'sku');

Result queries such as hasChanged() and at() take exact paths, without wildcards.

#Keys and Indexes

Segments are stored the way PHP stores array keys, so the string "4" and the integer 4 are the same segment:

1Path::fromSegments(['users', '4'])->toDot() === Path::fromSegments(['users', 4])->toDot(); // true

#The Path Class

1use Pharaonic\Diff\Path;
2 
3$path = Path::fromDot('users.4.email');
4 
5$path->segments(); // ['users', 4, 'email']
6$path->toDot(); // 'users.4.email'
7$path->toJsonPointer(); // '/users/4/email'
8$path->isRoot(); // false
9 
10Path::fromSegments(['tags', 'a.b'])->toDot(); // 'tags.a\.b'

The path, old_path and new_path keys of toArray() use the same escaped dot notation, so Path::fromDot() reads them back. Use toJsonPointer() when you need RFC 6901 pointers instead.

#Ignoring, Selecting & Redacting

Three options control which values the result reports and what it stores. Each takes one path or a list of paths, with * wildcards. Calling one again adds paths; it never replaces the earlier ones.

#Ignore

ignore() leaves out the values at these paths and everything below them. They are not compared or even traversed:

1$options = DiffOptions::create()->ignore(['updated_at', 'items.*.cached_at']);
2 
3Diff::between(
4 ['name' => 'a', 'updated_at' => 1],
5 ['name' => 'a', 'updated_at' => 2],
6 $options,
7)->hasChanges(); // false

#Only

only() reports only the changes at these paths and below them:

1$options = DiffOptions::create()->only('items.*.price');
2 
3$result = Diff::between(
4 ['items' => [['sku' => 'A', 'price' => 1, 'name' => 'Pen']]],
5 ['items' => [['sku' => 'A', 'price' => 5, 'name' => 'Blue pen']]],
6 $options,
7);
8 
9$result->wasModified('items.0.price'); // true
10$result->hasChanged('items.0.name'); // false
11$result->wasModified('items'); // false: containers above the selected paths are never reported

Branches that cannot lead to the selected paths are skipped. The containers above them are traversed but never reported, not even when a whole container was added, removed or changed type. ignore() still wins over only().

#Redact

redact() compares the values at these paths as usual, but stores Change::REDACTED ('[REDACTED]') in the result instead of them:

1$options = DiffOptions::create()->redact('user.password');
2 
3$result = Diff::between(
4 ['user' => ['name' => 'a', 'password' => 'old-secret']],
5 ['user' => ['name' => 'b', 'password' => 'new-secret']],
6 $options,
7);
8 
9$result->wasModified('user.password'); // true: the change is still detected
10$result->at('user.password')[0]->newValue(); // '[REDACTED]'
11$result->at('user.password')[0]->isRedacted(); // true
12$result->at('user')[0]->newValue()['password']; // '[REDACTED]' inside the parent too

The secret never reaches the result: it is replaced inside the values of every parent, and inside added or removed containers. In those, the container itself has isRedacted() set to false, because only part of it was replaced.

Redaction Covers Values, Not Keys

Redaction replaces values. A key name that is itself sensitive still appears in paths. Keep secrets out of keys, or ignore() those branches instead.

#Combining Them

1$options = DiffOptions::create()
2 ->only(['profile', 'settings'])
3 ->ignore('profile.updated_at')
4 ->redact(['profile.api_token', 'settings.*.secret']);

The order of the calls does not matter: a path is ignored when any ignore() pattern matches it, and redacted when any redact() pattern matches it.

#Lists & Identity

How a list should be compared depends on what it holds. Choose a strategy for each path with listStrategy(), or compare records by key with identify(). When several rules match the same path, the one added last wins.

#Sequence (the Default)

The diff aligns both lists the way git diff aligns lines, so inserting an item does not shift every later one:

1Diff::between(['a', 'b', 'c'], ['x', 'a', 'b', 'c']);
2// added 0

A replaced item is reported as modified when it can be paired with the item it replaced. Scalars pair when as many items were removed as added. Records pair when they share at least half of their keys and at least one value, and are then compared field by field:

1Diff::between(['a', 'b', 'c'], ['a', 'x', 'c']);
2// modified 1 ("b" -> "x")
3 
4Diff::between(
5 [['sku' => 'A', 'qty' => 1], ['sku' => 'B', 'qty' => 1]],
6 [['sku' => 'A', 'qty' => 2], ['sku' => 'B', 'qty' => 1]],
7);
8// modified 0 (aggregate)
9// modified 0.qty (1 -> 2)

Items that cannot be paired are reported as one removed and one added change.

Selected Paths Inside Replaced Records

A record reported as removed and added is a container above any path you select with only(), so only() hides it. Compare such lists by identity, below, so that each record is matched by its key.

#Position

ListStrategy::POSITION compares items index by index. Use it when the position is the item's meaning, such as coordinates or the days of a week:

1use Pharaonic\Diff\ListStrategy;
2 
3$options = DiffOptions::create()->listStrategy('point', ListStrategy::POSITION);
4 
5Diff::between(['point' => [1, 2]], ['point' => [2, 2]], $options);
6// modified point (aggregate)
7// modified point.0 (1 -> 2)

#Unordered

ListStrategy::UNORDERED ignores the order but keeps counts, so the list behaves like a multiset. Use it for tags, roles or permissions:

1$options = DiffOptions::create()->listStrategy('tags', ListStrategy::UNORDERED);
2 
3Diff::between(['tags' => ['php', 'laravel', 'php']], ['tags' => ['laravel', 'php', 'go']], $options);
4// modified tags (aggregate)
5// removed tags.2 ("php", the second one)
6// added tags.2 ("go")

A reordered list with the same items reports nothing.

#Identity

identify() compares a list of records by one key, or by several keys together. Records with the same identity are compared with each other wherever they are, and reordering them reports nothing:

1$before = ['users' => [['id' => 1, 'name' => 'Ahmed'], ['id' => 2, 'name' => 'Sara'], ['id' => 3, 'name' => 'Omar']]];
2$after = ['users' => [['id' => 2, 'name' => 'Sara'], ['id' => 1, 'name' => 'Ahmed M.'], ['id' => 4, 'name' => 'Mona']]];
3 
4$result = Diff::between($before, $after, DiffOptions::create()->identify('users', 'id'));
5 
6// modified users (aggregate)
7// modified users.1 (aggregate, was users.0)
8// modified users.1.name ("Ahmed" -> "Ahmed M.", was users.0.name)
9// added users.2 (id 4)
10// removed users.2 (id 3)

The record with id 1 was at index 0 and is now at index 1. This is where oldPath() and newPath() differ: oldPath() is users.0 and newPath() is users.1. The result's methods find it by either one:

1$result->wasModified('users.1.name'); // true: the canonical (new) path
2$result->hasChanged('users.0'); // true: through the old path

For a composite identity, pass the keys as a list. Records are then identified by all of them together:

1DiffOptions::create()->identify('lines', ['order_id', 'sku']);

Identity values are compared strictly by type, so 1 and '1' are different identities. Each record must have every identity key, and no two records of the same list may share an identity; otherwise an IdentityException is thrown.

#Detecting Moves

By default, records that only changed order report nothing. Add detectMoves() to report the records whose order changed relative to the others:

1$options = DiffOptions::create()->identify('users', 'id')->detectMoves();
2 
3Diff::between(
4 ['users' => [['id' => 1], ['id' => 2], ['id' => 3]]],
5 ['users' => [['id' => 3], ['id' => 1], ['id' => 2]]],
6 $options,
7);
8// modified users (aggregate)
9// moved users.0 (id 3, was users.2)

Only the record with id 3 moved: records 1 and 2 kept their order relative to each other, even though their indexes changed. The diff keeps the largest group of records in their original order and reports the rest as moved, so a single move never produces a cascade of changes.

A moved record that also changed has a moved change and its own modified changes. wasMoved() finds it by its old or its new path.

detectMoves() applies to lists compared by identity. Under the other strategies, a changed order is either an insertion and a removal (sequence), a modification (position) or nothing (unordered).

#Root Lists and Nested Lists

Use the empty path for a list at the root, and wildcards for lists inside lists:

1DiffOptions::create()
2 ->identify('', 'id')
3 ->identify('*.items', 'sku')
4 ->listStrategy('*.tags', ListStrategy::UNORDERED);

A rule for a nested list applies only when the records around it are compared with each other. If the outer list is a sequence and the outer records share no value, they are reported as removed and added, and the inner list is never compared. Identify the outer list too, as above.

#Objects

Objects are compared in one of two ways. A comparator decides whether two objects are equal. A normalizer turns an object into a scalar, null or an array, which is then compared like any other value.

#Built-in Rules

Object Compared
DateTimeInterface By instant, to the microsecond, whatever the time zones
JsonSerializable As the value jsonSerialize() returns
stdClass As a map of its public properties
1Diff::between(
2 ['at' => new DateTimeImmutable('2026-01-01 12:00 UTC')],
3 ['at' => new DateTime('2026-01-01 14:00 +02:00')],
4)->hasChanges(); // false: the same instant
5 
6Diff::between((object) ['a' => 1, 'b' => (object) ['c' => 1]], (object) ['a' => 1, 'b' => (object) ['c' => 2]]);
7// modified b (aggregate)
8// modified b.c

Any other object throws an UnsupportedValueException until you register a rule for its class:

Message
An object of class ArrayObject cannot be compared without a normalizer at the root.

#Normalizers

normalize() registers a callback that turns instances of a class, or of any class implementing an interface, into a comparable value. The diff then looks inside the result, so you get changes at nested paths:

1$options = DiffOptions::create()
2 ->normalize(Money::class, fn (Money $money) => [
3 'amount' => $money->amount,
4 'currency' => $money->currency,
5 ]);
6 
7Diff::between(['price' => new Money(100, 'EGP')], ['price' => new Money(150, 'EGP')], $options);
8// modified price (aggregate)
9// modified price.amount (100 -> 150)

A normalizer may return another object; that object is normalized in turn. The result stores the normalized values, not the objects. Objects handled by a comparator, such as dates, are stored as they are.

#Comparators

compare() registers a callback that decides whether two instances are equal. Only true means equal. Use it for objects whose inside you do not want to report, or that define their own equality:

1$options = DiffOptions::create()
2 ->compare(Money::class, fn (Money $a, Money $b) => $a->amount === $b->amount && $a->currency === $b->currency);
3 
4Diff::between(['price' => new Money(100, 'EGP')], ['price' => new Money(150, 'EGP')], $options);
5// modified price (one change; the objects are stored as they are)

A pair that is not equal is reported as one modified change and is never compared further. Both objects must be instances of the comparator's class; otherwise it does not apply.

Inside lists, objects that the comparator finds equal are matched with each other, and as identity keys they count as the same identity. This expects the comparator to behave like equality: if a equals b, then b equals a, and if b also equals c, then a equals c.

#Which Rule Applies

For each object, the diff uses the first rule that matches:

  1. A comparator you registered, the last one added first.
  2. A normalizer you registered, the last one added first.
  3. The built-in DateTimeInterface comparison.
  4. jsonSerialize() for JsonSerializable.
  5. Public properties for stdClass.

Anything you register wins over the built-in rules. To compare dates by day, register either one:

1// Report the date as one modified value.
2DiffOptions::create()->compare(
3 DateTimeInterface::class,
4 fn (DateTimeInterface $a, DateTimeInterface $b) => $a->format('Y-m-d') === $b->format('Y-m-d'),
5);
6 
7// Store and report the dates as 'Y-m-d' strings.
8DiffOptions::create()->normalize(DateTimeInterface::class, fn (DateTimeInterface $date) => $date->format('Y-m-d'));
Objects That Contain Themselves

An object that contains itself, directly or through its children, throws an UnsupportedValueException instead of recursing forever. The same applies to a normalizer chain that comes back to an object it already visited.

#Laravel Models and Collections

Diff has no Laravel dependency. Compare $model->toArray() or $collection->all(), or register a normalizer:

1use Illuminate\Database\Eloquent\Model;
2 
3$options = DiffOptions::create()
4 ->normalize(Model::class, fn (Model $model) => $model->attributesToArray())
5 ->ignore(['updated_at', 'created_at']);
6 
7Diff::between($original, $post->fresh(), $options);

Models implement JsonSerializable, so without a normalizer they are compared through jsonSerialize(), which includes loaded relations and appended attributes.

#Text Diffs

Diff::text() compares two strings line by line, like git diff, and returns a TextDiffResult.

1$result = Diff::text($old, $new);
2 
3$result->hasChanges(); // true
4$result->stats(); // ['added' => 2, 'removed' => 1, 'modified' => 1, 'unchanged' => 2]

#Line Endings

A line ends with "\n", "\r\n" or "\r", and all three are treated alike, so a file converted from Windows to Unix line endings reports no change. Line content never includes the ending.

Whether a text ends with a line ending is compared too. When only one side has a final newline, the last line is reported as changed, and the unified diff adds \ No newline at end of file, as Git does.

#Whitespace

1TextDiffOptions::create()->ignoreWhitespaceChanges(); // like git diff -b: "a b" equals "a b"
2TextDiffOptions::create()->ignoreTrailingWhitespace(); // like git diff --ignore-space-at-eol

Lines that are equal only because whitespace is ignored are unchanged, and keep their old content.

#Lines

lines() returns every line in reading order: unchanged, removed and added, with the removed lines of a change before its added lines. Each one is a DiffLine:

1foreach ($result->lines() as $line) {
2 $line->type(); // ChangeType::ADDED, REMOVED or UNCHANGED
3 $line->content(); // the text, without its line ending
4 $line->oldLineNumber(); // 1-based, or null for an added line
5 $line->newLineNumber(); // 1-based, or null for a removed line
6}
7 
8$result->addedLines(); // only added lines
9$result->removedLines(); // only removed lines

#Hunks

hunks() groups the changes with the unchanged lines around them, as in a unified diff. Each Hunk has oldStart(), oldLength(), newStart(), newLength() and lines(). When a side has no lines in the hunk, its start is the line the hunk comes after, or 0 at the start of the text, as Git writes it.

Set how many unchanged lines surround each change with context() (3 by default). Two changes closer than twice the context share one hunk:

1use Pharaonic\Diff\TextDiffOptions;
2 
3// Lines 5 and 15 of a 20-line file changed.
4count(Diff::text($old, $new)->hunks()); // 2
5count(Diff::text($old, $new, TextDiffOptions::create()->context(1))->hunks()); // 2, with 4 lines each
6count(Diff::text($old, $new, TextDiffOptions::create()->context(5))->hunks()); // 1

Equal texts have no hunks.

#Modified Lines

A removed line and an added line that look alike are paired as a modified line, the way GitHub shows a changed line. modifiedPairs() returns them as ModifiedLinePair objects with removed() and added(). Both lines still appear on their own in lines() and in the hunks.

1$result = Diff::text("The quick brown fox\n", "The quick red fox\n");
2 
3$pair = $result->modifiedPairs()[0];
4$pair->removed()->content(); // 'The quick brown fox'
5$pair->added()->content(); // 'The quick red fox'

Two lines pair when their word similarity is at least 0.55. Change it with similarityThreshold(), between 0 and 1, or turn pairing off with pairModifiedLines(false).

#Inline Highlighting

The lines of a modified pair are split into segments, so you can highlight what changed inside them. inlineSegments() returns InlineSegment objects with type() and text(), which join back into the line's content:

1foreach ($pair->added()->inlineSegments() as $segment) {
2 echo $segment->type(), ': ', $segment->text(), PHP_EOL;
3}
4 
5// unchanged: The quick
6// added: red
7// unchanged: fox

On a removed line, the changed segments are removed; on an added line, they are added.

Choose the granularity with inline():

Mode Splits by
InlineMode::WORD (default) Words, runs of whitespace and single punctuation characters
InlineMode::CHARACTER Characters: grapheme clusters for valid UTF-8, bytes otherwise
InlineMode::NONE Nothing: modified lines have no segments
1use Pharaonic\Diff\InlineMode;
2 
3$result = Diff::text("The colour of the sky\n", "The color of the sky\n", TextDiffOptions::create()->inline(InlineMode::CHARACTER));
4 
5// The removed line splits into "The colo", "u" (removed) and "r of the sky".

Lines are paired by word similarity in every mode, so two one-word lines such as colour and color are not paired, and have no segments.

inlineSegments() is empty for unchanged lines, unpaired lines, and lines longer than maxInlineLength() (1,000 bytes by default), which count as changed as a whole.

#Arrays and JSON

toArray(), jsonSerialize() and toJson() give the stats, whether each side ends with a newline, the hunks with their lines and inline segments, and the line numbers of the modified pairs:

1Diff::text("alpha\nbeta\n", "alpha\nbeta changed\n")->toArray();
2 
3// [
4// 'stats' => ['added' => 1, 'removed' => 1, 'modified' => 1, 'unchanged' => 1],
5// 'old_ends_with_newline' => true,
6// 'new_ends_with_newline' => true,
7// 'hunks' => [[
8// 'old_start' => 1, 'old_length' => 2, 'new_start' => 1, 'new_length' => 2,
9// 'lines' => [
10// ['type' => 'unchanged', 'content' => 'alpha', 'old_line' => 1, 'new_line' => 1, 'inline' => []],
11// ['type' => 'removed', 'content' => 'beta', 'old_line' => 2, 'new_line' => null, 'inline' => [...]],
12// ['type' => 'added', 'content' => 'beta changed', 'old_line' => null, 'new_line' => 2, 'inline' => [...]],
13// ],
14// ]],
15// 'modified' => [['old_line' => 2, 'new_line' => 2]],
16// ]

Only the lines inside hunks are included. Use lines() for all of them.

#Unified & HTML Output

A TextDiffResult renders itself as a unified diff or as an HTML table. Both are empty strings when the texts are equal.

#Unified Diff

toUnified() writes a standard unified diff, with the hunks and the context you set:

1echo Diff::text($old, $new)->toUnified('a/config/app.php', 'b/config/app.php');
Output
1--- a/config/app.php
2+++ b/config/app.php
3@@ -1,3 +1,4 @@
4 alpha
5-beta
6+beta changed
7 gamma
8+delta
9\ No newline at end of file

The two labels default to before and after, and cannot contain a line ending. Every line of the output ends with "\n", whatever line endings the texts used.

The output is a valid patch: git apply and patch apply it to the old text and give back the new one, including the \ No newline at end of file markers. With context(0), pass --unidiff-zero to git apply, as Git requires for patches without context.

Terminal
git apply changes.patch

#HTML

toHtml() returns a GitHub-style <table> of the hunks, with old and new line numbers, + and - markers, and the changed words of modified lines highlighted:

1echo Diff::text($old, $new)->toHtml();

Every piece of text is escaped with htmlspecialchars(), so you can render a diff of user content safely. Line numbers and markers are in data-line-number and data-marker attributes, displayed by CSS, so copying the code from the page does not copy them.

#Styling

The package ships an optional GitHub-style stylesheet at vendor/pharaonic/php-diff/resources/diff.css. Copy it into your assets, or write your own rules for its classes:

Terminal
cp vendor/pharaonic/php-diff/resources/diff.css public/css/diff.css

diff.css follows GitHub's colors, switches to dark colors with prefers-color-scheme, and is themed through custom properties on .ph-diff:

public/css/app.css
1.ph-diff {
2 --ph-diff-font: "JetBrains Mono", monospace;
3 --ph-diff-added-background: #e6ffec;
4 --ph-diff-removed-background: #ffebe9;
5}
Class Element
ph-diff The <table>
ph-diff-hunk A <tbody> per hunk
ph-diff-hunk-header, ph-diff-hunk-header-text The @@ -1,3 +1,4 @@ row and its cell
ph-diff-line Every line row, with ph-diff-line--context, ph-diff-line--added or ph-diff-line--removed
ph-diff-number The old and new line number cells
ph-diff-code The code cell
ph-diff-marker The +, - or space marker inside the code cell
ph-diff-inline--added, ph-diff-inline--removed The <ins> and <del> around changed words
ph-diff-no-newline The "No newline at end of file" row

The custom properties are --ph-diff-font, --ph-diff-text, --ph-diff-muted, --ph-diff-background, --ph-diff-border, --ph-diff-hunk-background, and --ph-diff-added-* / --ph-diff-removed-* for background, number-background and inline.

#Your Own Renderer

For any other layout, such as a side-by-side view or a terminal, build it from hunks(), each line's type(), content() and line numbers, and inlineSegments(). toArray() gives the same data as plain arrays for JavaScript front ends.

#Limits & Performance

Diffs can take time and memory on large or very different inputs. Every limit has a safe default, and you can change it in the options.

#Limits

Option Default When exceeded
DiffOptions::maxDepth() 64 levels MaximumDepthExceededException
TextDiffOptions::maxLines() 20,000 lines per text InputLimitExceededException
TextDiffOptions::maxInputBytes() 4 MiB per text InputLimitExceededException
TextDiffOptions::maxInlineLength() 1,000 bytes per line The line is highlighted as a whole, without an exception
1$options = TextDiffOptions::create()
2 ->maxLines(50_000)
3 ->maxInputBytes(10 * 1024 * 1024);

The text limits are checked before any work starts, on both texts. The depth limit also stops arrays that reference themselves.

Raise Text Limits With Care

The defaults keep the slowest case, two completely different texts at the limit, to a few seconds and under 100 MB on PHP 8.0. Doubling the limits can more than double the time and the memory. If users can submit the texts, keep the defaults or lower them, and run large diffs in a queued job.

#What to Expect

Measured on PHP 8.0, each case in a separate process. Your numbers depend on your machine:

Case Time Peak memory
1,000-key maps, every value changed about 5 ms 4 MB
1,000 records, all modified, no identity about 0.15 s 8 MB
1,000 identified records, reversed about 0.14 s 6 MB
10,000 records that never pair about 1.8 s 36 MB
10,000 text lines, every 100th changed about 0.07 s 16 MB
1,000 Unicode lines, character mode about 0.2 s 8 MB
5,000 completely different lines about 0.8 s 20 MB
20,000 completely different lines (default limit) about 4 s 73 MB
4 MiB in 20,000 different lines (default limits) about 3.3 s 87 MB

Typical inputs, like an edited file or a changed record, take milliseconds. The time grows with how different the inputs are, not only with their size.

#How the Work Stays Bounded

  • Text alignment uses the linear-space Myers algorithm, as Git does. On very different texts, it stops searching for the shortest diff after a cost limit, again as Git does, and returns a valid diff that may be slightly longer than the shortest one.
  • Modified line pairing skips pairs whose shared words already rule them out, before comparing them in full.
  • Objects in lists that a comparator handles are matched by calling the comparator against each different object already seen in that diff, so a list of 3,000 different objects takes about half a second. For large lists, a normalizer is faster: its values are matched directly.
  • Record pairing in a sequence list compares every removed record with every added one, up to 10,000 comparisons per replaced run. Larger runs pair records at the same position only.
  • Text lines are turned into numbers once, one per distinct line, so the alignment compares integers instead of strings, however long the lines are.

For lists of records, identify() is faster and more precise than the default sequence strategy: each record is found by its key, with no pairing guesswork.

#Exceptions

Every exception thrown while comparing implements Pharaonic\Diff\Exceptions\DiffException, so you can catch them all at once. Each one also extends a standard SPL exception, so code that catches those keeps working.

1use Pharaonic\Diff\Exceptions\DiffException;
2 
3try {
4 $result = Diff::between($before, $after, $options);
5} catch (DiffException $e) {
6 $logger->warning($e->getMessage());
7}

#While Comparing

Exception Extends Thrown when
UnsupportedValueException InvalidArgumentException A value cannot be compared: a resource, a closure, a generator, an object without a rule, or an object that contains itself
MaximumDepthExceededException UnsupportedValueException A value nests deeper than maxDepth(), which includes arrays that reference themselves
IdentityException UnexpectedValueException A record of an identified list has no identity key, or shares its identity with another record
InputLimitExceededException LengthException A text has more lines than maxLines() or more bytes than maxInputBytes()

The messages say where the problem is:

Messages
1An object of class ArrayObject cannot be compared without a normalizer at the root.
2The items at indexes 0 and 1 of the list at path "users" have the same identity.
3The item at index 0 of the list at path "users" has no identity key "id".
4The old text has 25000 lines, more than the maximum of 20000.

#While Building Options

Invalid options throw a plain InvalidArgumentException as soon as you set them, before any comparison. These are programming mistakes, not data problems, so they do not implement DiffException:

1TextDiffOptions::create()->context(-1);
2// InvalidArgumentException: The context must be at least 0, -1 given.
3 
4DiffOptions::create()->listStrategy('tags', 'sorted');
5// InvalidArgumentException: Unknown list strategy "sorted"; use a ListStrategy constant.
6 
7DiffOptions::create()->normalize('App\Missing', fn ($value) => $value);
8// InvalidArgumentException: The class or interface "App\Missing" does not exist.

Paths with an empty segment, such as users..1, also throw an InvalidArgumentException, both in options and in result queries. So does a toUnified() label with a line ending in it, which would break the patch.

#JSON

DiffResult::toJson() and TextDiffResult::toJson() always add JSON_THROW_ON_ERROR, so a value JSON cannot encode throws a JsonException.

#API Reference

All classes live in the Pharaonic\Diff namespace, and the exceptions in Pharaonic\Diff\Exceptions. Options are immutable: every method that configures them returns new options. Results and their parts are read-only.

#Diff

Method Description Returns
between(mixed $before, mixed $after, ?DiffOptions $options = null) Compares two structured values DiffResult
text(string $before, string $after, ?TextDiffOptions $options = null) Compares two texts line by line TextDiffResult

#DiffOptions

Method Description
create() New default options
ignore(string|array $paths) Leaves out these paths and everything below them
only(string|array $paths) Reports only these paths and everything below them
redact(string|array $paths) Stores Change::REDACTED instead of these values
maxDepth(int $depth) How deep values may nest (64 by default)
floatTolerance(float $tolerance) Treats floats within this absolute amount as equal (0 by default)
listStrategy(string $path, string $strategy) Compares the lists at a path with a ListStrategy constant
identify(string $path, int|string|array $keys) Compares the lists at a path as records identified by one or more keys
detectMoves() Reports identified records that changed their relative order as moved
normalize(string $class, callable $normalizer) Turns instances of a class or interface into a comparable value: fn (object $value): mixed
compare(string $class, callable $equals) Decides whether two instances are equal: fn (object $a, object $b): bool

#DiffResult

Implements Countable, IteratorAggregate and JsonSerializable. Methods that take a path accept dot notation or a Path.

Method Description Returns
hasChanges() Whether anything changed bool
hasChanged(string|Path $path) Whether anything at or below the path changed, at its old or new path bool
wasAdded(string|Path $path) Whether a value was added at exactly this path bool
wasRemoved(string|Path $path) Whether a value was removed from exactly this path bool
wasModified(string|Path $path) Whether the value at exactly this path was modified, directly or through its descendants bool
wasMoved(string|Path $path) Whether a value moved from or to exactly this path bool
changes() Every change, parents before their descendants list<Change>
added() / removed() / modified() / moved() The changes of one type list<Change>
at(string|Path $path) The changes whose canonical path is exactly this path list<Change>
under(string|Path $path) The changes at or below this path, at their old or new path list<Change>
count() The number of changes int
stats() total, added, removed, modified and moved counts array
toArray() / jsonSerialize() Every change as an array array
toJson(int $flags = 0) The changes as JSON, always with JSON_THROW_ON_ERROR string

#Change

Method Description Returns
type() A ChangeType constant string
path() The old path for a removed value, the new path otherwise Path
oldPath() / newPath() Where the value was / is, or null ?Path
hasOldValue() / hasNewValue() Whether the value existed before / exists after bool
oldValue() / newValue() The value before / after, or null mixed
isAggregate() Whether it is a container reported because of changes inside it bool
isRedacted() Whether the values were replaced with Change::REDACTED as a whole bool
toArray() type, path, old_path, new_path, old_exists, old, new_exists, new, aggregate, redacted array

Change::REDACTED is '[REDACTED]'.

#Path

Method Description Returns
fromSegments(array $segments) A path from keys and indexes Path
fromDot(string $path) Parses dot notation; '' is the root Path
segments() The keys and indexes list<int|string>
isRoot() Whether it is the empty path bool
toDot() Escaped dot notation string
toJsonPointer() RFC 6901 JSON Pointer string

#TextDiffOptions

Method Description
create() New default options
context(int $lines) Unchanged lines around each change in a hunk (3 by default)
pairModifiedLines(bool $pair = true) Whether alike removed and added lines are paired (on by default)
similarityThreshold(float $threshold) How similar, from 0 to 1, paired lines must be (0.55 by default)
inline(string $mode) An InlineMode constant (word by default)
ignoreWhitespaceChanges() Lines that differ only in the amount of whitespace are equal
ignoreTrailingWhitespace() Lines that differ only in trailing whitespace are equal
maxLines(int $lines) The most lines either text may have (20,000 by default)
maxInputBytes(int $bytes) The most bytes either text may have (4 MiB by default)
maxInlineLength(int $bytes) The longest line that gets inline highlighting (1,000 bytes by default)

#TextDiffResult

Implements JsonSerializable.

Method Description Returns
hasChanges() Whether the texts differ bool
lines() Every line in reading order list<DiffLine>
hunks() The changes with their context list<Hunk>
addedLines() / removedLines() The lines of one type list<DiffLine>
modifiedPairs() Alike removed and added lines list<ModifiedLinePair>
stats() added, removed, modified and unchanged line counts array
toUnified(string $oldLabel = 'before', string $newLabel = 'after') A unified diff that git apply and patch accept string
toHtml() A GitHub-style HTML table string
toArray() / jsonSerialize() Stats, final newlines, hunks and modified pairs array
toJson(int $flags = 0) toArray() as JSON, always with JSON_THROW_ON_ERROR string

#DiffLine, Hunk, ModifiedLinePair and InlineSegment

Class Methods
DiffLine type(), content(), oldLineNumber(), newLineNumber(), inlineSegments(), toArray()
Hunk oldStart(), oldLength(), newStart(), newLength(), lines(), toArray()
ModifiedLinePair removed(), added(), toArray()
InlineSegment type(), text(), toArray()

#Constants

Class Constants
ChangeType ADDED, REMOVED, MODIFIED, MOVED, UNCHANGED
ListStrategy SEQUENCE (default), POSITION, UNORDERED
InlineMode NONE, WORD (default), CHARACTER

Their values are the lowercase names, such as 'added' and 'unordered', so they are safe to store.

#Exceptions

Class Extends
DiffException Interface implemented by all of them
UnsupportedValueException InvalidArgumentException
MaximumDepthExceededException UnsupportedValueException
IdentityException UnexpectedValueException
InputLimitExceededException LengthException

Classes outside this list, and methods marked @internal, are not part of the public API and may change in any release.

#Examples

#An Audit Log of Model Changes

Record what changed on every update, without timestamps or secrets.

app/Observers/UserObserver.php
1namespace App\Observers;
2 
3use App\Models\User;
4use Pharaonic\Diff\Diff;
5use Pharaonic\Diff\DiffOptions;
6 
7class UserObserver
8{
9 public function updated(User $user): void
10 {
11 $options = DiffOptions::create()
12 ->ignore(['updated_at', 'remember_token'])
13 ->redact(['password', 'two_factor_secret']);
14 
15 $result = Diff::between($user->getRawOriginal(), $user->getAttributes(), $options);
16 
17 if ($result->hasChanges()) {
18 $user->audits()->create(['changes' => $result->toArray()]);
19 }
20 }
21}
resources/views/users/audits.blade.php
1@foreach ($audit->changes as $change)
2 <li>
3 <strong>{{ $change['path'] }}</strong> {{ $change['type'] }}:
4 {{ json_encode($change['old']) }} → {{ json_encode($change['new']) }}
5 </li>
6@endforeach

Each stored change keeps its type, path, and old and new values, with [REDACTED] in place of the password.

#Syncing Records From an Import

Compare the current catalog with an imported one and act on each product by SKU.

src/Catalog/ImportSync.php
1use Pharaonic\Diff\Diff;
2use Pharaonic\Diff\DiffOptions;
3 
4$options = DiffOptions::create()->identify('', 'sku')->ignore('*.synced_at');
5 
6$result = Diff::between($currentProducts, $importedProducts, $options);
7 
8foreach ($result->added() as $change) {
9 $catalog->create($change->newValue());
10}
11 
12foreach ($result->removed() as $change) {
13 $catalog->archive($change->oldValue()['sku']);
14}
15 
16foreach ($result->modified() as $change) {
17 if ($change->isAggregate() && count($change->path()->segments()) === 1) {
18 $catalog->update($change->newValue()['sku'], $change->newValue());
19 }
20}

Each product is matched by its SKU, wherever it is in either list, so a reordered import changes nothing.

#Showing a Revision Like GitHub

Render the difference between two versions of a document.

app/Http/Controllers/RevisionController.php
1use Pharaonic\Diff\Diff;
2use Pharaonic\Diff\TextDiffOptions;
3 
4public function show(Revision $revision)
5{
6 $diff = Diff::text(
7 $revision->previous->body,
8 $revision->body,
9 TextDiffOptions::create()->ignoreTrailingWhitespace(),
10 );
11 
12 return view('revisions.show', ['diff' => $diff]);
13}
resources/views/revisions/show.blade.php
1<link rel="stylesheet" href="{{ asset('css/diff.css') }}">
2 
3<p>+{{ $diff->stats()['added'] }} −{{ $diff->stats()['removed'] }}</p>
4 
5{!! $diff->toHtml() !!}

toHtml() escapes every piece of text, so printing it raw with {!! !!} is safe. Copy resources/diff.css to public/css/diff.css first.

#Downloading a Patch

Let users download the change as a patch they can apply with Git.

app/Http/Controllers/PatchController.php
1use Pharaonic\Diff\Diff;
2 
3public function download(Revision $revision)
4{
5 $patch = Diff::text($revision->previous->body, $revision->body)
6 ->toUnified('a/' . $revision->path, 'b/' . $revision->path);
7 
8 return response($patch, 200, [
9 'Content-Type' => 'text/x-diff',
10 'Content-Disposition' => 'attachment; filename="revision-' . $revision->id . '.patch"',
11 ]);
12}

#Clear Failure Messages in Tests

Show exactly which fields of an API response changed, instead of a wall of JSON.

tests/Feature/OrderApiTest.php
1use Pharaonic\Diff\Diff;
2use Pharaonic\Diff\DiffOptions;
3 
4it('returns the order unchanged', function () {
5 $expected = json_decode(file_get_contents(__DIR__ . '/fixtures/order.json'), true);
6 $actual = $this->getJson('/api/orders/1')->json();
7 
8 $result = Diff::between($expected, $actual, DiffOptions::create()->ignore(['*.created_at', '*.updated_at']));
9 
10 expect($result->hasChanges())->toBeFalse(json_encode($result->stats()) . PHP_EOL . $result->toJson(JSON_PRETTY_PRINT));
11});

#Troubleshooting

#A value is reported as modified, but it looks the same

Comparison is strict: 1, '1', 1.0 and true are all different. This usually comes from a database or form returning strings where your other side has numbers. Cast both sides the same way before comparing, or use floatTolerance() for floats that differ by rounding.

#Every item after an insertion is reported as changed

That happens with ListStrategy::POSITION, which compares index by index. Use the default sequence strategy, or identify() for records.

#A record is reported as removed and added instead of modified

In a sequence list, two records are paired only when they share at least half of their keys and at least one value. A record whose every value changed is reported as removed and added. Use identify() to match records by their key.

#Reordering a list reports changes

Lists keep their order by default. Use ListStrategy::UNORDERED for sets of values such as tags, or identify() for records. With identify(), order changes are reported only when you add detectMoves().

#A change I expected is missing from an only() result

only() never reports the containers above the selected paths. When a whole record was added, removed or replaced, its change is at the record's path, which is above the selected one.

#UnsupportedValueException for an object

The object is not a date, JsonSerializable or stdClass. Register a normalizer or a comparator for its class or one of its interfaces.

#MaximumDepthExceededException on normal data

The data nests deeper than 64 levels, or an array references itself. Raise the limit with maxDepth() if the data is really that deep.

#IdentityException

Every record of an identified list needs every identity key, and identities must be unique in that list. Check for records without the key, duplicates, or 1 and '1' mixed when you expected them to match: identities are compared strictly.

#InputLimitExceededException for a text

The text has more than 20,000 lines or 4 MiB. Raise maxLines() or maxInputBytes() if you trust the input and can afford the time.

#git apply rejects the patch

Check that the patch is applied to the same old text it was made from, and that the labels match the file path, such as a/src/File.php. For patches made with context(0), run git apply --unidiff-zero.

#The HTML has no colors

toHtml() returns markup only. Include resources/diff.css, or write rules for the ph-diff-* classes.

#Two lines are not paired as modified

Lines pair when their word similarity is at least 0.55. Short lines that share few words do not pair. Lower similarityThreshold(), or check that pairModifiedLines() was not turned off.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Diff!

Moamen Eltouny

@MoamenEltouny

37 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.