Skip to main content

🏗️ Lesson 16: OOP — Classes & Objects

Up to now, you've written procedural PHP — functions and variables organized in files. Object-Oriented Programming (OOP) takes a different approach: you bundle related data and behavior into classes, then create objects from those classes. OOP is the foundation of modern PHP frameworks and the way professional PHP code is written today.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between procedural and object-oriented programming
  • Define classes with properties and methods
  • Create objects (instances) from classes using new
  • Use $this to access the current object inside methods
  • Write constructors with __construct() and constructor promotion
  • Control access with visibility modifiers: public, protected, private
  • Use static properties, methods, and class constants
  • Work with common magic methods like __toString()

Estimated Time: 50 minutes

Prerequisites: Lesson 7 (Functions), Lessons 8–9 (Arrays)

📑 In This Lesson

Why OOP?

In procedural code, data and the functions that operate on it are separate. As projects grow, this leads to problems: related functions scattered across files, data passed through long parameter lists, naming collisions, and difficulty understanding which functions work with which data.

Procedural vs. OOP

<?php
// === PROCEDURAL APPROACH ===
// Data is an array, functions are separate
$user = [
    "name"  => "Alice",
    "email" => "alice@example.com",
    "role"  => "admin",
];

function get_user_display_name(array $user): string {
    return $user["name"] . " (" . $user["role"] . ")";
}

function can_user_edit(array $user): bool {
    return $user["role"] === "admin" || $user["role"] === "editor";
}

echo get_user_display_name($user);
echo can_user_edit($user) ? "Can edit" : "Read only";


// === OOP APPROACH ===
// Data and behavior live together
class User {
    public function __construct(
        public string $name,
        public string $email,
        public string $role,
    ) {}

    public function getDisplayName(): string {
        return $this->name . " (" . $this->role . ")";
    }

    public function canEdit(): bool {
        return $this->role === "admin" || $this->role === "editor";
    }
}

$user = new User("Alice", "alice@example.com", "admin");
echo $user->getDisplayName();
echo $user->canEdit() ? "Can edit" : "Read only";

The OOP version bundles data ($name, $email, $role) with the functions that use it (getDisplayName(), canEdit()). The object carries everything it needs — no more passing arrays around and hoping the right keys exist.

📖 Key Terms

Class: A blueprint or template that defines properties (data) and methods (behavior). Think of it as a cookie cutter.

Object: A specific instance created from a class. Think of it as one cookie cut from the cutter. Each object has its own copy of the data.

Instance: Another word for object. "Creating an instance" = "creating an object."

classDiagram class User { +string name +string email +string role +getDisplayName() string +canEdit() bool } note for User "This is the CLASS (blueprint)"

Classes & Objects Basics

Defining a Class

<?php
// Class names use PascalCase (UpperCamelCase) by convention
class Product {
    // Properties (data)
    public string $name;
    public float $price;
    public int $stock;

    // Methods (behavior) — we'll add these soon
}

// That's it! You've defined a class.
// No product exists yet — this is just the blueprint.

Creating Objects with new

<?php
// Create objects (instances) from the class
$widget = new Product();
$widget->name  = "Widget";
$widget->price = 12.99;
$widget->stock = 150;

$gadget = new Product();
$gadget->name  = "Gadget";
$gadget->price = 49.99;
$gadget->stock = 30;

// Each object is independent — changing one doesn't affect the other
$widget->price = 14.99;
echo $gadget->price; // Still 49.99

// Access properties with the arrow operator ->
echo $widget->name;  // "Widget"
echo $widget->price; // 14.99

// Check the type
var_dump($widget instanceof Product); // true
echo get_class($widget);              // "Product"

✅ The Arrow Operator ->

In PHP, you access an object's properties and methods with -> (the arrow operator). It's PHP's equivalent of the dot operator in languages like JavaScript (object.property) or Python (object.property). So $widget->name means "the name property of the $widget object."

Properties & $this

Declaring Properties

<?php
class Task {
    // Typed properties (PHP 7.4+) — recommended
    public string $title;
    public string $description = "";    // Default value
    public bool $completed = false;     // Default value
    public ?string $dueDate = null;     // Nullable type

    // Untyped properties (older style — still works but less safe)
    // public $title;
    // public $completed = false;
}

$this — The Current Object

Inside a method, $this refers to the current object — the specific instance the method was called on. It's how an object accesses its own properties and methods.

<?php
class Task {
    public string $title;
    public bool $completed = false;

    public function complete(): void {
        // $this refers to whichever Task object called this method
        $this->completed = true;
    }

    public function describe(): string {
        $status = $this->completed ? "✅" : "⬜";
        return "$status {$this->title}";
    }
}

$task1 = new Task();
$task1->title = "Learn OOP";

$task2 = new Task();
$task2->title = "Build CRUD app";

// When $task1->complete() runs, $this === $task1
$task1->complete();

echo $task1->describe(); // "✅ Learn OOP"
echo $task2->describe(); // "⬜ Build CRUD app"

⚠️ Don't Forget $this->

A common mistake is writing $title inside a method instead of $this->title. Without $this, PHP treats it as a local variable — not the object's property. If you get "undefined variable" errors inside methods, check that you're using $this->.

Methods

Methods are functions that belong to a class. They can access the object's properties via $this, accept parameters, return values, and call other methods on the same object.

Defining Methods

<?php
class BankAccount {
    public string $owner;
    public float $balance = 0.0;

    /**
     * Deposit money into the account.
     */
    public function deposit(float $amount): void {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Deposit amount must be positive.");
        }
        $this->balance += $amount;
    }

    /**
     * Withdraw money from the account.
     */
    public function withdraw(float $amount): void {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Withdrawal amount must be positive.");
        }
        if ($amount > $this->balance) {
            throw new RuntimeException("Insufficient funds.");
        }
        $this->balance -= $amount;
    }

    /**
     * Get a formatted balance string.
     */
    public function getFormattedBalance(): string {
        return "$" . number_format($this->balance, 2);
    }

    /**
     * Transfer money to another account.
     */
    public function transferTo(BankAccount $recipient, float $amount): void {
        $this->withdraw($amount);        // Take from this account
        $recipient->deposit($amount);     // Give to recipient
    }
}

// Usage
$alice = new BankAccount();
$alice->owner = "Alice";
$alice->deposit(1000);

$bob = new BankAccount();
$bob->owner = "Bob";
$bob->deposit(500);

$alice->transferTo($bob, 200);

echo $alice->getFormattedBalance(); // "$800.00"
echo $bob->getFormattedBalance();   // "$700.00"

Method Chaining (Fluent Interface)

By returning $this from methods, you can chain multiple method calls together:

<?php
class QueryBuilder {
    private string $table = "";
    private array $conditions = [];
    private ?int $limitVal = null;
    private string $orderBy = "";

    public function from(string $table): self {
        $this->table = $table;
        return $this; // Return the object itself for chaining
    }

    public function where(string $condition): self {
        $this->conditions[] = $condition;
        return $this;
    }

    public function limit(int $limit): self {
        $this->limitVal = $limit;
        return $this;
    }

    public function orderBy(string $column, string $direction = "ASC"): self {
        $this->orderBy = "$column $direction";
        return $this;
    }

    public function toSQL(): string {
        $sql = "SELECT * FROM {$this->table}";
        if (!empty($this->conditions)) {
            $sql .= " WHERE " . implode(" AND ", $this->conditions);
        }
        if ($this->orderBy) {
            $sql .= " ORDER BY {$this->orderBy}";
        }
        if ($this->limitVal !== null) {
            $sql .= " LIMIT {$this->limitVal}";
        }
        return $sql;
    }
}

// Method chaining in action
$query = (new QueryBuilder())
    ->from("products")
    ->where("price > 10")
    ->where("stock > 0")
    ->orderBy("price", "DESC")
    ->limit(20)
    ->toSQL();

echo $query;
// SELECT * FROM products WHERE price > 10 AND stock > 0 ORDER BY price DESC LIMIT 20

✅ Return Type self

When a method returns $this for chaining, use the return type self (or static in inheritance scenarios). This tells PHP and your IDE that the method returns an instance of the same class.

Constructors

Setting properties one by one after creating an object is tedious and error-prone. A constructor is a special method that runs automatically when you create an object with new, allowing you to set up the object in one step.

The __construct Method

<?php
class Product {
    public string $name;
    public float $price;
    public int $stock;

    // Constructor — runs when you call new Product(...)
    public function __construct(string $name, float $price, int $stock = 0) {
        $this->name  = $name;
        $this->price = $price;
        $this->stock = $stock;
    }

    public function describe(): string {
        return "{$this->name}: \${$this->price} ({$this->stock} in stock)";
    }
}

// Now you can create fully initialized objects in one line
$widget = new Product("Widget", 12.99, 150);
$gadget = new Product("Gadget", 49.99);     // stock defaults to 0

echo $widget->describe(); // "Widget: $12.99 (150 in stock)"
echo $gadget->describe(); // "Gadget: $49.99 (0 in stock)"

Constructor Promotion (PHP 8.0+)

Writing $this->name = $name for every parameter is repetitive. PHP 8 introduced constructor promotion — add a visibility modifier to the constructor parameter, and PHP creates the property and assigns it automatically:

<?php
// BEFORE: Traditional constructor (verbose)
class ProductOld {
    public string $name;
    public float $price;
    public int $stock;

    public function __construct(string $name, float $price, int $stock = 0) {
        $this->name  = $name;
        $this->price = $price;
        $this->stock = $stock;
    }
}

// AFTER: Constructor promotion (concise — PHP 8+)
class Product {
    public function __construct(
        public string $name,
        public float $price,
        public int $stock = 0,
    ) {
        // No body needed! PHP creates properties automatically
        // But you CAN add validation or other logic here:
        if ($price < 0) {
            throw new InvalidArgumentException("Price cannot be negative.");
        }
    }
}

// Usage is identical
$widget = new Product("Widget", 12.99, 150);
echo $widget->name;  // "Widget"
echo $widget->price; // 12.99

📖 How Constructor Promotion Works

When you write public string $name as a constructor parameter (with a visibility modifier), PHP automatically:

  1. Creates a property $this->name with the same type
  2. Assigns the parameter value to it

It's pure syntactic sugar — the result is identical to the traditional approach. You can mix promoted and non-promoted parameters in the same constructor.

Constructor with Validation

<?php
class User {
    public function __construct(
        public readonly string $name,   // readonly = can't change after creation
        public readonly string $email,
        public string $role = "user",
    ) {
        // Validation runs after promotion assigns values
        if (empty($this->name)) {
            throw new InvalidArgumentException("Name cannot be empty.");
        }
        if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: $this->email");
        }
        if (!in_array($this->role, ["user", "editor", "admin"])) {
            throw new InvalidArgumentException("Invalid role: $this->role");
        }
    }
}

// Valid
$user = new User("Alice", "alice@example.com", "admin");

// Throws InvalidArgumentException: "Invalid email: not-an-email"
// $bad = new User("Bob", "not-an-email");

✅ readonly Properties (PHP 8.1+)

The readonly modifier means a property can only be set once (in the constructor). After that, any attempt to change it throws an error. This is great for data that should never change — user IDs, creation dates, email addresses, etc.

Visibility: public, protected, private

Visibility modifiers control who can access a property or method. This is encapsulation — hiding internal details and exposing only what external code needs.

Modifier Access From Use When
public Anywhere (inside class, child classes, outside) The method/property is part of the class's public API
protected Inside class + child classes only Internal logic that subclasses might need to override
private Inside the same class only Internal implementation details no one else should touch
<?php
class BankAccount {
    // Public — anyone can read the owner's name
    public string $owner;

    // Private — only this class can access the balance directly
    private float $balance = 0.0;

    // Private — internal transaction log
    private array $transactions = [];

    public function __construct(string $owner, float $initialDeposit = 0.0) {
        $this->owner = $owner;
        if ($initialDeposit > 0) {
            $this->deposit($initialDeposit);
        }
    }

    // Public — the controlled way to add money
    public function deposit(float $amount): void {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Amount must be positive.");
        }
        $this->balance += $amount;
        $this->logTransaction("deposit", $amount);
    }

    // Public — the controlled way to remove money
    public function withdraw(float $amount): void {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Amount must be positive.");
        }
        if ($amount > $this->balance) {
            throw new RuntimeException("Insufficient funds.");
        }
        $this->balance -= $amount;
        $this->logTransaction("withdrawal", $amount);
    }

    // Public — read the balance (but can't set it directly)
    public function getBalance(): float {
        return $this->balance;
    }

    // Public — get transaction history
    public function getTransactions(): array {
        return $this->transactions;
    }

    // Private — internal helper, no one outside needs this
    private function logTransaction(string $type, float $amount): void {
        $this->transactions[] = [
            "type"   => $type,
            "amount" => $amount,
            "date"   => date("Y-m-d H:i:s"),
            "balance_after" => $this->balance,
        ];
    }
}

$acct = new BankAccount("Alice", 1000);
$acct->deposit(500);
$acct->withdraw(200);

echo $acct->getBalance();    // 1300.00 ✅
echo $acct->owner;           // "Alice" ✅

// $acct->balance = 999999;  // Error! balance is private ❌
// $acct->logTransaction();  // Error! logTransaction is private ❌

📖 Why Encapsulation Matters

If $balance were public, any code anywhere could do $acct->balance = 999999 — bypassing all validation. By making it private and exposing deposit(), withdraw(), and getBalance(), you guarantee that every balance change goes through validation and gets logged. This is the core promise of encapsulation: the object controls its own state.

Getters and Setters

<?php
class Temperature {
    private float $celsius;

    public function __construct(float $celsius) {
        $this->setCelsius($celsius);
    }

    // Getter — read the value
    public function getCelsius(): float {
        return $this->celsius;
    }

    // Setter — write with validation
    public function setCelsius(float $celsius): void {
        if ($celsius < -273.15) {
            throw new InvalidArgumentException("Below absolute zero!");
        }
        $this->celsius = $celsius;
    }

    // Computed getter — derived from the stored value
    public function getFahrenheit(): float {
        return ($this->celsius * 9 / 5) + 32;
    }

    public function setFahrenheit(float $fahrenheit): void {
        $this->setCelsius(($fahrenheit - 32) * 5 / 9);
    }
}

$temp = new Temperature(100);
echo $temp->getCelsius();    // 100
echo $temp->getFahrenheit(); // 212

$temp->setFahrenheit(72);
echo $temp->getCelsius();    // 22.222...

✅ When to Use Getters/Setters

Use getters/setters when you need validation, computation, or side effects when reading/writing a property. For simple data objects where direct access is fine, public properties (or public readonly) are simpler and perfectly acceptable. Don't add getters/setters "just because" — they should serve a purpose.

Static Members & Constants

Regular properties and methods belong to a specific object. Static members belong to the class itself — they exist without creating any object.

Static Properties

<?php
class Counter {
    // Static property — shared by ALL instances (one copy for the whole class)
    private static int $count = 0;

    // Regular property — each instance has its own copy
    public string $name;

    public function __construct(string $name) {
        $this->name = $name;
        self::$count++;  // Increment the shared counter
    }

    // Static method — called on the class, not on an object
    public static function getCount(): int {
        return self::$count;
    }
}

echo Counter::getCount(); // 0 — no objects created yet

$a = new Counter("First");
$b = new Counter("Second");
$c = new Counter("Third");

echo Counter::getCount(); // 3 — three objects created
// The count is shared — it doesn't belong to $a, $b, or $c

⚠️ self:: vs. $this->

Use $this->property for regular (instance) members and self::$property for static members. Note the $ prefix on static property names: self::$count not self::count. Static methods cannot use $this because there's no object instance.

Static Methods

<?php
class MathHelper {
    // Static methods work without creating an object
    public static function clamp(float $value, float $min, float $max): float {
        return max($min, min($max, $value));
    }

    public static function percentage(float $part, float $total): float {
        if ($total == 0) return 0;
        return ($part / $total) * 100;
    }

    public static function randomFloat(float $min, float $max): float {
        return $min + mt_rand() / mt_getrandmax() * ($max - $min);
    }
}

// Call directly on the class — no object needed
echo MathHelper::clamp(150, 0, 100);        // 100
echo MathHelper::percentage(75, 200);        // 37.5
echo MathHelper::randomFloat(1.0, 10.0);     // 6.283...

Named Constructors (Static Factory Methods)

<?php
class Color {
    public function __construct(
        public readonly int $red,
        public readonly int $green,
        public readonly int $blue,
    ) {}

    // Static factory methods — alternative ways to create objects
    public static function fromHex(string $hex): self {
        $hex = ltrim($hex, "#");
        return new self(
            hexdec(substr($hex, 0, 2)),
            hexdec(substr($hex, 2, 2)),
            hexdec(substr($hex, 4, 2)),
        );
    }

    public static function red(): self {
        return new self(255, 0, 0);
    }

    public static function white(): self {
        return new self(255, 255, 255);
    }

    public function toHex(): string {
        return sprintf("#%02x%02x%02x", $this->red, $this->green, $this->blue);
    }
}

// Multiple ways to create a Color object
$c1 = new Color(100, 150, 200);
$c2 = Color::fromHex("#6495ED");  // Named constructor
$c3 = Color::red();                // Preset

echo $c2->toHex(); // "#6495ed"

Class Constants

<?php
class HttpStatus {
    // Constants — values that never change
    public const OK = 200;
    public const CREATED = 201;
    public const NOT_FOUND = 404;
    public const INTERNAL_ERROR = 500;

    // Constants can be any scalar type
    public const MESSAGES = [
        200 => "OK",
        201 => "Created",
        404 => "Not Found",
        500 => "Internal Server Error",
    ];

    public static function getMessage(int $code): string {
        return self::MESSAGES[$code] ?? "Unknown Status";
    }
}

// Access with :: (no $ prefix for constants)
echo HttpStatus::OK;                     // 200
echo HttpStatus::getMessage(404);        // "Not Found"

// Use in conditionals
if ($responseCode === HttpStatus::NOT_FOUND) {
    echo "Page not found!";
}

✅ Constants vs. Static Properties

Constants (const) are values that never change — HTTP codes, config keys, mathematical values. They can't be modified at runtime.

Static properties (static) are shared mutable state — counters, caches, registries. They can be changed but are shared across all instances.

Magic Methods

PHP defines several special methods that start with double underscores (__). These are called magic methods because PHP calls them automatically in specific situations. You've already met __construct(). Here are the most useful ones.

__toString() — String Representation

<?php
class Product {
    public function __construct(
        public string $name,
        public float $price,
    ) {}

    // Called when the object is used as a string
    public function __toString(): string {
        return "{$this->name} (\${$this->price})";
    }
}

$widget = new Product("Widget", 12.99);

// __toString is called automatically:
echo $widget;                    // "Widget ($12.99)"
echo "Product: $widget";         // "Product: Widget ($12.99)"
echo "Product: " . $widget;      // "Product: Widget ($12.99)"

__destruct() — Cleanup

<?php
class FileWriter {
    private $handle;

    public function __construct(string $path) {
        $this->handle = fopen($path, "a");
        if ($this->handle === false) {
            throw new RuntimeException("Cannot open file: $path");
        }
    }

    public function write(string $line): void {
        fwrite($this->handle, $line . "\n");
    }

    // Called automatically when the object is destroyed
    public function __destruct() {
        if ($this->handle) {
            fclose($this->handle);
        }
    }
}

$log = new FileWriter("app.log");
$log->write("Application started");
$log->write("Processing request");
// When $log goes out of scope or the script ends,
// __destruct runs and closes the file handle

__debugInfo() — Custom var_dump Output

<?php
class DatabaseConnection {
    public function __construct(
        private string $host,
        private string $user,
        private string $password, // Sensitive!
        private string $database,
    ) {}

    // Control what var_dump shows (hide sensitive data)
    public function __debugInfo(): array {
        return [
            "host"     => $this->host,
            "user"     => $this->user,
            "password" => "********",  // Don't expose the real password!
            "database" => $this->database,
        ];
    }
}

$db = new DatabaseConnection("localhost", "root", "super_secret", "myapp");
var_dump($db);
// Shows password as "********" instead of the real value

Common Magic Methods Reference

Method Called When Common Use
__construct() Object is created with new Initialize properties, validate data
__destruct() Object is destroyed Close files, connections, cleanup
__toString() Object is used as a string Display, logging, debugging
__debugInfo() var_dump() is called on object Hide sensitive data, format debug output
__get($name) Reading an inaccessible property Dynamic properties, lazy loading
__set($name, $val) Writing an inaccessible property Validation, virtual properties
__isset($name) isset() on inaccessible property Support isset()/empty() checks
__clone() Object is cloned with clone Deep copy nested objects

Putting It All Together

Let's build a complete, practical class that uses everything from this lesson.

A TodoList Class

<?php
class TodoItem {
    public function __construct(
        public readonly string $id,
        public string $title,
        public bool $completed = false,
        public readonly string $createdAt = "",
    ) {
        // Generate values if not provided
        if ($this->id === "") {
            // Use reflection workaround for readonly in constructor
        }
    }

    public function __toString(): string {
        $icon = $this->completed ? "✅" : "⬜";
        return "$icon {$this->title}";
    }
}

class TodoList {
    /** @var TodoItem[] */
    private array $items = [];
    private static int $nextId = 1;
    private string $dataFile;

    public const MAX_ITEMS = 100;

    public function __construct(string $dataFile = "todos.json") {
        $this->dataFile = $dataFile;
        $this->load();
    }

    // --- Public API ---

    public function add(string $title): TodoItem {
        if (count($this->items) >= self::MAX_ITEMS) {
            throw new OverflowException("Maximum of " . self::MAX_ITEMS . " items reached.");
        }

        $item = new TodoItem(
            id: (string)self::$nextId++,
            title: trim($title),
            createdAt: date("Y-m-d H:i:s"),
        );
        $this->items[$item->id] = $item;
        $this->save();
        return $item;
    }

    public function complete(string $id): void {
        $item = $this->findOrFail($id);
        $item->completed = true;
        $this->save();
    }

    public function remove(string $id): void {
        $this->findOrFail($id); // Ensure it exists
        unset($this->items[$id]);
        $this->save();
    }

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

    public function getPending(): array {
        return array_values(array_filter(
            $this->items,
            fn(TodoItem $item) => !$item->completed
        ));
    }

    public function getCompleted(): array {
        return array_values(array_filter(
            $this->items,
            fn(TodoItem $item) => $item->completed
        ));
    }

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

    public function countPending(): int {
        return count($this->getPending());
    }

    public static function getMaxItems(): int {
        return self::MAX_ITEMS;
    }

    // --- Private helpers ---

    private function findOrFail(string $id): TodoItem {
        if (!isset($this->items[$id])) {
            throw new InvalidArgumentException("Todo item not found: $id");
        }
        return $this->items[$id];
    }

    private function save(): void {
        $data = array_map(fn(TodoItem $item) => [
            "id"        => $item->id,
            "title"     => $item->title,
            "completed" => $item->completed,
            "createdAt" => $item->createdAt,
        ], $this->items);

        file_put_contents(
            $this->dataFile,
            json_encode(array_values($data), JSON_PRETTY_PRINT),
            LOCK_EX
        );
    }

    private function load(): void {
        if (!file_exists($this->dataFile)) return;

        $json = file_get_contents($this->dataFile);
        $data = json_decode($json, true) ?? [];

        foreach ($data as $row) {
            $item = new TodoItem(
                id: $row["id"],
                title: $row["title"],
                completed: $row["completed"],
                createdAt: $row["createdAt"],
            );
            $this->items[$item->id] = $item;
            self::$nextId = max(self::$nextId, (int)$item->id + 1);
        }
    }
}

// === Usage ===
$todos = new TodoList("data/my_todos.json");

$item1 = $todos->add("Learn PHP OOP");
$item2 = $todos->add("Build a CRUD app");
$item3 = $todos->add("Deploy to production");

$todos->complete($item1->id);

echo "Total: {$todos->count()}\n";
echo "Pending: {$todos->countPending()}\n\n";

foreach ($todos->getAll() as $item) {
    echo "$item\n"; // __toString is called
}
// ✅ Learn PHP OOP
// ⬜ Build a CRUD app
// ⬜ Deploy to production
classDiagram class TodoItem { +readonly string id +string title +bool completed +readonly string createdAt +__toString() string } class TodoList { -TodoItem[] items -static int nextId -string dataFile +const MAX_ITEMS = 100 +add(title) TodoItem +complete(id) void +remove(id) void +getAll() array +getPending() array +getCompleted() array +count() int +countPending() int -findOrFail(id) TodoItem -save() void -load() void } TodoList "1" *-- "*" TodoItem : contains

✅ Notice the Patterns

  • Encapsulation: $items is private — external code uses add(), complete(), remove()
  • Constructor promotion: TodoItem uses promoted properties for conciseness
  • Static members: $nextId tracks IDs across all instances; MAX_ITEMS is a class constant
  • Private helpers: save(), load(), findOrFail() are implementation details
  • Magic methods: __toString() on TodoItem for clean display

Hands-On Exercises

🏋️ Exercise 1: Rectangle Class

Objective: Build a Rectangle class with calculated properties and validation.

Instructions:

  1. Create a Rectangle class with width and height (both float, private)
  2. Constructor that validates both are positive numbers
  3. Getters: getWidth(), getHeight()
  4. Computed methods: getArea(), getPerimeter(), isSquare()
  5. A scale(float $factor) method that multiplies both dimensions
  6. A __toString() that returns "Rectangle(w×h)"
  7. A static factory method square(float $size) that creates a square
💡 Hint

A square is just a rectangle where width equals height. For isSquare(), compare with a small epsilon (e.g., abs($this->width - $this->height) < 0.0001) to handle floating point imprecision.

✅ Solution
<?php
class Rectangle {
    private float $width;
    private float $height;

    public function __construct(float $width, float $height) {
        if ($width <= 0 || $height <= 0) {
            throw new InvalidArgumentException("Dimensions must be positive.");
        }
        $this->width = $width;
        $this->height = $height;
    }

    public function getWidth(): float { return $this->width; }
    public function getHeight(): float { return $this->height; }

    public function getArea(): float {
        return $this->width * $this->height;
    }

    public function getPerimeter(): float {
        return 2 * ($this->width + $this->height);
    }

    public function isSquare(): bool {
        return abs($this->width - $this->height) < 0.0001;
    }

    public function scale(float $factor): void {
        if ($factor <= 0) {
            throw new InvalidArgumentException("Scale factor must be positive.");
        }
        $this->width *= $factor;
        $this->height *= $factor;
    }

    public function __toString(): string {
        return "Rectangle({$this->width}×{$this->height})";
    }

    public static function square(float $size): self {
        return new self($size, $size);
    }
}

// Test it
$rect = new Rectangle(10, 5);
echo $rect;                  // "Rectangle(10×5)"
echo $rect->getArea();       // 50
echo $rect->getPerimeter();  // 30
echo $rect->isSquare();      // false

$rect->scale(2);
echo $rect;                  // "Rectangle(20×10)"

$sq = Rectangle::square(7);
echo $sq;                    // "Rectangle(7×7)"
echo $sq->isSquare();        // true

🏋️ Exercise 2: Inventory System

Objective: Build an Inventory class that manages a collection of products.

Instructions:

  1. Create a Product class with: name (string), price (float), stock (int) — use constructor promotion
  2. Add a getTotalValue() method (price × stock)
  3. Create an Inventory class that stores an array of Product objects
  4. Add methods: addProduct(Product), removeProduct(string $name), findProduct(string $name): ?Product
  5. Add methods: getTotalValue() (sum of all products), getLowStock(int $threshold = 10): array
  6. Add a static counter that tracks total Product instances created
💡 Hint

Store products in an associative array keyed by name for easy lookup and removal. Use array_filter for getLowStock(). The static counter should be a private static int incremented in Product::__construct().

✅ Solution
<?php
class Product {
    private static int $instanceCount = 0;

    public function __construct(
        public readonly string $name,
        public float $price,
        public int $stock = 0,
    ) {
        if ($price < 0) throw new InvalidArgumentException("Price must be non-negative.");
        if ($stock < 0) throw new InvalidArgumentException("Stock must be non-negative.");
        self::$instanceCount++;
    }

    public function getTotalValue(): float {
        return $this->price * $this->stock;
    }

    public function __toString(): string {
        return sprintf("%s: $%.2f (%d in stock)", $this->name, $this->price, $this->stock);
    }

    public static function getInstanceCount(): int {
        return self::$instanceCount;
    }
}

class Inventory {
    /** @var array<string, Product> */
    private array $products = [];

    public function addProduct(Product $product): void {
        $this->products[$product->name] = $product;
    }

    public function removeProduct(string $name): void {
        if (!isset($this->products[$name])) {
            throw new InvalidArgumentException("Product not found: $name");
        }
        unset($this->products[$name]);
    }

    public function findProduct(string $name): ?Product {
        return $this->products[$name] ?? null;
    }

    public function getTotalValue(): float {
        return array_sum(array_map(
            fn(Product $p) => $p->getTotalValue(),
            $this->products
        ));
    }

    public function getLowStock(int $threshold = 10): array {
        return array_values(array_filter(
            $this->products,
            fn(Product $p) => $p->stock < $threshold
        ));
    }

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

// Test
$inv = new Inventory();
$inv->addProduct(new Product("Widget", 12.99, 150));
$inv->addProduct(new Product("Gadget", 49.99, 5));
$inv->addProduct(new Product("Doohickey", 8.50, 200));
$inv->addProduct(new Product("Thingamajig", 99.99, 3));

echo "Products: {$inv->count()}\n";
echo "Total value: $" . number_format($inv->getTotalValue(), 2) . "\n";

echo "\nLow stock:\n";
foreach ($inv->getLowStock() as $p) {
    echo "  $p\n";
}

echo "\nTotal Product instances created: " . Product::getInstanceCount();

🎯 Quick Quiz

Question 1: What does $this refer to inside a class method?

Question 2: What is constructor promotion in PHP 8?

Question 3: If a property is private, who can access it?

Question 4: How do you access a static property inside its class?

Question 5: When is __toString() called?

Summary

🎉 Key Takeaways

  • Classes are blueprints; objects are instances created with new
  • Properties hold data; methods define behavior — accessed with ->
  • $this refers to the current object instance inside methods
  • __construct() initializes objects; constructor promotion (PHP 8) reduces boilerplate
  • readonly properties (PHP 8.1) can only be set once in the constructor
  • Visibility: public (anywhere), protected (class + children), private (same class only)
  • Encapsulation: make properties private, expose controlled access through methods
  • static members belong to the class, not instances — accessed with ::
  • const defines immutable class values; static factory methods provide alternative constructors
  • Magic methods like __toString(), __destruct(), __debugInfo() are called automatically by PHP

📚 Additional Resources

🚀 What's Next?

Now that you can build classes and objects, Lesson 17: OOP — Inheritance, Interfaces & Traits will show you how to extend classes, define shared contracts with interfaces, and mix in reusable behavior with traits. These tools let you build flexible, maintainable class hierarchies.

🎉 Congratulations!

You've learned the fundamentals of object-oriented PHP! You can now create classes with properties, methods, constructors, visibility control, and static members — the building blocks of every modern PHP application.