jfcherng/php-diff
jfcherng/php-diff is a comprehensive PHP library for generating diffs between two strings. It provides text renderers (Context, JSON, Unified) and HTML renderers (Combined, Inline, JSON, Side by Side). Install via Composer; requires PHP 8.3+ and ext-iconv. HTML output needs CSS for proper visualizat...
Install and check the floor first. composer require jfcherng/php-diff pulls v7, which requires PHP ≥ 8.3 and ext-iconv. In a Laravel context that means Laravel 11/12-era apps; if you're still on 8.1/8.2 for a legacy project, this package simply won't resolve. Confirm with php -v and composer why-not jfcherng/php-diff before planning around it.
Where to look first. Skip the source and go straight to the example/ directory in the repo — it has old_file.txt / new_file.txt, runnable scripts per renderer, and diff-table.css, which is the only CSS reference you get. Then read the two options classes in this order: Options\DifferOptions (what counts as a difference) and Options\RendererOptions (how it looks). Those two classes are ~90% of the package's surface area.
First use case: a review screen. Diffing a model revision against the live record is the highest-value, lowest-effort integration:
use Jfcherng\Diff\DiffHelper;
use Jfcherng\Diff\Options\{DifferOptions, RendererOptions};
$html = DiffHelper::calculate(
$old->body,
$new->body,
'SideBySide',
new DifferOptions(context: 3, ignoreWhitespace: false),
new RendererOptions(detailLevel: 'word', lineNumbers: true, showHeader: true),
);
Then, in Blade, {!! $html !!} inside a container, and ship the stylesheet once:
@push('styles')
<style>{!! \Jfcherng\Diff\DiffHelper::getStyleSheet() !!}</style>
@endpush
Note the {!! !!} — the renderers deliberately emit HTML, but they escape the content of each line, so user text doesn't become XSS. That is the intended contract, not a shortcut.
Sanity check before you build UI: call each renderer name once (Unified, Context, Inline, SideBySide, Combined, JsonText, JsonHtml) with your real data and eyeball the output. Names are exact strings, and a typo surfaces as an exception from RendererFactory, not a fallback.
Wrap it in one service, not forty call sites. The package is stateless and cheap to instantiate, but options drift fast. Bind a single class from the container and let it own renderer choice and defaults:
final class DiffService
{
public function html(string $old, string $new, string $renderer = 'Inline'): string
{
if (hash_equals(hash('xxh128', $old), hash('xxh128', $new))) {
return ''; // cheap identical short-circuit
}
return DiffHelper::calculate($old, $new, $renderer, $this->differOptions(), $this->rendererOptions());
}
}
Read the options from config/diff.php and construct DifferOptions/RendererOptions there with named arguments. Treat them as immutable value objects — build fresh per call rather than mutating a shared instance.
Store JSON, render HTML later. This is the pattern that earns its keep. JsonText gives you a structured, detail-free representation you can persist in a diffs table or cache; JsonHtml gives you word-level <ins>/<del> markup baked into the JSON. Persist one, and re-render into any view without re-diffing:
$json = DiffHelper::calculate($old, $new, 'JsonText');
// later, per viewer preference
$html = RendererFactory::make('Inline', $rendererOptions)
->renderArray(json_decode($json, true));
The JSON is an array of hunks, each an array of {tag, old: {offset, lines}, new: {offset, lines}} entries, where tag is eq/ins/del/rep. Offsets are usable line numbers, so a "jump to line" link or an inline annotation layer is a short loop away. If you only need where things changed (e.g. to count changed lines, or to build a minimap), don't render HTML at all — parse the JSON.
Match the renderer to the audience.
SideBySide — admin review screens and merge/draft UIs; line numbers matter here.Inline — change notifications, activity feeds, email bodies; detailLevel: 'word' reads best at narrow widths.Combined — diffing prose (CMS pages, docs, emails) where line alignment is meaningless. Word-level plus mergeThreshold to stop it fragmenting into confetti.Unified / Context — anywhere a human might copy-paste a patch: support tickets, generated changelogs, diff-style artifacts. Both support cliColorization, so they're the right choice for an artisan command or a php artisan diff:... tool: force RendererConstant::CLI_COLOR_ENABLE there and avoid the HTML path entirely.JsonText — machine consumption, persistence, analytics.Normalise before you diff. Decide once whether you care about line endings and whitespace, and encode that decision in the options rather than rewriting the input strings. ignoreCase, ignoreLineEnding, ignoreWhitespace affect only the comparison — the rendered output still shows the original bytes, which is exactly what you want for review. Reach for manual normalisation only when the source data is genuinely malformed (Windows-1252 from an old import, etc.).
Use resultForIdenticals in UI paths. Null gives you an empty render, which looks like a broken panel. Set it to 'No changes.' (or your translated equivalent) at the options level and every screen gets consistent behaviour for free — same for fullContextIfIdentical: true when you'd rather show the unchanged content than a placeholder.
Localise through the package, not around it. language: 'eng' | 'cht' | 'chs' | 'jpn' | … renames the header/labels. For a single-locale app this is cosmetic; for a multi-locale one, pass an array with the same keys as the bundled language file, and derive it from your existing lang/ files so the diff header doesn't become the one untranslated surface in the product. wrapperClasses complements this — use it to scope CSS and to tag the container for your own JS hooks.
Test against the JSON renderer. Assertions on Inline HTML are brittle and unreadable in a diff view of failures. Assert on JsonText output (fixture-based) and rely on a couple of browser-level checks for the HTML shape. That keeps the interesting test — "does ignoreWhitespace really ignore this?" — one line long.
No CSS, no diff. HTML renderers emit unstyled tables; without the stylesheet you get an unreadable wall. Publish DiffHelper::getStyleSheet() once — as a compiled asset, not an inline dump on every request — and namespace your overrides under your wrapperClasses. Version the CSS alongside the package: class names like ch sp (the spaceToHtmlTag output) are implementation details and can shift between majors.
The trailing-newline phantom. You control the split. explode("\n", "a\nb\n") yields three elements, the last empty — so a file that got a trailing newline added produces a diff you'll swear is a bug in the library. It isn't. Either rtrim consistently or accept the empty element on both sides; just be consistent, and test the case where one side has it and the other doesn't.
lengthLimit is a safety valve, not a config knob. Default 2000. It exists so char-level diffs on long lines don't hang a request. If you bump it, do it because you've measured a real case, and set detailLevel: 'word' before 'char' — word-level is nearly as useful and an order of magnitude cheaper. Pre-truncate pathological lines (minified JS, base64 blobs) rather than raising the ceiling. For genuinely large artifacts, push the render into a queued job and cache the result; the diff itself is CPU-bound and synchronous.
JsonText discards detail permanently. If you persist JsonText and later want word-level highlighting, you must re-diff from the original strings — so store those too, or store JsonHtml and accept HTML-in-JSON. Decide at the schema design stage, not when the feature request arrives.
calculateFiles() is a file-read primitive. It takes paths, not file handles. Never let a user-supplied value reach it — that's arbitrary read, and it bypasses whatever Storage disk scoping you thought you had. Load contents through Laravel's filesystem layer and use calculate().
context and hunk splitting interact. Setting context: Differ::CONTEXT_ALL collapses everything into a single hunk; small context values produce many hunks. If you're indexing the JSON hunk array into your own UI sections, know that hunk count changes when you tune context — don't hardcode "there are three blocks".
Debugging unexpected equality/inequality. Nine times out of ten it's invisible characters. Dump bin2hex() of the first differing line rather than squinting at the output — a stray \r, non-breaking space, or zero-width character will fool you at the terminal. Once confirmed, the fix is usually an ignore* flag, not string surgery.
Extension points are real but narrow. Differ takes the two token arrays and options; the renderer takes the Differ. You can implement a custom renderer and hand the class name to RendererFactory::make() — useful when you need a bespoke output (say, a per-change summary JSON for an API) that isn't one of the seven built-ins. You can also construct Differ yourself and pass it to any renderer, which is how you'd reuse one diff across two renderers (HTML and JSON) in a single request instead of computing it twice.
Watch the ecosystem gap. Zero dependents and a modest star count means few blog posts and no Stack Overflow corpus. The README, example/, and the options classes are the documentation — when something surprises you, read the relevant renderer's source for ten minutes rather than searching. Pin the major version in composer.json; v7 is a constructor-named-arguments API and v6's config-array style won't port cleanly if you're copying snippets from older material.
How can I help you explore Laravel packages today?