Named arguments. A new way to break LSP in PHP.

Victor Todoran
4 min readJan 8, 2023

--

A photo of Barbara Liskov. source: https://www.freedomology.org/barbara-liskov/

With the introduction of named arguments in PHP8, method parameter names are now part of the method’s contract, which means that any implementors or children can not change parameter names of the methods they are implementing/overriding without breaking Liskov’s Substitution Principle.

Let’s consider an example. Symfony offers a ConstraintValidatorInterface which you can implement to add new ‘constraint’ validations to your project. You can read more about it here, but for the purpose of this article you really don’t have to.

Before we go further. Please don’t get stuck on the particulars of the example. If you are working with any decent real life application, chances are you need to implement an Interface at some point and this ValidatorInterface is just an example of how that Interface could look like.

Here is a oversimplified version of the ConstraintValidatorInterface:

interface ConstraintValidatorInterface
{
public function validate(mixed $value): void;
}

Now the reason $value is of type mixed is to facilitate the Open/Closed Principle. You can configure new Validators and they will be used everywhere the validation is made, without needing to actually update those places (OCP). But it is the responsibility of the Validators to make sure the object they are validating is of the correct type.

Let us assume that our application deals with Invoices, an Invoice has a start and an end date as well as a createdAt date.

class Invoice
{
public function __construct(
public readonly Money $invoiceTotal
public readonly DateTimeImmutable $startDate
public readonly DateTimeImmutable $endDate
public readonly DateTimeImmutable $createdAt
) {}
}

For an Invoice to be considered ‘valid’, the startDate can not be greater than the endDate and createdAt can not be in the future.

Let’s add an InvoiceValidator for it:

class InvoiceValidator implements ConstraintValidatorInterface
{
// ...

public function validate(mixed $value): : void
{
if (!$value instanceOf Invoice) {
return;
}

if ($value->startDate > $value->endDate) {
$this->addNewViolation('startDate can\'t be gt endDate');
}

if ($value->createdAt > new DateTimeImmutable()) {
$this->addNewViolation('createdAt can not be in the future');
}
}
}

At this point you, or maybe a collegue, rightfully so, might say that having the parameter name $value is a little weird, $invoice would be so much better. So you decide to change it.

class InvoiceValidator implements ConstraintValidatorInterface
{
// ...

public function validate(mixed $invoice): : void
{
if (!$invoice instanceOf Invoice) {
return;
}

if ($invoice->startDate > $invoice->endDate) {
$this->addNewViolation('startDate can\'t be gt endDate');
}

if ($invoice->createdAt > new DateTimeImmutable()) {
$this->addNewViolation('createdAt can not be in the future');
}
}
}

You have just, potentially, broken Liskov’s Substitution Principle. The InvoiceValidator can no longer substitute the Interface it implements if the consumer is calling the validate method using named arguments.

Consider this consumer code:

class ImportInvoiceEventSubscriber
{
public function __construct(
private readonly ValidatorInterface $invoiceValidator
) {}

public function onImportInvoice(Invoice $importedInvoice): void
{
$this->invoiceValidator->validate(value: $importedInvoice);

// some more code
}
}

This code will break if you pass the second implementation of the InvoiceValidator to the ImportInvoiceEventSubscriber. And there is nothing wrong with the Event Subscriber, which is well within the constraints of the language. The problem lies with the InvoiceValidator.

Any implementor of the ConstraintValidatorInterface MUST BE able to substitute the interface without causing the program to explode as a result, in any scenario.

In order to mitigate this you might adopt a convention to not use named arguments inside your application code. This does not solve the problem entirely, because the consumer of your code (in our case the EventSubscriber) could live in a 3rd party package your are using, case in which you have no control over it.

There is only one solution, in order to be on the safe side, don’t change the parameter names of the methods your implementing/overriding.

For our scenario, a solution that addresses readability without breaking Liskov might look something like this:

class InvoiceValidator implements ConstraintValidatorInterface
{
// ...

public function validate(mixed $value): : void
{
if (!$value instanceOf Invoice) {
return;
}

$invoice = $value;
if ($invoice->startDate > $invoice->endDate) {
$this->addNewViolation('startDate can\'t be gt endDate');
}

if ($invoice->createdAt > new DateTimeImmutable()) {
$this->addNewViolation('createdAt can not be in the future');
}
}
}

Hopefully I’ve managed to convince you not to change the parameter names of methods you are implementing/overriding inside your PHP applications.

That’s it, thanks for reading!

Disclaimer: Consider this to be a living document, which means it’s subject to undocumented changes and it might even die in the future.

--

--

Victor Todoran

I write, read and think about software and its users for a living