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( 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.
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.5
There are no runtime dependencies and no required PHP extensions.
#Composer Installation
composer require pharaonic/php-diffComposer autoloads the Pharaonic\Diff namespace.
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'); // true10$result->wasAdded('roles.1'); // true11$result->wasRemoved('nickname'); // true12$result->hasChanged('name'); // falseEach 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 email6// modified roles7// added roles.18// added age9// removed nicknameroles 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');1--- a/notes.txt2+++ b/notes.txt3@@ -1,3 +1,4 @@4 alpha5-beta6+beta changed7 gamma8+delta9\ No newline at end of fileecho $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'); // true2Diff::between(['total' => 1.0], ['total' => 1])->hasChanges(); // true3Diff::between(['flag' => 0], ['flag' => false])->hasChanges(); // trueFloats 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'); // trueChange::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 a3}#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.emailThe 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 segments10$result->wasModified('user.email'); // true11$result->wasModified('user'); // true: aggregate modifications count12$result->wasAdded('user.age'); // true13$result->wasRemoved('user.nickname'); // true14$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 descendants2$result->added(); // only added changes3$result->removed();4$result->modified(); // direct and aggregate modifications5$result->moved();6$result->at('user'); // changes whose path is exactly user7$result->under('user'); // changes at user or below it8count($result); // 49$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'); // true10$result->hasChanged('items.0.name'); // false11$result->wasModified('items'); // false: containers above the selected paths are never reportedBranches 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 detected10$result->at('user.password')[0]->newValue(); // '[REDACTED]'11$result->at('user.password')[0]->isRedacted(); // true12$result->at('user')[0]->newValue()['password']; // '[REDACTED]' inside the parent tooThe 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 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 0A 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.
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) path2$result->hasChanged('users.0'); // true: through the old pathFor 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 instant5 6Diff::between((object) ['a' => 1, 'b' => (object) ['c' => 1]], (object) ['a' => 1, 'b' => (object) ['c' => 2]]);7// modified b (aggregate)8// modified b.cAny other object throws an UnsupportedValueException until you register a rule for its class:
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:
- A comparator you registered, the last one added first.
- A normalizer you registered, the last one added first.
- The built-in
DateTimeInterfacecomparison. jsonSerialize()forJsonSerializable.- 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'));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(); // true4$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-eolLines 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 UNCHANGED3 $line->content(); // the text, without its line ending4 $line->oldLineNumber(); // 1-based, or null for an added line5 $line->newLineNumber(); // 1-based, or null for a removed line6}7 8$result->addedLines(); // only added lines9$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()); // 25count(Diff::text($old, $new, TextDiffOptions::create()->context(1))->hunks()); // 2, with 4 lines each6count(Diff::text($old, $new, TextDiffOptions::create()->context(5))->hunks()); // 1Equal 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 quick6// added: red7// unchanged: foxOn 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');1--- a/config/app.php2+++ b/config/app.php3@@ -1,3 +1,4 @@4 alpha5-beta6+beta changed7 gamma8+delta9\ No newline at end of fileThe 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.
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:
cp vendor/pharaonic/php-diff/resources/diff.css public/css/diff.cssdiff.css follows GitHub's colors, switches to dark colors with prefers-color-scheme, and is themed through custom properties on .ph-diff:
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.
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:
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.
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): void10 {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}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@endforeachEach 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.
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.
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}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.
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.
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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.