Small interfaces
UserRepository has ten methods. PasswordReset uses two of them. To test it with a fake (4.3), you write a class with ten methods: two that work, and eight that throw new LogicException('not needed').
Those eight are the interface telling you it's the wrong size. And they're not free: when someone adds an eleventh method to UserRepository, every implementation and every fake has to grow one, including the ones that will never call it.
Interfaces belong to the client
It's natural to write an interface from the implementation's side: here's everything the database table can do. But an interface is a promise to the code that uses it, and each user needs a different slice.
| Client | Needs |
|---|---|
PasswordReset |
findByEmail(), save() |
GdprErasure |
findByEmail(), anonymise() |
| The admin search page | search(), countActive() |
Give a client an interface sized for its job, and name it for that role. This is the interface segregation principle: no client should depend on methods it doesn't use.
interface UserAccounts { public function findByEmail(string $email): ?User; public function save(User $user): void; }
PasswordReset asks for UserAccounts. Its fake has two methods, its signature says exactly what it does with users, and nobody changing search() ever has to look at it.
Splitting without breaking anything
You don't need to touch the implementations. Make the big interface extend the small one:
interface UserRepository extends UserAccounts { // the other eight methods }
Every class that implements UserRepository now implements UserAccounts too, so the composition root can still pass a DatabaseUserRepository to PasswordReset. Do the same for the next client when it needs a fake, one role at a time.
Power-up
<?php // Ten methods, to test a class that calls two final class FakeUsers implements UserRepository { public function findByEmail(string $email): ?User { /* … */ } public function save(User $user): void { /* … */ } public function find(int $id): ?User { throw new LogicException('not needed'); } public function all(): array { throw new LogicException('not needed'); } public function search(string $query, int $limit): array { throw new LogicException('not needed'); } // …and five more }
<?php interface UserAccounts { public function findByEmail(string $email): ?User; public function save(User $user): void; } interface UserRepository extends UserAccounts { /* the other eight */ } final readonly class PasswordReset { public function __construct(private UserAccounts $users) {} // a two-method fake will do }