sebastian/comparator
Compare PHP values for equality with dedicated comparators for scalars, arrays, objects, dates, and more. Part of PHPUnit, it reports detailed differences via ComparisonFailure exceptions and ships a Factory for selecting the right comparator.
sebastian/comparator almost always arrives transitively with PHPUnit — check before adding it explicitly:
composer why sebastian/comparator
composer show sebastian/comparator # note the resolved version
Adding it as a direct dependency is only worth it when you also want to use it outside assertions (diffing payloads, snapshot checks, domain-level equality). If you do require it, pin nothing looser than PHPUnit's own constraint or Composer will downgrade one of them:
composer require --dev sebastian/comparator
Where to look first: SebastianBergmann\Comparator\Factory (the dispatcher), Comparator (the base class with accepts() / assertEquals()), and ComparisonFailure (the exception carrying expected/actual/diff). The built-in comparator list is essentially your feature map: scalars, numerics, arrays, objects, DateTime, DateInterval, DOMNode, SplObjectStorage, enums, BcMath\Number, closures, mocks.
First use case — timezone-safe date assertions, which is where naive assertEquals on formatted strings bites you:
use SebastianBergmann\Comparator\Factory;
use SebastianBergmann\Comparator\ComparisonFailure;
$factory = new Factory; // private instance: no global state
$comparator = $factory->getComparatorFor($expected, $actual);
try {
// $delta in seconds for date types
$comparator->assertEquals($expected, $actual, delta: 0.0);
} catch (ComparisonFailure $failure) {
report($failure->getMessage());
}
In a Laravel test the same thing is already happening behind $this->assertEquals(), so the fastest win is understanding which comparator your assertion routes to — that explains most "why is this passing/failing?" moments.
Comparator::assertEquals($expected, $actual, $delta = 0.0, $canonicalize = false, $ignoreCase = false) maps directly onto PHPUnit's API. Use them rather than sort()-ing arrays yourself:
// Order-agnostic API payload check
$this->assertEquals($expectedJson, $response->json(), canonicalize: true);
// Float tolerance
$this->assertEquals(0.1 + 0.2, 0.3, 0.000001);
// Case-insensitive slug/header comparison
$this->assertEquals('application/json', $header, ignoreCase: true);
ObjectComparator walks public/accessible properties recursively. Eloquent models carry $original, $changes, $relations, $connection, the resolver, and event dispatcher — comparing two models directly produces noise or false negatives. Compare the serialized shape:
$this->assertEquals(
User::factory()->make(['id' => 1])->toArray(),
$model->fresh()->toArray(),
canonicalize: true
);
If you genuinely need model-level equality (identity + dirty state), that is the classic case for a custom comparator (below) rather than a raw object diff.
PHPUnit resolves comparators through Factory::getInstance(), so register() on the singleton makes your comparator apply to every assertEquals() in the suite:
// tests/TestCase.php
protected function setUp(): void
{
parent::setUp();
Factory::getInstance()->register(new MoneyComparator);
}
final class MoneyComparator extends Comparator
{
public function accepts(mixed $expected, mixed $actual): bool
{
return $expected instanceof Money && $actual instanceof Money;
}
public function assertEquals(
mixed $expected, mixed $actual, float $delta = 0.0,
bool $canonicalize = false, bool $ignoreCase = false
): void {
if ($expected->getCurrency() !== $actual->getCurrency()
|| abs($expected->getAmount() - $actual->getAmount()) > $delta) {
throw new ComparisonFailure(
$expected, $actual,
(string) $expected, (string) $actual,
'Failed asserting that two Money values are equal.'
);
}
}
}
Key details: overriding accepts() with a narrow instanceof check, and preferring to extend ObjectComparator/ArrayComparator when your type contains nested structures — the base implementations delegate child comparisons back through $this->getFactory(), which register() wires up for you.
ComparisonFailure as a diff engine, not just an exceptionFor non-assertion contexts — contract tests writing artifacts, "why did this job payload change?" investigations — catch the failure and pull the diff:
try {
$factory->getComparatorFor($before, $after)->assertEquals($before, $after, canonicalize: true);
} catch (ComparisonFailure $failure) {
Storage::disk('local')->put(
'diffs/job-payload-'.now()->timestamp.'.diff',
$failure->getDiff()
);
}
getExpectedAsString(), getActualAsString(), getDiff() and showDiff() are the useful members; guard getDiff() with showDiff() because a diff is not always produced.
Keep the package out of application code and behind one helper or expectation so the team standardizes on it:
// tests/Pest.php
expect()->extend('toEqualCanonicalized', fn ($expected) =>
$this->toBeInstanceOf(...)->and(...) // or fall through to assertEquals
);
More practically, define a trait method assertComparable($expected, $actual, float $delta = 0) that resolves a private Factory, so behaviour is identical everywhere and you never depend on global registration.
Several Laravel-flavoured pain points only exist in older versions — check the resolved version before writing a workaround:
8.1.2 — arrays of equal objects no longer sorted by spl_object_id()8.1.1 — deterministic sort for mixed-type arrays8.2.0 — DateIntervalComparator, XML diff fixes (comments, C14N failures, non-ASCII), serializable ComparisonFailure, DateTimeComparator pair tracking in nested structures8.2.1 — closure comparison by value, canonicalized string keys preservedIf you're on a lower major, write the workaround; if you can bump PHPUnit (and therefore the comparator), delete the workaround.
sebastian/comparator 8.x requires PHP 8.4+ and drops 8.3; a Laravel app on PHP 8.2/8.3 will resolve to 6.x/7.x. So "we upgraded the comparator" really means "we upgraded PHP + PHPUnit." Never add a direct constraint that fights PHPUnit's.assertEquals is loose. ScalarComparator/NumericComparator accept 1 vs '1' and '1' vs 1.0. This is the single biggest source of "test passed but shouldn't have." Use assertSame for scalars and reserve assertEquals for structured data.canonicalize: true throws away order. It is correct for unordered sets/JSON payloads and wrong for ordered lists (queue order, sequence columns). Also remember it sorts recursively, so a nested ordered array gets silently reordered too.Factory::getInstance() is global and sticky. A broad accepts() will shadow every built-in comparator for the rest of the process, including inside PHPUnit itself. Keep custom comparators narrow, and restore state in tearDown() (Factory::reset()) if you register per-test.delta is comparator-specific. Absolute numeric tolerance for numbers, seconds for DateTime/DateInterval. Don't assume one meaning across types.04:13:35 America/New_York equals 03:13:35 America/Chicago. That's usually what you want from a Carbon assertion on an API response, but not what a "same wall clock in the user's timezone" test wants — assert the formatted string there instead.Carbon to a string never works; compare Carbon to Carbon (or to a DateTimeInterface) so DateTimeComparator engages.ComparisonFailure serialization broke when a stack trace held non-serializable objects (fixed 8.2.0). Relevant if you serialize failures into a queued report, cache, or a data provider.assertJsonStructure, specific keys, counts) or compare hashes; save full deep-diffs for small fixtures.$failure->getExpectedAsString(), getActualAsString() and getDiff() to artifacts — the diff (context lines configurable since 8.1.0, and strict unified output since 8.1.4) is far more actionable than the truncated PHPUnit message.Factory::register() is the whole story — no config file, no service provider, no container binding. Treat it as dev-only tooling and keep it out of app/.composer show sebastian/comparator in your pipeline; mysterious pass/fail flips after a PHPUnit bump are usually a comparator major changing array/object semantics.How can I help you explore Laravel packages today?