0

When to use abstract class vs interface vs trait — senior decision framework

Advanced5 min read·eng-09-006
interviewsolid

Concept

Abstract class vs Interface vs Trait — the senior decision framework. The choice reflects the RELATIONSHIP between the abstraction and the classes that use it.

Interface: Defines a CONTRACT. "This class CAN do these things." No implementation. Pure type. Classes can implement multiple interfaces.

  • Use when: different classes from different hierarchies must be interchangeable in a specific context.
  • Example: JsonSerializable, Countable, IteratorAggregate. A User and an Order can both implement JsonSerializable despite being unrelated.
  • Signals: "is-capable-of", "supports", "responds-to".

Abstract class: Defines a PARTIAL IMPLEMENTATION. "This class IS a kind of X, with some behavior already provided." Single inheritance only.

  • Use when: you have shared implementation code AND the relationship genuinely is "is-a".
  • Example: BaseController extends Controller, CsvExporter extends DataExporter.
  • Signals: "is-a", "extends-the-behavior-of", "is-a-specialization-of".
  • Beware: inheritors are tightly coupled. Use template method pattern here.

Trait: CODE REUSE without inheritance. "This class HAS THIS CAPABILITY injected."

  • Use when: unrelated classes need the same implementation, AND it doesn't represent a type relationship.
  • Example: SoftDeletes, HasTimestamps, Searchable traits used by any Eloquent model.
  • Signals: "mixes-in", "has-the-behavior-of".
  • Beware: traits are invisible in type systems (no instanceof Trait), can create conflicts.

Decision tree:

  1. Does the abstraction represent a TYPE contract? → Interface
  2. Is there shared implementation AND a genuine "is-a" relationship? → Abstract class
  3. Is it shared implementation WITHOUT a type relationship? → Trait
  4. Need both contract AND partial implementation, with multiple classes from DIFFERENT hierarchies? → Interface + Trait (implement interface, use trait for default implementation).

Code Example

php
<?php
// ============================================================
// INTERFACE — contract, no implementation
// ============================================================
interface Exportable
{
    public function toArray(): array;
    public function toJson(): string;
}

interface Cacheable
{
    public function getCacheKey(): string;
    public function getCacheTtl(): int;
}

// Multiple interfaces — a User can be both exportable and cacheable
class User implements Exportable, Cacheable
{
    public function toArray(): array  { return ['id' => $this->id, 'name' => $this->name]; }
    public function toJson(): string  { return json_encode($this->toArray()); }
    public function getCacheKey(): string { return "user:{$this->id}"; }
    public function getCacheTtl(): int    { return 3600; }
}

// ============================================================
// ABSTRACT CLASS — shared implementation + "is-a" relationship
// ============================================================
abstract class DataExporter
{
    // Template method — skeleton defined here, steps deferred
    final public function export(): string
    {
        $data      = $this->fetchData();     // abstract
        $processed = $this->filter($data);    // concrete (shared)
        return $this->format($processed);    // abstract
    }

    abstract protected function fetchData(): array;
    abstract protected function format(array $data): string;

    protected function filter(array $data): array   // shared, overridable
    {
        return array_filter($data, fn($row) => !empty($row));
    }
}

class CsvExporter extends DataExporter  // IS-A DataExporter
{
    protected function fetchData(): array    { return \DB::table('users')->get()->toArray(); }
    protected function format(array $data): string { return implode("\n", array_map(fn($r) => implode(',', (array)$r), $data)); }
}

// ============================================================
// TRAIT — code reuse WITHOUT a type relationship
// ============================================================
trait HasAuditLog
{
    protected function logAction(string $action, array $data = []): void
    {
        \Log::info($action, array_merge(['model' => static::class], $data));
    }

    public static function bootHasAuditLog(): void
    {
        static::created(fn($model) => $model->logAction('created'));
        static::updated(fn($model) => $model->logAction('updated', $model->getDirty()));
        static::deleted(fn($model) => $model->logAction('deleted'));
    }
}

class Order extends \Illuminate\Database\Eloquent\Model
{
    use HasAuditLog; // mixes in audit behavior — not "is-a", just "has-capability"
}

class Product extends \Illuminate\Database\Eloquent\Model
{
    use HasAuditLog; // same trait, different unrelated class
}

// ============================================================
// INTERFACE + TRAIT — contract with default implementation
// ============================================================
interface HasTimestampsInterface
{
    public function getCreatedAt(): \DateTimeImmutable;
    public function getUpdatedAt(): \DateTimeImmutable;
}

trait HasTimestampsBehavior
{
    protected \DateTimeImmutable $createdAt;
    protected \DateTimeImmutable $updatedAt;

    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
    public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; }
}

// Any class can implement the interface and use the trait for free implementation
class Comment implements HasTimestampsInterface { use HasTimestampsBehavior; }
class Article implements HasTimestampsInterface { use HasTimestampsBehavior; }