framework #61770: [13.x] Allow fake assertions to accept an array of properties
Contributed by jackbayliss
Part of the framework v13.35.0 release
Laravel's testing fakes just got a more ergonomic way to assert on dispatched work. Pull request #61770, merged into the 13.x branch after v13.34.0, lets you pass a plain array of expected properties to the fake assertion methods instead of writing a closure for every check. If you're upgrading from v13.34.0, this is a purely additive change: your existing closure-based assertions keep working exactly as before, but you can now replace much of that boilerplate with a one-line array.
What Changed
A new Illuminate\Support\Testing\Fakes\MatchesProperties trait has been added, and it is now used by the four testing fakes: QueueFake, BusFake, EventFake, and NotificationFake. The trait provides a single resolveTruthTest() method: when the callback argument is an array (and not callable), the framework builds the truth test for you and compares each key against the corresponding property on the dispatched object.
The method signatures have been widened accordingly, from callable|int|null to callable|array<string, mixed>|int|null. In practice, this covers the assertion and inspection methods you already use, including:
QueueFake:assertPushed,assertPushedOn,assertNotPushed, andpushed()BusFake:assertDispatched,assertNotDispatched,dispatched(),dispatchedSync(), anddispatchedAfterResponse()EventFake:assertDispatched,assertNotDispatched, anddispatched()NotificationFake:assertSentToand friends, which all route throughsent()
Why It Matters
Until now, asserting on a job's state required a closure, with the inevitable use (...) imports and manual property comparisons:
Queue::assertPushed(ProcessPodcast::class, function (ProcessPodcast $job) use ($podcast, $user) {
return $job->podcast->id === $podcast->id
&& $job->user->id === $user->id
&& $job->status === 'pending';
});
That's fine once, but painful when a test suite asserts on a dozen similar jobs. The same assertions can now be written as:
Queue::assertPushed(ProcessPodcast::class, [
'podcast' => $podcast,
'user' => $user,
'status' => 'pending',
]);
Queue::assertNotPushed(ProcessPodcast::class, ['status' => 'complete']);
The same array style works across the other fakes, so mixed assertions stay consistent:
Bus::assertDispatched(ShipOrder::class, ['status' => 'paid']);
Event::assertDispatched(OrderPaid::class, ['order' => $order]);
Notification::assertSentTo($user, OrderShipped::class, ['status' => 'sent']);
assertPushedOn also benefits: it resolves the array against the job's properties while still checking the queue name, so you no longer need a closure that inspects $pushedQueue manually:
Queue::assertPushedOn('high', ProcessPodcast::class, ['status' => 'pending']);
How the Matching Works
A few semantics are worth knowing so your assertions behave predictably:
- All keys must match. Every entry in the array is checked, and the truth test only passes if every property matches (the framework builds this with
Collection::every()). - Comparisons are strict. Ordinary values are compared with
===, so'1'will not match1. - Eloquent models use
is(). When both the expected and actual values are models, they're compared withis()rather than===. That means two separate instances of the same database record match, which is exactly what you want when the job received a re-fetched copy of your model:
Queue::assertPushed(ProcessVideo::class, ['user' => $users[0]]);
Queue::assertPushed(ProcessVideo::class, ['user' => $users[1]]);
- Missing properties never match. If the dispatched object doesn't have the property at all, the check fails — even if you expected
null. However, expecting'property' => nulldoes match an object where that property exists and is genuinelynull. This distinction keeps anullexpectation from silently passing against a typo'd property name. - Closures still win. Because the trait only treats non-callable arrays as property maps, any existing closure or invokable callback continues to work unchanged, and integer times arguments are unaffected.
Upgrade Impact
There is no required action when moving from v13.34.0. This change is additive: no existing signatures were narrowed, no methods were removed, and the only new behavior activates when you pass an associative array where you previously would have passed a closure. Your current test suite should run without modification.
If you adopt the array style, a couple of small gotchas apply: stick to strict-compatible expected values (remember === semantics), and double-check that the properties you name actually exist on the job, event, or notification — a missing property quietly fails the match rather than erroring, which is safe but can be confusing if you typo a key. When you need logic beyond direct property comparison — computed values, ranges, or cross-property conditions — drop back to a closure, which remains fully supported.
Takeaways
- Fake assertions on
Queue,Bus,Event, andNotificationfakes now accept an array of property expectations as an alternative to closures, powered by a newMatchesPropertiestrait. - Matching is strict (
===) for scalars and usesis()for Eloquent models, so different instances of the same record compare correctly. - All array keys must match, and a named property must exist on the object; expecting
nullonly matches a property that exists and isnull. - Closures, invokable callbacks, and integer times arguments are untouched, so upgrading from v13.34.0 requires no changes to existing tests — the array syntax is simply available when you want it.