Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 086 / 100

PHP interfaces and abstract classes: a step-by-step guide

Contracts vs shared base behavior.

Expona Admin8 min read

Introduction

By day 86 of your PHP and Laravel journey, you've likely built classes, extended them, and organized your code. But there's a gap between knowing syntax and writing maintainable, flexible systems. Understanding PHP interfaces and abstract classes: a step-by-step guide is the bridge that separates junior code from production-ready architecture. We'll explore how interfaces define contracts, how abstract classes share implementation, and how to wield both effectively in real codebases.

Interfaces define what a class can do

An interface is a contract. It says: "if you claim to implement me, you must have these methods with these signatures." Nothing more, nothing less. Interfaces contain no implementation—only method declarations.

Think of a real-world scenario: you're building a payment system. Multiple payment gateways exist: Stripe, PayPal, Square. Each works differently internally, but from your application's perspective, they all do one thing: process a payment. An interface captures that requirement.

php
interface PaymentGateway
{
    public function charge(float $amount, string $currency): array;
    public function refund(string $transactionId, float $amount): bool;
    public function verify(string $transactionId): bool;
}

Now each payment provider implements this contract:

php
class StripeGateway implements PaymentGateway
{
    public function charge(float $amount, string $currency): array
    {
        // Stripe-specific API calls
        return ['transaction_id' => '...', 'status' => 'success'];
    }

    public function refund(string $transactionId, float $amount): bool
    {
        // Stripe refund logic
        return true;
    }

    public function verify(string $transactionId): bool
    {
        // Stripe verification logic
        return true;
    }
}

class PayPalGateway implements PaymentGateway
{
    public function charge(float $amount, string $currency): array
    {
        // PayPal-specific implementation
        return ['transaction_id' => '...', 'status' => 'success'];
    }

    public function refund(string $transactionId, float $amount): bool
    {
        // PayPal refund logic
        return true;
    }

    public function verify(string $transactionId): bool
    {
        // PayPal verification logic
        return true;
    }
}

The beauty here: your business logic never knows whether Stripe or PayPal is being used. The interface guarantees both have the same public contract. This is polymorphism in action.

Abstract classes share implementation

Where interfaces define what, abstract classes define what and provide shared how. An abstract class can contain concrete methods (with bodies) alongside abstract methods (without bodies). Any subclass must implement the abstract methods, but can reuse the concrete ones.

Imagine you're building a notification system with email, SMS, and Slack notifications. All share common logic: logging, rate-limiting, retry policies. But the actual sending mechanism differs.

php
abstract class Notification
{
    protected array $retryPolicy = ['attempts' => 3, 'delay' => 5];
    protected Logger $logger;

    public function __construct(Logger $logger)
    {
        $this->logger = $logger;
    }

    // Shared concrete method
    public function notify(string $recipient, string $message): void
    {
        $attempts = 0;
        while ($attempts < $this->retryPolicy['attempts']) {
            try {
                $this->send($recipient, $message);
                $this->logger->info("Notification sent to {$recipient}");
                return;
            } catch (Exception $e) {
                $attempts++;
                $this->logger->warning("Attempt {$attempts} failed: " . $e->getMessage());
                if ($attempts < $this->retryPolicy['attempts']) {
                    sleep($this->retryPolicy['delay']);
                }
            }
        }
        $this->logger->error("Failed to notify {$recipient} after all attempts");
    }

    // Abstract method - subclasses must implement
    abstract protected function send(string $recipient, string $message): void;

    // Shared utility
    protected function sanitizeMessage(string $message): string
    {
        return trim($message);
    }
}

class EmailNotification extends Notification
{
    protected function send(string $recipient, string $message): void
    {
        // Email-specific implementation
        mail($recipient, 'Notification', $this->sanitizeMessage($message));
    }
}

class SlackNotification extends Notification
{
    protected function send(string $recipient, string $message): void
    {
        // Slack API call
        // $this->slackClient->send($recipient, $this->sanitizeMessage($message));
    }
}

Notice: retry logic, logging, and sanitization live once in the abstract class. All subclasses inherit this behavior. This is the DRY principle applied to inheritance—we avoid duplicating shared logic across EmailNotification, SlackNotification, and any future notification type.

A class can implement many interfaces

A class can only extend one parent class (single inheritance), but it can implement multiple interfaces. This is powerful for composing behavior and expressing "capabilities."

Suppose we're building a document processor. Some documents are archivable, some are shareable, some are both. Interfaces let us express these capabilities orthogonally:

php
interface Archivable
{
    public function archive(): void;
    public function restore(): void;
}

interface Shareable
{
    public function share(array $recipients): void;
    public function revoke(): void;
}

interface Versionable
{
    public function createVersion(): string;
    public function rollback(string $versionId): void;
}

class Report implements Archivable, Shareable, Versionable
{
    private string $content;
    private array $versions = [];

    public function archive(): void
    {
        // Mark as archived in database
    }

    public function restore(): void
    {
        // Restore from archive
    }

    public function share(array $recipients): void
    {
        // Send to recipients
    }

    public function revoke(): void
    {
        // Revoke access
    }

    public function createVersion(): string
    {
        // Create snapshot, return version ID
        return uniqid();
    }

    public function rollback(string $versionId): void
    {
        // Restore to specific version
    }
}

Now we can write code that checks capabilities at runtime:

php
function processDocument(object $document): void
{
    if ($document instanceof Archivable) {
        // Safe to call $document->archive()
    }

    if ($document instanceof Shareable) {
        // Safe to call $document->share()
    }
}

This pattern is common in Laravel itself—think of middleware chains or the Eloquent model interfaces.

Type-hint interfaces for flexibility

This is where the design patterns meet real code. Instead of type-hinting concrete classes, type-hint interfaces. Your function or method accepts anything that implements the interface, not a specific class.

Bad approach:

php
class OrderProcessor
{
    public function process(StripeGateway $gateway, Order $order): void
    {
        // Tightly coupled to StripeGateway
        $gateway->charge($order->total, 'USD');
    }
}

Problem: if you switch to PayPal, you rewrite this method. Tests must use StripeGateway. Tight coupling.

Good approach:

php
class OrderProcessor
{
    public function process(PaymentGateway $gateway, Order $order): void
    {
        // Works with any PaymentGateway implementation
        $gateway->charge($order->total, 'USD');
    }
}

Benefits: You can inject StripeGateway, PayPalGateway, or a MockGateway for tests without changing this class. The method is decoupled from concrete implementations.

In Laravel, this is dependency injection:

php
class PaymentController extends Controller
{
    public function checkout(PaymentGateway $gateway)
    {
        // Laravel's service container resolves the bound implementation
        $gateway->charge(99.99, 'USD');
    }
}

// In a service provider:
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Or for testing:
$this->app->bind(PaymentGateway::class, MockGateway::class);

Switching implementations is a configuration change, not a code rewrite.

Common mistakes

  • Mixing concerns: Don't use an interface when you need shared base behavior, and don't use an abstract class when you only need a contract. Interfaces are contracts; abstract classes are templates.
  • Over-interfacing: Not everything needs an interface. If a class has no polymorphic need, a direct dependency is fine. Use interfaces when you know you'll have multiple implementations or when testing requires mocks.
  • Ignoring type hints: Writing public function process($gateway) defeats the purpose. Always type-hint interfaces. It documents intent, enables IDE autocompletion, and catches errors early.
  • Fat interfaces: Don't create one interface with 20 methods. Small, focused interfaces are easier to implement and compose. Aim for single responsibility.
  • Abstract class as interface: Don't create an abstract class with only abstract methods and no state. Use an interface instead. Abstract classes are for sharing behavior and state.
  • Forgetting visibility in abstract methods: Abstract methods in PHP don't need an access modifier specified, but when implementing them, you must be at least as visible (public abstract can be public concrete, but not protected or private).

Key takeaways

  • Interfaces define contracts: "what must you do?" They enable polymorphism and decoupling.
  • Abstract classes share implementation: "what do you do and how do you do part of it?" They reduce code duplication across related classes.
  • Single inheritance, multiple interfaces: extend one parent; implement as many interfaces as needed to express capabilities.
  • Type-hint interfaces, not concrete classes: your code becomes testable, flexible, and resilient to change.
  • Start simple: not every class needs an interface or abstract parent. Introduce them when you see duplication, multiple implementations, or testing friction.
  • Leverage Laravel's service container to bind interfaces to implementations at runtime, swapping strategies without touching application logic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles