Master OOP in PHP with practical examples. Learn classes, inheritance, polymorphism, encapsulation, and design patterns that production systems rely on.

Object-Oriented Programming (OOP) is the backbone of modern PHP applications. Whether you're building a Laravel application, maintaining legacy code, or designing microservices, understanding OOP principles is non-negotiable.
PHP's OOP capabilities have evolved significantly since PHP 5. Today, PHP 8+ offers robust features like typed properties, constructor property promotion, and named arguments that make OOP patterns more expressive and maintainable.
This guide covers the five core OOP concepts with production-grade examples you can use immediately in your projects.
A class is a blueprint. An object is an instance of that blueprint. Think of a class as a cookie cutter and objects as the actual cookies.
class User
{
public string $name;
public string $email;
private string $password;
public function __construct(string $name, string $email, string $password)
{
$this->name = $name;
$this->email = $email;
$this->password = $password;
}
public function getEmail(): string
{
return $this->email;
}
private function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT);
}
}
// Creating objects
$user = new User('John Doe', 'john@example.com', 'secret123');
echo $user->name; // John DoeIn PHP 8+, you can use constructor property promotion to reduce boilerplate:
class User
{
public function __construct(
public string $name,
public string $email,
private string $password,
) {}
}
$user = new User('John Doe', 'john@example.com', 'secret123');This automatically declares and assigns properties in the constructor, making code cleaner and more readable.
Encapsulation is about hiding internal details and exposing only what's necessary. It protects your object's state from unwanted external interference.
PHP provides three visibility modifiers:
public: Accessible from anywhereprotected: Accessible within the class and its subclassesprivate: Accessible only within the classclass BankAccount
{
private float $balance = 0;
private string $accountNumber;
public function __construct(string $accountNumber)
{
$this->accountNumber = $accountNumber;
}
public function deposit(float $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('Deposit amount must be positive');
}
$this->balance += $amount;
}
public function withdraw(float $amount): void
{
if ($amount > $this->balance) {
throw new Exception('Insufficient funds');
}
$this->balance -= $amount;
}
public function getBalance(): float
{
return $this->balance;
}
public function getAccountNumber(): string
{
return $this->accountNumber;
}
}
$account = new BankAccount('ACC123456');
$account->deposit(1000);
$account->withdraw(200);
echo $account->getBalance(); // 800
// This would fail - balance is private
// $account->balance = 5000; // Error: Cannot access private propertyWhy encapsulation matters: You control how data changes. In the example above, you prevent negative deposits and overdrafts at the source, not in calling code.
Inheritance allows a class to inherit properties and methods from another class. It promotes code reuse and establishes hierarchical relationships.
class Animal
{
protected string $name;
protected int $age;
public function __construct(string $name, int $age)
{
$this->name = $name;
$this->age = $age;
}
public function eat(): string
{
return "{$this->name} is eating";
}
public function sleep(): string
{
return "{$this->name} is sleeping";
}
public function getInfo(): string
{
return "{$this->name} is {$this->age} years old";
}
}
class Dog extends Animal
{
private string $breed;
public function __construct(string $name, int $age, string $breed)
{
parent::__construct($name, $age);
$this->breed = $breed;
}
public function bark(): string
{
return "{$this->name} says: Woof! Woof!";
}
public function getInfo(): string
{
return parent::getInfo() . " and is a {$this->breed}";
}
}
$dog = new Dog('Buddy', 3, 'Golden Retriever');
echo $dog->eat(); // Buddy is eating
echo $dog->bark(); // Buddy says: Woof! Woof!
echo $dog->getInfo(); // Buddy is 3 years old and is a Golden RetrieverKey points:
extends to inherit from a parent classparent:: to call parent class methodsprotected properties are accessible in child classesPolymorphism means "many forms." It allows objects of different classes to be treated through the same interface. This is powerful for writing flexible, maintainable code.
interface PaymentProcessor
{
public function process(float $amount): bool;
public function refund(float $amount): bool;
}
class CreditCardProcessor implements PaymentProcessor
{
public function process(float $amount): bool
{
// Process credit card payment
return true;
}
public function refund(float $amount): bool
{
// Refund to credit card
return true;
}
}
class PayPalProcessor implements PaymentProcessor
{
public function process(float $amount): bool
{
// Process PayPal payment
return true;
}
public function refund(float $amount): bool
{
// Refund via PayPal
return true;
}
}
class StripeProcessor implements PaymentProcessor
{
public function process(float $amount): bool
{
// Process Stripe payment
return true;
}
public function refund(float $amount): bool
{
// Refund via Stripe
return true;
}
}
// Polymorphic usage
function processOrder(PaymentProcessor $processor, float $amount): void
{
if ($processor->process($amount)) {
echo "Payment processed successfully";
}
}
$creditCard = new CreditCardProcessor();
$paypal = new PayPalProcessor();
$stripe = new StripeProcessor();
processOrder($creditCard, 99.99);
processOrder($paypal, 49.99);
processOrder($stripe, 199.99);The processOrder function doesn't care which payment processor is used. It works with any class implementing PaymentProcessor. This is the power of polymorphism.
Abstract classes define a contract that subclasses must follow:
abstract class DatabaseConnection
{
protected string $host;
protected string $database;
public function __construct(string $host, string $database)
{
$this->host = $host;
$this->database = $database;
}
abstract public function connect(): void;
abstract public function query(string $sql): array;
abstract public function close(): void;
public function getDatabase(): string
{
return $this->database;
}
}
class MySQLConnection extends DatabaseConnection
{
private $connection;
public function connect(): void
{
// MySQL connection logic
$this->connection = mysqli_connect($this->host, 'user', 'pass', $this->database);
}
public function query(string $sql): array
{
// Execute MySQL query
return [];
}
public function close(): void
{
mysqli_close($this->connection);
}
}
class PostgreSQLConnection extends DatabaseConnection
{
private $connection;
public function connect(): void
{
// PostgreSQL connection logic
$this->connection = pg_connect("host={$this->host} dbname={$this->database}");
}
public function query(string $sql): array
{
// Execute PostgreSQL query
return [];
}
public function close(): void
{
pg_close($this->connection);
}
}Abstraction hides complexity and shows only essential features. It's about creating a simplified interface to complex underlying code.
// Complex internal logic hidden
class EmailService
{
private SMTPClient $smtpClient;
private TemplateEngine $templateEngine;
private Logger $logger;
public function __construct(
SMTPClient $smtpClient,
TemplateEngine $templateEngine,
Logger $logger
) {
$this->smtpClient = $smtpClient;
$this->templateEngine = $templateEngine;
$this->logger = $logger;
}
// Simple public interface
public function sendWelcomeEmail(string $email, string $name): bool
{
try {
$template = $this->templateEngine->render('welcome', ['name' => $name]);
$this->smtpClient->send($email, 'Welcome!', $template);
$this->logger->info("Welcome email sent to {$email}");
return true;
} catch (Exception $e) {
$this->logger->error("Failed to send welcome email: {$e->getMessage()}");
return false;
}
}
public function sendPasswordResetEmail(string $email, string $resetToken): bool
{
try {
$template = $this->templateEngine->render('password-reset', ['token' => $resetToken]);
$this->smtpClient->send($email, 'Reset Your Password', $template);
$this->logger->info("Password reset email sent to {$email}");
return true;
} catch (Exception $e) {
$this->logger->error("Failed to send password reset email: {$e->getMessage()}");
return false;
}
}
}
// Client code doesn't need to know about SMTP, templates, or logging
$emailService = new EmailService($smtp, $template, $logger);
$emailService->sendWelcomeEmail('user@example.com', 'John');The caller doesn't need to understand SMTP protocols, template rendering, or logging mechanisms. They just call a simple method.
Composition is often better than inheritance. Instead of inheriting behavior, you compose objects that have the behavior you need.
// Instead of inheriting from Logger
class UserRepository
{
private Logger $logger;
private Database $database;
public function __construct(Logger $logger, Database $database)
{
$this->logger = $logger;
$this->database = $database;
}
public function findById(int $id): ?User
{
$this->logger->debug("Fetching user with ID: {$id}");
$result = $this->database->query("SELECT * FROM users WHERE id = ?", [$id]);
return $result ? new User($result) : null;
}
public function save(User $user): bool
{
try {
$this->database->query("INSERT INTO users ...", $user->toArray());
$this->logger->info("User saved: {$user->getId()}");
return true;
} catch (Exception $e) {
$this->logger->error("Failed to save user: {$e->getMessage()}");
return false;
}
}
}Composition is flexible: you can swap out the logger or database implementation without changing UserRepository.
Interfaces define contracts. A class implementing an interface must implement all its methods.
interface CacheStore
{
public function get(string $key): mixed;
public function put(string $key, mixed $value, int $ttl = 3600): void;
public function forget(string $key): void;
public function flush(): void;
}
class RedisCache implements CacheStore
{
private Redis $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function get(string $key): mixed
{
return $this->redis->get($key);
}
public function put(string $key, mixed $value, int $ttl = 3600): void
{
$this->redis->setex($key, $ttl, serialize($value));
}
public function forget(string $key): void
{
$this->redis->del($key);
}
public function flush(): void
{
$this->redis->flushAll();
}
}
class FileCache implements CacheStore
{
private string $path;
public function __construct(string $path)
{
$this->path = $path;
}
public function get(string $key): mixed
{
$file = $this->path . '/' . md5($key);
if (!file_exists($file)) {
return null;
}
$data = unserialize(file_get_contents($file));
if ($data['expires'] < time()) {
unlink($file);
return null;
}
return $data['value'];
}
public function put(string $key, mixed $value, int $ttl = 3600): void
{
$file = $this->path . '/' . md5($key);
$data = ['value' => $value, 'expires' => time() + $ttl];
file_put_contents($file, serialize($data));
}
public function forget(string $key): void
{
$file = $this->path . '/' . md5($key);
if (file_exists($file)) {
unlink($file);
}
}
public function flush(): void
{
array_map('unlink', glob($this->path . '/*'));
}
}
// Both implementations satisfy the interface
function cacheUserData(CacheStore $cache, int $userId, array $data): void
{
$cache->put("user:{$userId}", $data, 3600);
}
$redisCache = new RedisCache($redis);
$fileCache = new FileCache('/tmp/cache');
cacheUserData($redisCache, 1, ['name' => 'John']);
cacheUserData($fileCache, 1, ['name' => 'John']);Traits allow you to reuse methods across unrelated classes without inheritance.
trait Timestampable
{
private DateTime $createdAt;
private DateTime $updatedAt;
public function setCreatedAt(DateTime $date): void
{
$this->createdAt = $date;
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
public function setUpdatedAt(DateTime $date): void
{
$this->updatedAt = $date;
}
public function getUpdatedAt(): DateTime
{
return $this->updatedAt;
}
}
trait Loggable
{
private array $logs = [];
public function addLog(string $message): void
{
$this->logs[] = [
'message' => $message,
'timestamp' => date('Y-m-d H:i:s'),
];
}
public function getLogs(): array
{
return $this->logs;
}
}
class BlogPost
{
use Timestampable, Loggable;
private string $title;
private string $content;
public function __construct(string $title, string $content)
{
$this->title = $title;
$this->content = $content;
$this->setCreatedAt(new DateTime());
$this->addLog('Post created');
}
public function publish(): void
{
$this->setUpdatedAt(new DateTime());
$this->addLog('Post published');
}
}
$post = new BlogPost('My First Post', 'Content here...');
$post->publish();
print_r($post->getLogs());Static members belong to the class, not to instances. Use them for shared data or utility functions.
class Config
{
private static array $settings = [];
private static bool $initialized = false;
public static function initialize(array $config): void
{
if (self::$initialized) {
throw new Exception('Config already initialized');
}
self::$settings = $config;
self::$initialized = true;
}
public static function get(string $key, mixed $default = null): mixed
{
return self::$settings[$key] ?? $default;
}
public static function set(string $key, mixed $value): void
{
self::$settings[$key] = $value;
}
}
Config::initialize(['app_name' => 'MyApp', 'debug' => true]);
echo Config::get('app_name'); // MyApp
Config::set('debug', false);Warning
Static members can make testing difficult and create hidden dependencies. Use them sparingly. Dependency injection is usually better.
class Animal {}
class Mammal extends Animal {}
class Carnivore extends Mammal {}
class Feline extends Carnivore {}
class DomesticCat extends Feline {}
// This is fragile and hard to maintaininterface Eater {
public function eat(): void;
}
interface Sleeper {
public function sleep(): void;
}
class Cat implements Eater, Sleeper {
public function eat(): void {}
public function sleep(): void {}
}class User
{
public array $data = [];
}
$user = new User();
$user->data['password'] = 'plaintext'; // Dangerous!class User
{
private string $password;
public function setPassword(string $password): void
{
$this->password = password_hash($password, PASSWORD_BCRYPT);
}
}function processUser($user)
{
return $user->getName();
}function processUser(User $user): string
{
return $user->getName();
}class OrderService
{
public function __construct(
private PaymentProcessor $paymentProcessor,
private EmailService $emailService,
private Logger $logger,
) {}
public function placeOrder(Order $order): bool
{
if (!$this->paymentProcessor->process($order->getTotal())) {
$this->logger->error('Payment failed for order ' . $order->getId());
return false;
}
$this->emailService->sendOrderConfirmation($order);
$this->logger->info('Order placed: ' . $order->getId());
return true;
}
}class Product
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly float $price,
public readonly bool $available = true,
) {}
public function getDiscountedPrice(float $discountPercent): float
{
return $this->price * (1 - $discountPercent / 100);
}
public function isAffordable(float $budget): bool
{
return $this->price <= $budget;
}
}class NotificationService
{
public function send(
string $recipient,
string $message,
string $channel = 'email',
bool $urgent = false,
): void {
$priority = match ($channel) {
'sms' => $urgent ? 'high' : 'normal',
'email' => 'normal',
'push' => 'high',
default => throw new InvalidArgumentException("Unknown channel: {$channel}"),
};
}
}
// Named arguments make calls clear
$service->send(
recipient: 'user@example.com',
message: 'Your order is ready',
channel: 'email',
urgent: false,
);OOP isn't always the answer. Consider alternatives:
// ✅ Simple and clear
function calculateTax(float $amount, float $rate): float
{
return $amount * ($rate / 100);
}
// ❌ Over-engineered
class TaxCalculator
{
private float $rate;
public function __construct(float $rate) { $this->rate = $rate; }
public function calculate(float $amount): float { return $amount * ($this->rate / 100); }
}OOP in PHP provides powerful tools for building maintainable, scalable applications. Master these concepts:
Start with SOLID principles, use dependency injection, and leverage PHP 8+ features like typed properties and constructor promotion. Your code will be cleaner, more testable, and easier to maintain.
The best OOP code is code that solves problems clearly without unnecessary complexity. Write for the next developer who reads your code—it might be you in six months.