Lesson 14 of 20

Object-Oriented PHP

The Problem Classes Solve

Everything so far has kept data in arrays and behaviour in functions, with nothing connecting them. That works well for a small script and starts to hurt as soon as a project has a few moving parts.

Picture a shopping cart built the procedural way. The cart is an array; there is an addToCart() function, a cartTotal() function and a applyDiscount() function, and each of them has to be passed the array and trusted to hand back a correct one. Nothing stops another part of the program from reaching into the array and setting a quantity to -3. Nothing tells a new developer which functions belong to a cart and which belong to something else. And when the shape of the array changes, you have to find every function that touched it.

A class fixes all three problems by putting the data and the operations in one place. It is a blueprint: it declares what a cart has (its properties) and what a cart can do (its methods). An object is one actual cart made from that blueprint, created with new.

The payoff is not that the code is shorter — often it is slightly longer. The payoff is that the rules about a cart live inside the cart, so they cannot be bypassed by accident, and that a reader can find everything about carts in one file. On a project of any size, that is the difference between code you can change confidently and code you are afraid to touch.

Example
<?php
// Procedural: data and rules are separate, and nothing is protected
$cart = [];
function addToCart(array $cart, string $sku, int $qty): array {
    $cart[$sku] = ($cart[$sku] ?? 0) + $qty;
    return $cart;
}
$cart = addToCart($cart, 'P-101', 2);
$cart['P-101'] = -5;          // nothing stops this

// Object-oriented: the rules live with the data
class Cart
{
    private array $items = [];

    public function add(string $sku, int $qty): void
    {
        if ($qty < 1) {
            throw new InvalidArgumentException('Quantity must be at least 1.');
        }
        $this->items[$sku] = ($this->items[$sku] ?? 0) + $qty;
    }

    public function remove(string $sku): void
    {
        unset($this->items[$sku]);
    }

    public function count(): int
    {
        return array_sum($this->items);
    }

    public function items(): array
    {
        return $this->items;
    }
}

$cart = new Cart();
$cart->add('P-101', 2);
$cart->add('P-244', 1);
echo $cart->count();          // 3
// $cart->items['P-101'] = -5;   // Error - items is private
Notes
  • One class per file, named exactly like the class, is the convention every modern PHP project follows. Cart lives in Cart.php. Composer's autoloader depends on this, and it is what lets you stop writing require lines by hand.

Properties, Methods and $this

Inside a class, a variable belonging to the object is a property and a function belonging to it is a method. Both are accessed through $this, which is the object the method was called on.

The arrow syntax has a detail that trips up everyone once: you write $this->name, with no dollar sign before name. Writing $this->$name means something else entirely — it uses the value of $name as the property name, which is almost never what you meant and produces a baffling error.

Since PHP 7.4 properties can be typed, and you should type them. A typed property rejects a wrong value at the moment of assignment rather than letting it flow through the object. It also brings one behaviour worth knowing: a typed property with no default has no value at all until you assign one, and reading it throws an Error saying it "must not be accessed before initialization". That is more useful than the old silent null, because it points at the constructor that forgot to set it.

Objects are compared in two ways, and the difference matters. == is true when two objects are of the same class and all their properties match. === is true only when both variables refer to the very same object. Since assigning an object copies the handle rather than the object, two variables usually are the same object — which is why clone exists.

Example
<?php
class Student
{
    public string $name;
    public int $roll;
    private array $marks = [];

    public function addMark(string $subject, int $score): void
    {
        $this->marks[$subject] = $score;      // no $ before the name
    }

    public function average(): float
    {
        if ($this->marks === []) {
            return 0.0;
        }
        return round(array_sum($this->marks) / count($this->marks), 2);
    }

    public function hasPassed(): bool
    {
        return $this->average() >= 40;        // methods can call methods
    }
}

$s = new Student();
$s->name = 'Ananya';
$s->roll = 118;
$s->addMark('Physics', 91);
$s->addMark('Maths', 88);

echo $s->average();       // 89.5
var_dump($s->hasPassed()); // true

// A typed property with no default is uninitialised, not null
class Order { public string $reference; }
$o = new Order();
// echo $o->reference;   // Error: must not be accessed before initialization

// Comparing objects
$a = new Student(); $a->roll = 1;
$b = new Student(); $b->roll = 1;
var_dump($a == $b);   // true  - same class, same property values
var_dump($a === $b);  // false - two different objects

$c = $a;
var_dump($a === $c);  // true  - the same object, two names
Notes
  • $student->name on an object that is null throws an Error and stops the script. This is where the nullsafe operator earns its keep: $student?->name gives null instead, which is often what you want when a lookup may legitimately find nothing.

Visibility, and Why private Is the Default You Want

Every property and method has a visibility. public means anything anywhere can reach it. private means only code inside this class can. protected sits between the two: this class and any class that extends it, which the inheritance lesson covers.

The instinct is to make everything public, because it is less typing and nothing complains. Resist it. The value of private is that it draws a line around what other code may depend on. Anything public is a promise you have to keep — change it and you might break code anywhere in the project. Anything private you can rename, restructure or delete freely, knowing the only code affected is in the file you are already looking at.

Encapsulation also means the object can enforce its own rules. If $balance is public, any line anywhere can set it to a negative number and you will never find out where. If it is private and changed only through withdraw(), the rule that a balance cannot go below zero exists in exactly one place and cannot be bypassed.

The usual pattern is private properties with public methods to read them, often called getters. Do not write a getter and a setter for every property out of habit, though — that is public data with extra steps. Ask what the object is for. A cart does not need setItems(); it needs add(), remove() and total(). Methods named after real operations are what makes a class worth having.

Example
<?php
class BankAccount
{
    private int $balancePaise = 0;
    private array $log = [];

    public function deposit(int $paise): void
    {
        if ($paise <= 0) {
            throw new InvalidArgumentException('Deposit must be positive.');
        }
        $this->balancePaise += $paise;
        $this->record('deposit', $paise);
    }

    public function withdraw(int $paise): void
    {
        if ($paise > $this->balancePaise) {
            throw new RuntimeException('Insufficient balance.');
        }
        $this->balancePaise -= $paise;
        $this->record('withdraw', $paise);
    }

    public function balance(): string
    {
        return 'Rs ' . number_format($this->balancePaise / 100, 2);
    }

    // Internal helper - nobody outside needs it
    private function record(string $type, int $paise): void
    {
        $this->log[] = ['type' => $type, 'paise' => $paise, 'at' => time()];
    }
}

$acc = new BankAccount();
$acc->deposit(500000);
$acc->withdraw(120000);
echo $acc->balance();          // Rs 3,800.00

// The rules cannot be bypassed
// $acc->balancePaise = 99999999;   // Error: private property
// $acc->record('fake', 1);         // Error: private method
// $acc->withdraw(99999999);        // RuntimeException - by design
Notes
  • Note that the balance is stored as an integer number of paise, exactly as the data-types lesson recommended for money. Because the property is private, that decision is invisible to the rest of the program — the outside world only sees deposit(), withdraw() and a formatted balance(), so you could change the internal representation later without touching anything else.

Constructors and Constructor Promotion

The __construct() method runs automatically when you create an object with new. Its job is to put the object into a valid state, so that no object of this class ever exists in a half-built condition. Anything that is genuinely required for the object to work belongs in the constructor's parameters.

That last point is a design idea worth taking seriously. If a Student is meaningless without a name and a roll number, requiring them in the constructor means no code anywhere can forget them — as opposed to creating an empty object and hoping every caller remembers to fill it in.

Writing constructors got noticeably shorter in PHP 8 with constructor property promotion. Adding a visibility keyword to a constructor parameter declares the property, types it, and assigns it, all at once. The three-times repetition of every field name — once in the property list, once in the parameter, once in the assignment — disappears.

The constructor is also the right place to validate. Throwing an exception from it means an invalid object simply never comes into existence, which is far easier to reason about than an object that exists but is wrong. PHP 8.1's readonly keyword takes this further: a readonly property can be set once in the constructor and never changed again, which is ideal for values such as an id or a creation timestamp.

There is a matching __destruct() method that runs when an object is discarded. In web scripts it is rarely needed — PHP closes files and database connections at the end of the request anyway — so do not go looking for a use for it.

Example
<?php
// The long form (still valid, and what you will see in older code)
class StudentOld
{
    private string $name;
    private int $roll;

    public function __construct(string $name, int $roll)
    {
        $this->name = $name;
        $this->roll = $roll;
    }
}

// Constructor promotion (PHP 8) - identical, far less noise
class Student
{
    public function __construct(
        private string $name,
        private int $roll,
        private array $marks = [],
    ) {
        if (trim($name) === '') {
            throw new InvalidArgumentException('Student name cannot be empty.');
        }
        if ($roll < 1) {
            throw new InvalidArgumentException('Roll number must be positive.');
        }
    }

    public function name(): string { return $this->name; }
    public function roll(): int    { return $this->roll; }
}

$s = new Student('Ananya Sharma', 118);
echo $s->name();

// An invalid object never exists
try {
    $bad = new Student('', 5);
} catch (InvalidArgumentException $e) {
    echo $e->getMessage();     // "Student name cannot be empty."
}

// readonly (PHP 8.1): set once, then permanent
class Invoice
{
    public function __construct(
        public readonly string $number,
        public readonly int $createdAt,
    ) {}
}
$inv = new Invoice('INV-00042', time());
echo $inv->number;
// $inv->number = 'INV-1';   // Error: cannot modify readonly property

// Named arguments pair well with promoted constructors
$s2 = new Student(name: 'Ravi', roll: 121);
Notes
  • Promotion works only in the constructor, only with a visibility keyword, and cannot be used with a variadic parameter. Mixing promoted and ordinary parameters in one constructor is allowed, which is handy when one value needs processing before it is stored.

Static Members, and When They Are the Wrong Answer

A static property or method belongs to the class itself rather than to any object, so you call it with ClassName::method() and no new. Because there is no object involved, $this does not exist inside a static method — using it is an error.

The honest uses are narrow. Class constants for fixed values such as status names. Small pure helper functions that need no state. And factory methods — a static method that builds and returns an object, often more readable than a constructor when there are several ways to create one. Student::fromDatabaseRow($row) says more at the call site than a constructor with five arguments.

The dishonest use is treating a class as a bag of global functions with shared static state. It feels convenient and it recreates every problem globals have: any code anywhere can change that state, tests interfere with each other, and you cannot have two configurations at once. If a static property is being written to from several places, that is a sign the design wants an object.

There is one more distinction to know. self:: refers to the class where the code was literally written, while static:: refers to the class that was actually called — which matters once inheritance is involved, because a factory using new self() in a parent class will build a parent even when called on a child. new static() builds the right one. This is called late static binding, and it is worth remembering the day a factory returns the wrong class.

Finally, ClassName::class gives you the full class name as a string. It is checked by your editor and survives renaming, which makes it far better than typing the name in quotes.

Example
<?php
class Invoice
{
    // Class constants: fixed, shared, self-documenting
    public const STATUS_DRAFT = 'draft';
    public const STATUS_PAID  = 'paid';

    private static int $created = 0;   // shared across all invoices

    private function __construct(
        public readonly string $number,
        public string $status = self::STATUS_DRAFT,
    ) {
        self::$created++;
    }

    // Factory methods read better than one overloaded constructor
    public static function draft(int $sequence): static
    {
        return new static(sprintf('INV-%05d', $sequence));
    }

    public static function fromRow(array $row): static
    {
        return new static($row['number'], $row['status']);
    }

    public static function createdCount(): int
    {
        return self::$created;
    }
}

$a = Invoice::draft(42);
$b = Invoice::fromRow(['number' => 'INV-00043', 'status' => Invoice::STATUS_PAID]);

echo $a->number;                 // INV-00042
echo Invoice::createdCount();    // 2
echo Invoice::STATUS_PAID;       // paid
echo Invoice::class;             // "Invoice" - the full name, as a string

// Static methods have no $this
class Slug
{
    public static function make(string $title): string
    {
        $s = mb_strtolower(trim($title));
        $s = preg_replace('/[^a-z0-9]+/', '-', $s);
        return trim($s, '-');
    }
}
echo Slug::make('Working With MySQL');   // working-with-mysql
Notes
  • A quick test for whether something should be static: would two of them ever need different settings at the same time? A database connection, a logger and a mailer all fail that test, which is why frameworks pass them around as objects rather than reaching for a static DB::query().

Magic Methods, and Enums

PHP calls certain specially named methods automatically. You have already met __construct(); a few others are worth knowing, and a few are worth avoiding.

__toString() defines what happens when an object is used where a string is expected. It is genuinely useful for value objects — a Money or an Invoice number that has an obvious text form. Keep it simple and side-effect free, since it can be triggered by something as innocuous as string interpolation.

__get() and __set() intercept reads and writes of properties that are not accessible or do not exist. They look powerful and are usually a mistake in application code: they defeat type declarations, hide typos that would otherwise be errors, and make it impossible for your editor to tell you what a class actually has. Prefer explicit properties and methods.

__invoke() lets an object be called like a function, which is occasionally elegant for a single-purpose object such as a validator or a middleware step.

Finally, enums, added in PHP 8.1, deserve a mention here because they replace a very common use of class constants. An enum defines a fixed set of possible values as a type, so a function declaring OrderStatus $status cannot receive a misspelled string at all. Backed enums carry an underlying string or int for storing in a database, and tryFrom() converts a stored value back, returning null rather than throwing when the value is unknown. For anything with a fixed list of states, this is a substantial improvement over loose strings.

Example
<?php
class Money
{
    public function __construct(private int $paise) {}

    public function __toString(): string
    {
        return 'Rs ' . number_format($this->paise / 100, 2);
    }
}

$price = new Money(499950);
echo "Total: $price";        // Total: Rs 4,999.50

// __invoke: the object behaves like a function
class MinLength
{
    public function __construct(private int $min) {}

    public function __invoke(string $value): bool
    {
        return mb_strlen(trim($value)) >= $this->min;
    }
}
$atLeast10 = new MinLength(10);
var_dump($atLeast10('Hi there!'));   // false

// Enums (PHP 8.1): a fixed set of values, checked by the language
enum OrderStatus: string
{
    case Pending  = 'pending';
    case Paid     = 'paid';
    case Shipped  = 'shipped';

    public function label(): string
    {
        return match ($this) {
            OrderStatus::Pending => 'Awaiting payment',
            OrderStatus::Paid    => 'Payment received',
            OrderStatus::Shipped => 'On the way',
        };
    }
}

function updateStatus(OrderStatus $status): void
{
    echo $status->label();
}

updateStatus(OrderStatus::Paid);        // Payment received
// updateStatus('paid');                // TypeError - not a valid status

// Converting to and from the database value
$stored = 'shipped';
$status = OrderStatus::tryFrom($stored) ?? OrderStatus::Pending;
echo $status->value;                    // "shipped"
Notes
  • OrderStatus::from('typo') throws a ValueError, while tryFrom('typo') returns null. Use from() for values you control and tryFrom() for anything read from a database or a request, where an unexpected value is a real possibility.
Ask AI