What Is the Double Library?
Double is a PHP 8.3 test double library by Jason McCreary, the creator of Laravel Shift. Instead of choosing between a mock, a spy, and a partial at creation time, you create one kind of object — a double — and the verbs you use afterward determine how it behaves. The package is at v0.4.0 and works with PHPUnit 11 and 12 as well as Pest.
Install it as a dev dependency:
composer require --dev jasonmccreary/double
No service provider or configuration file is needed.
Creating a Double
Double::for() accepts a class name, an interface, multiple interfaces, or a real instance:
use JMac\Testing\Double;
$repository = Double::for(BookRepository::class);
$logger = Double::for(LoggerInterface::class, FlushableInterface::class);
The returned object satisfies instanceof and any type hint expecting the target, so it drops straight into a constructor without casting.
Three Modes
| Mode | Behaviour on unconfigured calls |
|---|---|
| Loose (default) | Returns a type-safe value (false, 0, [], a fresh double, etc.) |
| Strict | Throws immediately |
| Passthru | Delegates to a real instance and still records every call |
$repository = Double::for(BookRepository::class)->strict();
$logger = Double::for(Logger::class)->passthru($realLogger);
Expectations and Argument Matching
Two verbs cover all setup: expects() (must be called) and allows() (may be called).
$repository->expects('find')->with(123)->returns($book);
$repository->allows('find')->with(999)->throws(new NotFoundException());
$repository->allows('calculateTax')->resolves(fn (...$args) => $gateway->calculateTax(...$args));
Call counts use a single times() method with named arguments instead of Mockery's chained helpers:
$repository->expects('save'); // exactly once
$repository->expects('save')->times(2); // exactly twice
$repository->expects('save')->times(1, 3); // between 1 and 3
$repository->expects('save')->times(minimum: 2); // at least 2
$repository->allows('save')->times(maximum: 5); // at most 5
$repository->allows('save')->never(); // zero calls
The Argument facade handles looser matching:
use JMac\Testing\Matching\Argument;
$repository->allows('save')->with(Argument::type(Book::class))->returns(true);
$repository->allows('find')->with(Argument::any(1, 2, 3))->returns($book);
$repository->allows('saveAll')->with(Argument::contains($book))->returns(true);
Argument::capture($var) writes the real argument into a variable for further assertions. Argument::not() negates any matcher without nesting.
Verification
Call verify() at the end of a test, or add the VerifiesDoubles trait to your base test case and let it run automatically:
use JMac\Testing\Integrations\PHPUnit\VerifiesDoubles;
class TestCase extends \PHPUnit\Framework\TestCase
{
use VerifiesDoubles;
}
received() checks after the fact on any double — no spy declaration required:
$repository->received('recordView')->with($book);
$repository->received('save')->times(2);
$repository->received('delete')->never();
unused() asserts a double received zero calls to any method and lists every call it actually saw.
Failure Messages
Double names the class you doubled (not a generated identifier), and on an unmet expectation it lists what the method was actually called with:
Double `foo` expected `find('baz')` to be called exactly 1 time, but it was never called.
The following calls to `find` were made during this test: `find('Baz')`
A typo in a method name is caught at configuration time, not at the end of the test, and a "did you mean" suggestion is included when something close exists.
Migrating From Mockery
The docs include a full method-by-method mapping. The most common conversions:
Mockery::mock(Foo::class)→Double::for(Foo::class)shouldReceive('foo')->once()->andReturn($x)→expects('foo')->returns($x)shouldHaveReceived('foo')→received('foo')Mockery::close()→verify()or theVerifiesDoublestrait
A free Double Converter from Laravel Shift automates the migration.
Key Takeaways
- One constructor (
Double::for()) replaces mock, spy, and partial decisions. expects()andallows()are the only setup verbs;times()with named arguments handles all count shapes.- Loose mode returns type-safe defaults so tests don't break on unconfigured calls.
received()provides spy-style post-hoc assertions on every double.- PHPUnit integration turns failures into proper assertion failures and the
VerifiesDoublestrait removes manualverify()calls. - Requires PHP 8.3; works inside or outside Laravel with PHPUnit 11/12 or Pest.
Full documentation is at testdoublephp.com.
Source: Mock PHP Classes in Tests With the Double Library — Laravel News