0

ISP in PHP — PSR interfaces as good examples

Intermediate5 min read·eng-01-009
solidpsr

Concept

PSR (PHP Standard Recommendation) interfaces are arguably the best real-world examples of ISP done correctly. The PHP-FIG (PHP Framework Interoperability Group) designed the PSR interfaces with extreme care specifically to avoid the fat-interface problem. Each PSR interface describes exactly one role, from exactly one caller's perspective, with the minimum number of methods needed to fulfill that role.

PSR-7's RequestInterface separates reading HTTP messages from creating them. PSR-11's ContainerInterface has exactly two methods: get() and has(). PSR-14's EventDispatcherInterface has one method: dispatch(). PSR-15 splits request handling into two interfaces: MiddlewareInterface (processes a request and passes it on) and RequestHandlerInterface (produces a final response). None of these interfaces does more than its role requires.

The reason the PHP community converged on PSR interfaces is precisely because fat interfaces were a major source of incompatibility between frameworks. If ContainerInterface had 20 methods, only containers that implemented all 20 could be swapped. By keeping it at 2 methods, every container from Laravel's to Symfony's to a hand-rolled one can implement it.

Studying PSR interfaces teaches you how to design your own interfaces: identify the role, identify the caller, define the minimum methods that satisfy that caller's needs.

Code Example

php
<?php
declare(strict_types=1);

// PSR-11: ContainerInterface — the minimal contract for a DI container
// Only two methods because that's all a caller ever needs
use Psr\Container\ContainerInterface;

// PSR-11 interface (shown for study):
// interface ContainerInterface {
//     public function get(string $id): mixed;
//     public function has(string $id): bool;
// }

// Any class that only READS from the container gets ContainerInterface
final class ViewFactory
{
    public function __construct(private readonly ContainerInterface $container) {}

    public function make(string $viewClass): View
    {
        if (!$this->container->has($viewClass)) {
            throw new \InvalidArgumentException("View not registered: {$viewClass}");
        }
        return $this->container->get($viewClass);
    }
}

// PSR-3: LoggerInterface — segregated by log level
// (simplified — shows the ISP thinking behind it)
use Psr\Log\LoggerInterface;

// Logger is injected only into classes that need logging
// They don't get the whole container — just the logger interface
final class PaymentProcessor
{
    public function __construct(
        private readonly PaymentGateway  $gateway,
        private readonly LoggerInterface $logger,  // PSR-3 — just logging, nothing else
    ) {}

    public function charge(Order $order): PaymentResult
    {
        try {
            $result = $this->gateway->charge($order);
            $this->logger->info('Payment succeeded', ['order_id' => $order->id]);
            return $result;
        } catch (PaymentException $e) {
            $this->logger->error('Payment failed', [
                'order_id' => $order->id,
                'error'    => $e->getMessage(),
            ]);
            throw $e;
        }
    }
}

// Your own application following PSR ISP lessons

// Segregated notification interfaces — each describes one delivery channel
interface EmailChannel
{
    public function sendEmail(User $user, string $subject, string $body): void;
}

interface SmsChannel
{
    public function sendSms(User $user, string $message): void;
}

interface PushChannel
{
    public function sendPush(User $user, string $title, string $body): void;
}

// Implementation can combine all channels
final class NotificationDriver implements EmailChannel, SmsChannel, PushChannel
{
    public function sendEmail(User $user, string $subject, string $body): void
    {
        Mail::to($user->email)->send(new GenericMail($subject, $body));
    }

    public function sendSms(User $user, string $message): void
    {
        Twilio::message($user->phone, $message);
    }

    public function sendPush(User $user, string $title, string $body): void
    {
        FCM::push($user->push_token, $title, $body);
    }
}

// Password reset service — only needs email, not SMS or push
final class PasswordResetNotifier
{
    public function __construct(private readonly EmailChannel $email) {}

    public function notify(User $user, string $resetUrl): void
    {
        $this->email->sendEmail(
            $user,
            'Reset your password',
            "Click here to reset: {$resetUrl}"
        );
    }
}

// Order shipped — needs both email AND push, but not SMS
final class ShipmentNotifier
{
    public function __construct(
        private readonly EmailChannel $email,
        private readonly PushChannel  $push,
    ) {}

    public function notify(User $user, Order $order): void
    {
        $this->email->sendEmail($user, 'Your order shipped', "Order #{$order->id} is on its way!");
        $this->push->sendPush($user, 'Order shipped!', "Order #{$order->id}");
    }
}

Interview Q&A

Q: How do PSR interfaces demonstrate good ISP design?

PSR-11's ContainerInterface has only get() and has(). PSR-14's EventDispatcherInterface has only dispatch(). PSR-15 splits a single concept (handling HTTP requests) into two interfaces — MiddlewareInterface for processing-and-passing-on, and RequestHandlerInterface for final response generation — because they serve different roles. The PHP-FIG deliberately kept interfaces minimal to maximize interoperability. Any container can implement PSR-11; any event dispatcher can implement PSR-14. Larger interfaces would exclude legitimate implementations and create unnecessary dependencies.


Q: What is the practical difference between ISP and SRP at the interface level?

SRP asks: does this class/interface have only one reason to change? ISP asks: does this interface require the implementor to support methods that some callers will never use? They often fix the same problem from different angles. An interface that bundles "read from cache" + "write to cache" + "clear cache" + "get cache stats" might violate ISP if some callers only ever read. It also might violate SRP if cache statistics reporting is a different concern than cache operations. ISP focuses on the caller's perspective; SRP focuses on the reason-to-change. Applying both leads to interfaces that are small and coherent.


Q: Can ISP create too many interfaces in a codebase?

Yes — interface explosion is a real problem in overly rigid architectures. When every single method becomes its own interface, you create overhead: more files, more type hints, harder navigation. The balance is to group by caller role. If every caller that reads from a repository also needs to paginate, those belong in the same ReadableRepository interface. Only split when you find real callers that need a strict subset. A practical heuristic: if you cannot name the role clearly ("readable", "writable", "auditable", "notifiable"), the split is probably premature.