Double is a new test double library for PHP by Jason McCreary, the creator of Laravel Shift. It requires PHP 8.3 and sits at v0.4.0. The core idea: instead of choosing between a mock, a spy, and a partial up front, you create one kind of object with Double::for() and let the verbs you call afterward define its behavior. The article walks through the API, contrasts it with Mockery, and explains why each design decision was made.
Double::for() accepts classes, interfaces, several interfaces at once (everything after the first must be an interface, mirroring PHP's intersection-type rule), or a real instance. The returned object is a genuine instanceof match for its target, so it satisfies any type hint. Every double runs in exactly one of three modes: loose (the default) returns type-safe values for unconfigured calls — false for bool, 0 for int, [] for array, the first enum case, the double itself for self, and a freshly generated double for non-nullable class or interface return types (generation goes only one level deep, after which you're told to configure explicitly). Strict mode throws on the first unconfigured call. Passthru delegates unconfigured calls to a real instance while still recording everything.
Configuration happens on the double itself, so seven method names are reserved: expects, allows, strict, passthru, received, unused, and verify. Doubling a class that declares one of them (relevant in Laravel, where allows() is part of the Gate contract) throws immediately and names the collision.
The two setup verbs are expects() — the call must happen exactly once by default — and allows(), which permits any number of calls including zero. Modifiers read left to right: with() for arguments, returns() (multiple values build a queue, holding the last), throws(), and resolves() for closures. Call counts all run through times(): times(2), times(5), times(1, 3) for ranges, and times(minimum: 2) / times(maximum: 5) via named arguments; never() stays as its own method. The same counts work on received() after the fact. When two expectations could match one call, the most recently registered wins, so you write broad defaults first.
Compared with a Mockery test, four things change: no mock()/spy() decision, since received() works on any double; once() disappears because expects() already means exactly one call; shouldReceive()/andReturn() become expects()/returns() with no aliases; and Mockery::close() is replaced by $double->verify() or the automatic VerifiesDoubles trait. Failure output improves too — Double names the doubled class instead of a Mockery_0_ identifier and prints the actual call log alongside an unmet expectation. Mockery's shouldNotHaveBeenCalled() only checks whether the mock was invoked as a callable, while Double's unused() asserts a double received zero calls to any method and lists whatever it did see.
Argument matching in with() compares scalars and arrays with === and objects with ==. Looser matching goes through the Argument facade: Argument::any(), type(), same() (identity), matches() (regex), contains(), remaining() (variadic tail), capture($var) to grab the real argument into a variable, and not(), which returns an object exposing its own verbs so negations read fluently. Expectations can be marked ordered() for call-order checks, enforced per double; an early call throws immediately naming both methods. Static methods cannot be configured — the library rejects them up front with an explanation.
For verification, verify() checks every registered expects(). received() chains evaluate once the statement completes, since the library can't know whether you'll chain ->with() or ->never(). Failure messages name the double, the call, and a suggested fix: a typo'd method name is caught at configuration time with a "did you mean" hint, strict-mode failures show the allows() line that would fix them (or the actual call log if the method was invoked elsewhere), and loose-mode-generated doubles identify themselves in their own messages.
With PHPUnit installed (versions 11 and 12 supported, detected at runtime), failures extend AssertionFailedError so unmet expectations report as failures rather than errors, passing verifications count as real assertions, and the VerifiesDoubles trait automatically verifies every double created during a test. Nothing in the library requires PHPUnit, though.
For migration, the documentation includes a method-by-method Mockery mapping: mock() becomes Double::for(), spies become received(), shouldIgnoreMissing() is the default loose behavior, shouldDeferMissing() becomes passthru($realInstance), shouldReceive('foo')->once()->andReturn($x) becomes expects('foo')->returns($x), and andThrow()/andReturnUsing() become throws()/resolves(). Matchers map one-to-one: Mockery::on() to Argument::satisfies(), isSame() to same(), andAnyOtherArgs() to remaining(). Some Mockery features are deliberately absent — aliases, ducktype(), static method mocking, globally() ordering, and byDefault() — each with a documented alternative. A free Double Converter tool on laravelshift.com automates the conversion.
Installation is composer require --dev jasonmccreary/double, with no service provider or config file, so it works in PHPUnit or Pest suites inside or outside Laravel. To double final classes, call Double::bypassFinals() as the first line of your PHPUnit bootstrap, ahead of the autoloader — it rewrites the final keyword before compilation, but classes already loaded elsewhere stay rejected. Full docs live at testdoublephp.com; the code is on GitHub under jasonmccreary/double.
Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.