PHP Zero to Advanced #7: OOP - Classes, Constructor Promotion, Interfaces, Traits & Enums
Functions and arrays can take you a long way, but as an application grows you need a better way to group data together with the behavior that operates on it. That is what object-oriented programming (OOP) gives you. Every modern PHP framework, including Laravel and Symfony, is built on OOP, and PHP 8 added features that make classes concise and safe. Let's go through them.
Classes and Objects
A class is a blueprint; an object is an instance created from it with new. Properties hold data, methods define behavior, and $this refers to the current object. Use -> to access members.
<?php
class Product
{
public string $name;
public int $price;
public function __construct(string $name, int $price)
{
$this->name = $name;
$this->price = $price;
}
public function label(): string
{
return $this->name . " - Rp " . number_format($this->price, 0, ',', '.');
}
}
$mouse = new Product("Mouse", 150000);
$keyboard = new Product("Keyboard", 450000);
echo $mouse->label() . "\n";
echo $keyboard->name . "\n";
Output:
Mouse - Rp 150.000
Keyboard
Constructor Promotion, Visibility, and readonly
Writing each property three times (declaration, parameter, assignment) is tedious. Constructor property promotion (PHP 8.0) declares and assigns properties directly in the constructor signature. Combine it with visibility and readonly (PHP 8.1):
public: accessible from anywhere.protected: accessible inside the class and its children.private: accessible only inside the class itself.readonly: can be assigned once, then never changed.
<?php
declare(strict_types=1);
class BankAccount
{
public function __construct(
public readonly string $owner,
private int $balance = 0,
) {
}
public function deposit(int $amount): static
{
if ($amount <= 0) {
throw new InvalidArgumentException("Amount must be positive");
}
$this->balance += $amount;
return $this; // enables method chaining
}
public function balance(): int
{
return $this->balance;
}
}
$account = new BankAccount("Siti");
$account->deposit(50000)->deposit(25000);
echo $account->owner . ": " . $account->balance() . "\n";
try {
$account->owner = "Budi";
} catch (Error $e) {
echo get_class($e) . ": " . $e->getMessage() . "\n";
}
Output:
Siti: 75000
Error: Cannot modify readonly property BankAccount::$owner
Keeping $balance private means the only way to change it is through deposit(), which validates the amount. This is encapsulation: the object protects its own rules. Accessing private members from outside fails:
<?php
class Wallet
{
private int $balance = 0;
}
$wallet = new Wallet();
try {
echo $wallet->balance;
} catch (Error $e) {
echo $e->getMessage() . "\n";
}
Output:
Cannot access private property Wallet::$balance
Static members and constants
Static methods and properties belong to the class itself rather than to an instance. Access them with ::. Class constants are useful for fixed values related to the class.
<?php
class Temperature
{
public const FREEZING_C = 0;
private static int $conversions = 0;
public static function toFahrenheit(float $celsius): float
{
self::$conversions++;
return $celsius * 9 / 5 + 32;
}
public static function count(): int
{
return self::$conversions;
}
}
echo Temperature::toFahrenheit(30) . "\n";
echo Temperature::toFahrenheit(Temperature::FREEZING_C) . "\n";
echo "Conversions: " . Temperature::count() . "\n";
Output:
86
32
Conversions: 2
Use static methods sparingly: they are convenient for pure helper functions, but they make code harder to swap out in tests.
Inheritance and Abstract Classes
A class can extend another class to reuse and override its behavior. An abstract class cannot be instantiated and may declare abstract methods that every child must implement. Use parent:: to call the parent version of a method.
<?php
abstract class Shape
{
abstract public function area(): float;
public function describe(): string
{
return static::class . " with area " . round($this->area(), 2);
}
}
class Rectangle extends Shape
{
public function __construct(private float $width, private float $height) {}
public function area(): float
{
return $this->width * $this->height;
}
}
class Circle extends Shape
{
public function __construct(private float $radius) {}
public function area(): float
{
return M_PI * $this->radius ** 2;
}
public function describe(): string
{
return parent::describe() . " (radius {$this->radius})";
}
}
$shapes = [new Rectangle(4, 5), new Circle(3)];
foreach ($shapes as $shape) {
echo $shape->describe() . "\n";
}
Output:
Rectangle with area 20
Circle with area 28.27 (radius 3)
Mark classes final when they are not designed to be extended. Prefer shallow hierarchies; deep inheritance trees become fragile.
Interfaces
An interface defines a contract: a list of methods a class promises to implement, without any implementation. Code that depends on the interface works with any implementation. This is the foundation of dependency injection, which you will see everywhere in Laravel.
<?php
interface Notifier
{
public function send(string $to, string $message): bool;
}
class EmailNotifier implements Notifier
{
public function send(string $to, string $message): bool
{
echo "Email to $to: $message\n";
return true;
}
}
class SmsNotifier implements Notifier
{
public function send(string $to, string $message): bool
{
echo "SMS to $to: $message\n";
return true;
}
}
class OrderService
{
public function __construct(private Notifier $notifier) {}
public function placeOrder(string $customer): void
{
// ... save the order ...
$this->notifier->send($customer, "Your order has been placed");
}
}
(new OrderService(new EmailNotifier()))->placeOrder("budi@example.com");
(new OrderService(new SmsNotifier()))->placeOrder("+62-800-0000-0000");
var_dump(new SmsNotifier() instanceof Notifier);
Output:
Email to budi@example.com: Your order has been placed
SMS to +62-800-0000-0000: Your order has been placed
bool(true)
OrderService does not care how notifications are delivered. Swapping email for SMS (or a fake notifier in tests) requires no change to the service.
Traits
PHP supports single inheritance only. Traits let you share methods across unrelated classes by "copying" them in with use:
<?php
trait HasTimestamps
{
private ?string $createdAt = null;
public function touch(string $time): void
{
$this->createdAt = $time;
}
public function createdAt(): ?string
{
return $this->createdAt;
}
}
trait Sluggable
{
public function slug(): string
{
return strtolower(trim(preg_replace('/[^A-Za-z0-9]+/', '-', $this->title), '-'));
}
}
class Post
{
use HasTimestamps, Sluggable;
public function __construct(public string $title) {}
}
$post = new Post("Learning PHP: OOP Basics!");
$post->touch("2026-01-15 10:00:00");
echo $post->slug() . "\n";
echo $post->createdAt() . "\n";
Output:
learning-php-oop-basics
2026-01-15 10:00:00
Traits are great for small, reusable capabilities like timestamps or slugs. Do not use them as a dumping ground for unrelated logic.
Enums (PHP 8.1)
Enums represent a fixed set of possible values, such as order statuses or user roles. Backed enums map each case to a string or int, which is perfect for storing in a database. Enums can have methods and work beautifully with match.
<?php
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Waiting for payment',
self::Paid => 'Payment received',
self::Shipped => 'On the way',
self::Cancelled => 'Cancelled',
};
}
public function canCancel(): bool
{
return in_array($this, [self::Pending, self::Paid], true);
}
}
$status = OrderStatus::from('paid'); // from a database value
echo $status->name . " / " . $status->value . "\n";
echo $status->label() . "\n";
var_dump($status->canCancel());
var_dump(OrderStatus::tryFrom('refunded')); // null instead of an error
echo count(OrderStatus::cases()) . " statuses\n";
Output:
Paid / paid
Payment received
bool(true)
NULL
4 statuses
Compared with string constants, an enum type-hint like function ship(OrderStatus $status) makes invalid values impossible.
Bonus: immutable value objects
Combining final, readonly, and the magic method __toString() gives you clean value objects:
<?php
final class Money implements Stringable
{
public function __construct(
public readonly int $amount,
public readonly string $currency = 'IDR',
) {}
public function add(Money $other): self
{
return new self($this->amount + $other->amount, $this->currency);
}
public function __toString(): string
{
return $this->currency . ' ' . number_format($this->amount, 0, ',', '.');
}
}
$a = new Money(150000);
$b = $a->add(new Money(25000));
echo $a . "\n"; // original is unchanged (immutable)
echo $b . "\n";
Output:
IDR 150.000
IDR 175.000
Common Mistakes
- Forgetting
$this->. Inside a method,$nameis a local variable, not the property$this->name. - Making everything public. Start with
privateand open up only what callers need. - Using inheritance to share code. If "B is a kind of A" is not true, prefer composition (pass an object into the constructor) or a trait.
- Using
from()on untrusted input.OrderStatus::from('xyz')throws aValueError. UsetryFrom()and handlenull. - God classes. A class with dozens of methods doing unrelated things is hard to test. Keep classes focused on a single responsibility.
Exercise
Model a shopping cart: a Category enum with a tax rate per category, an immutable CartItem, and a Cart that calculates the grand total.
<?php
declare(strict_types=1);
enum Category: string
{
case Food = 'food';
case Book = 'book';
public function taxRate(): float
{
return match ($this) {
self::Food => 0.0,
self::Book => 0.11,
};
}
}
final class CartItem
{
public function __construct(
public readonly string $name,
public readonly int $price,
public readonly int $qty,
public readonly Category $category,
) {}
public function total(): int
{
return (int) round($this->price * $this->qty * (1 + $this->category->taxRate()));
}
}
final class Cart
{
/** @var CartItem[] */
private array $items = [];
public function add(CartItem $item): void
{
$this->items[] = $item;
}
public function total(): int
{
return array_sum(array_map(fn(CartItem $i) => $i->total(), $this->items));
}
}
$cart = new Cart();
$cart->add(new CartItem('Rice 5kg', 75000, 2, Category::Food));
$cart->add(new CartItem('PHP Book', 120000, 1, Category::Book));
echo 'Total: Rp ' . number_format($cart->total(), 0, ',', '.') . "\n";
Output:
Total: Rp 283.200
Extend it with a Discount interface and two implementations (percentage and fixed amount) that the cart can apply.
Summary
- Classes bundle data and behavior; create objects with
newand access members with->or::. - Constructor promotion and
readonlymake classes short and safe. - Use visibility for encapsulation, interfaces for contracts, abstract classes for shared base behavior, and traits for reusable snippets.
- Enums model fixed sets of values and pair perfectly with
match.
In Part 8 we enter advanced territory: organizing classes with namespaces, installing packages with Composer, PSR-4 autoloading, and robust error handling with exceptions.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
Ruby on Rails Zero to Advanced #5: Routing, Controllers & Views
Follow a request through Rails: define routes, use resources, read params in controllers, and render dynamic HTML with ERB views and partials.
Python Zero to Advanced #5: Lists, Tuples, Dictionaries, Sets & Comprehensions
Master Python's built-in collections: lists, tuples, dictionaries, and sets, when to use each, slicing, unpacking, and writing concise list, dict, and set comprehensions.