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. AUserand anOrdercan both implementJsonSerializabledespite 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,Searchabletraits 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:
- Does the abstraction represent a TYPE contract? → Interface
- Is there shared implementation AND a genuine "is-a" relationship? → Abstract class
- Is it shared implementation WITHOUT a type relationship? → Trait
- 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; }