Interfaces with constructors
PHP sometimes allows us to do things that we shouldn't do. That's why we often have a discussion with other developers who tell us: "if PHP allows me to do it, then I'll do it". Here it helps to have good arguments as to why you shouldn't do something.
A very good example of this is the question of whether we should declare constructors in an interface. Technically, this is possible in the first place:
<?php declare(strict_types=1);
interface SomeInterface
{
public function __construct(int $a, string $b);
public function doWork(): void;
}
If we want to implement this interface, then we have to implement both the constructor and the doWork()
method:
<?php declare(strict_types=1);
class SomeImplementation implements SomeInterface
{
private int $a;
private string $b;
public function __construct(int $a, string $b)
{
$this->a = $a;
$this->b = $b;
}
public function doWork(): void
{
var_dump('doing work');
}
}
Now we can instantiate the object:
<?php declare(strict_types=1);
require __DIR__ . '/autoload.php';
$object = new SomeImplementation(1, 'the-string');
$object->doWork();
SomeImplementation.php:16: string(10) "doing work"
What is the problem now? Does everything work?
Loose coupling
The problem is that interfaces are there to achieve loose coupling. Instead of making myself dependent on a concrete class, I only make myself dependent on an interface. This is why we normally use interfaces as type hints instead of concrete classes. An important exception to this rule are value objects, for which we generally do not define interfaces.
When we declare a constructor method in an interface, we are cementing assumptions about how an object must be created. In the example above, this is an integer as the first parameter and a string as the second parameter.
Normally PHP is relatively relaxed when we change the signature of a constructor:
<?php declare(strict_types=1);
class BaseClass
{
private int $a;
private string $b;
public function __construct(int $a, string $b)
{
$this->a = $a;
$this->b = $b;
}
}
class ChangingSignature extends BaseClass
{
private array $x;
private float $y;
public function __construct(array $x, float $y)
{
$this->x = $x;
$this->y = $y;
}
}
var_dump(new BaseClass(1, 'the-string'));
var_dump(new ChangingSignature([], 1.5));
Here, the subclass declares a completely different constructor signature than the base class. Nevertheless, this works without PHP complaining:
changingSignature.php:27: class BaseClass#1 (2) { private int $a => int(1) private string $b => string(10) "the-string" } changingSignature.php:28: class ChangingSignature#1 (4) { private int $a => *uninitialized* private string $b => *uninitialized* private array $x => array(0) { } private float $y => double(1.5) }
Of course, you can discuss the extent to which the subclass in this example makes sense in terms of content, but that's not the point here. Instead, we ask ourselves whether this changing of the constructor signature still works if we have already defined the constructor in the interface?
<?php declare(strict_types=1);
class SomeOtherImplementation implements SomeInterface
{
private array $x;
private float $y;
public function __construct(array $x, float $y)
{
$this->x = $x;
$this->y = $y;
}
public function doWork(): void
{
var_dump('doing work');
}
}
Let's try our luck:
<?php declare(strict_types=1);
require __DIR__ . '/autoload.php';
$object = new SomeOtherImplementation([], 1.5);
$object->doWork();
PHP Fatal error: Declaration of SomeOtherImplementation::__construct(array $x, float $y) must be compatible with SomeInterface::__construct(int $a, string $b) in SomeOtherImplementation.php on line 8 PHP Stack trace: PHP 1. {main}() createOtherObject-will-fail.php:0 PHP 2. {closure:autoload.php:6-27}($class = 'SomeOtherImplementation') createOtherObject-will-fail.php:5 PHP 3. require() autoload.php:25
Of course, this does not work because the object SomeOtherImplementation
does not fulfill the interface. Depending on the configuration, the IDE has already told us this:
So we restrict ourselves unnecessarily when we make a constructor method part of an interface. We could also say that object creation is a "separate concern".
Different implementations
It is usually the case that the different implementations of an interface have different dependencies. As an example, let's take an interface with which we can load Something
from somewhere:
<?php declare(strict_types=1);
interface SomethingReaderInterface
{
public function findById(string $id): Something;
}
If we want to load Something
from a database, we need a database connection. So:
<?php declare(strict_types=1);
class DatabaseSomethingReader implements SomethingReaderInterface
{
public function __construct(
private readonly DatabaseConnection $db
) {}
public function findById(string $id): Something
{
return new Something($this->db->query('...'));
}
}
Another reader, on the other hand, would have completely different dependencies:
<?php declare(strict_types=1);
class HttpSomethingReader implements SomethingReaderInterface
{
public function __construct(
private readonly HttpClient $http
) {}
public function findById(string $id): Something
{
return new Something($this->http->get('...'));
}
}
To make it really clear that the two readers are interchangeable, let's build a piece of code that randomly uses both of them alternately:
<?php declare(strict_types=1);
require __DIR__ . '/autoload.php';
$db = new DatabaseConnection;
$readers[] = new DatabaseSomethingReader($db);
$http = new HttpClient;
$readers[] = new HttpSomethingReader($http);
for ($i = 0; $i < 5; $i++) {
var_dump($readers[random_int(0, 1)]->findById('the-id'));
}
readSomething.php:12: class Something#6 (1) { private readonly array $state => array(0) { } } readSomething.php:12: class Something#6 (1) { private readonly array $state => array(0) { } } readSomething.php:12: class Something#6 (1) { private readonly array $state => array(0) { } } readSomething.php:12: class Something#6 (1) { private readonly array $state => array(0) { } } readSomething.php:12: class Something#6 (1) { private readonly array $state => array(0) { } }
If you now say that we can no longer see from which reader each Something
was read: that's exactly the joke. This is exactly the loose coupling we wanted to achieve here.
So if we declare a constructor in the interface, we make life difficult for ourselves because we prevent ourselves from creating different implementations of this interface, each with different dependencies. Any attempt to save this, for example by not doing the dependency injection via the constructor or by defining a signature that wildly combines different dependencies as optional parameters, is impractical.
Conclusion: If we want loose coupling and therefore use an interface, then this interface must not make any restrictions with regard to object creation.