Creating a double
use Rasuvaeff\Understudy\Understudy;
$repository = Understudy::for(BookRepository::class);for() returns the contract's own type, so your IDE and your static analyser treat $repository as a BookRepository. It carries no members of its own — see What is understudy for why.
Combining contracts
Several interfaces can be doubled at once:
$double = Understudy::for(BookRepository::class, Countable::class);Understudy unifies compatible signatures across them:
| Parameter types | widened |
| Return types | the narrowest compatible declaration, or a synthesised interface intersection |
| Named arguments | follow the first (primary) interface |
Static contract methods exist on the generated class, because the interface has to be implemented — but calling one raises InvalidCallSpecification. A static call has no double instance to own its state.
Doubling a class
A class can be the first target, with interfaces after it:
$repository = Understudy::for(DoctrineBookRepository::class, Countable::class);What a class double does and does not do:
| The target's constructor | never runs — the double is built without it, so no side effect of construction reaches your test |
| Public and protected methods | overridden and dispatched; a protected one shows up in the transcript and under strict mode, but PHP's own visibility keeps it out of a specification closure |
| Private and static methods | untouched — the target keeps them, because there is no instance state to intercept |
| The destructor | replaced with an empty one, so nothing is torn down that was never built |
| Writable public properties | start at an empty value of their type; object-typed, hooked, final, readonly and private(set) ones are left uninitialized, and reading one raises PHP's own error |
clone | produces a double of its own: same contracts, no expectations, no call log, owned by the context that cloned it |
A readonly target produces a readonly double, which PHP requires and which costs nothing — the double declares no properties of its own.
What is refused, and why
Some targets are refused before anything is generated, each with the reason and what to do instead:
- a
finalclass — see Doubling a final class - a class with a non-private
finalinstance method - an enum, a trait, an internal class, an anonymous class
- any class that is not the first target
The rule behind all of them is one rule. A double that cannot intercept every method would run the target's real code against an object whose constructor never ran — which is worse than not building the double at all.
Next
- Property hooks — doubling a contract that declares properties, on PHP 8.4+.
- Doubling a final class —
bypassFinals()and its limits. - Stubbing — saying what the double answers.