objects namespaces and application architecture

Covariance and Contravariance

Covariance and contravariance describe which type changes are allowed when a child class overrides a parent method or implements an interface.

The short version for PHP is:

  • return types may become more specific in the child method
  • parameter types may become less specific in the child method

These rules preserve substitutability. If code expects the parent type, it must still be safe to use the child type in its place.

Covariant Return Types

Covariance lets a child method return a more specific type than the parent method promised.

PHP example
<?php

declare(strict_types=1);

class Response
{
    public function body(): string
    {
        return 'response';
    }
}

final class JsonResponse extends Response
{
    public function body(): string
    {
        return '{"ok":true}';
    }
}

class Controller
{
    public function show(): Response
    {
        return new Response();
    }
}

final class ApiController extends Controller
{
    public function show(): JsonResponse
    {
        return new JsonResponse();
    }
}

$controller = new ApiController();

echo $controller->show()->body() . PHP_EOL;

// Prints:
// {"ok":true}

Controller::show() promises a Response. ApiController::show() returns a JsonResponse, which is still a Response, so callers relying on the parent contract are safe.

Contravariant Parameter Types

Contravariance lets a child method accept a broader parameter type than the parent method required.

PHP example
<?php

declare(strict_types=1);

class User
{
    public function __construct(
        public string $email,
    ) {
    }
}

final class AdminUser extends User
{
}

class AdminNotifier
{
    public function notify(AdminUser $user): string
    {
        return 'Admin notice for ' . $user->email;
    }
}

final class AnyUserNotifier extends AdminNotifier
{
    public function notify(User $user): string
    {
        return 'User notice for ' . $user->email;
    }
}

$notifier = new AnyUserNotifier();

echo $notifier->notify(new AdminUser('admin@example.com')) . PHP_EOL;

// Prints:
// User notice for admin@example.com

The parent requires an AdminUser. The child accepts any User, which includes admin users. That is safe because code that calls the method with an AdminUser still works.

Why Narrower Parameters Are Unsafe

If a child method required a more specific parameter than the parent, code using the parent contract could break.

Imagine a parent method accepts any User, but the child only accepts AdminUser. Code that correctly passes a normal User to the parent contract would fail with the child. PHP rejects that kind of override.

The rule protects callers.

Interfaces Follow The Same Rules

The same rules apply when implementing interfaces.

PHP example
<?php

declare(strict_types=1);

interface ReportFactory
{
    public function create(): Report;
}

class Report
{
}

final class SalesReport extends Report
{
}

final class SalesReportFactory implements ReportFactory
{
    public function create(): SalesReport
    {
        return new SalesReport();
    }
}

$factory = new SalesReportFactory();

echo $factory->create()::class . PHP_EOL;

// Prints:
// SalesReport

The interface promises a Report. Returning a SalesReport is allowed because it is more specific.

Practical Review Rule

When checking an override, ask:

  • Does the child return something the parent caller can still use?
  • Does the child accept at least everything the parent promised to accept?

Return values go narrower. Parameters go wider.

What You Should Be Able To Do

After this lesson, you should be able to explain that covariant returns are more specific, contravariant parameters are less specific, and both rules exist to keep inheritance safe.

For junior work, the practical skill is understanding why a method signature override is accepted or rejected by PHP, especially when working with interfaces, framework base classes, and static analysis errors.

Practice

Practice: Override Method Types Safely

Create a small PHP example that demonstrates one covariant return and one contravariant parameter.

Task

Build:

  • a base Response class and a JsonResponse subclass
  • a base controller method that returns Response
  • a child controller method that returns JsonResponse
  • a base notifier method that accepts a specific user subtype
  • a child notifier method that accepts a broader user type

Use strict types. Keep the expected output in the PHP code block as printed lines or comments.

Check Your Work

Confirm:

  • the child controller return type is more specific
  • the child notifier parameter type is broader
  • both examples still satisfy the parent contract

Afterward, explain why narrowing a parameter type in the child method would be unsafe.

Show solution

This solution uses a more specific return type in the child controller and a broader parameter type in the child notifier.

PHP example
<?php

declare(strict_types=1);

class Response
{
    public function body(): string
    {
        return 'response';
    }
}

final class JsonResponse extends Response
{
    public function body(): string
    {
        return '{"ok":true}';
    }
}

class Controller
{
    public function show(): Response
    {
        return new Response();
    }
}

final class ApiController extends Controller
{
    public function show(): JsonResponse
    {
        return new JsonResponse();
    }
}

class User
{
    public function __construct(
        public string $email,
    ) {
    }
}

final class AdminUser extends User
{
}

class AdminNotifier
{
    public function notify(AdminUser $user): string
    {
        return 'Admin notice for ' . $user->email;
    }
}

final class AnyUserNotifier extends AdminNotifier
{
    public function notify(User $user): string
    {
        return 'User notice for ' . $user->email;
    }
}

$controller = new ApiController();
$notifier = new AnyUserNotifier();

echo $controller->show()->body() . PHP_EOL;
echo $notifier->notify(new AdminUser('admin@example.com')) . PHP_EOL;

// Prints:
// {"ok":true}
// User notice for admin@example.com

Narrowing a child parameter would be unsafe because code using the parent contract might pass a value the parent accepts, only for the child implementation to reject it.