🏗️ 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
$thisto access the current object inside methods - Write constructors with
__construct()and constructor promotion - Control access with visibility modifiers:
public,protected,private - Use
staticproperties, 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."
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:
- Creates a property
$this->namewith the same type - 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
✅ Notice the Patterns
- Encapsulation:
$itemsis private — external code usesadd(),complete(),remove() - Constructor promotion:
TodoItemuses promoted properties for conciseness - Static members:
$nextIdtracks IDs across all instances;MAX_ITEMSis 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:
- Create a
Rectangleclass withwidthandheight(both float, private) - Constructor that validates both are positive numbers
- Getters:
getWidth(),getHeight() - Computed methods:
getArea(),getPerimeter(),isSquare() - A
scale(float $factor)method that multiplies both dimensions - A
__toString()that returns "Rectangle(w×h)" - 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:
- Create a
Productclass with: name (string), price (float), stock (int) — use constructor promotion - Add a
getTotalValue()method (price × stock) - Create an
Inventoryclass that stores an array of Product objects - Add methods:
addProduct(Product),removeProduct(string $name),findProduct(string $name): ?Product - Add methods:
getTotalValue()(sum of all products),getLowStock(int $threshold = 10): array - 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
-> $thisrefers to the current object instance inside methods__construct()initializes objects; constructor promotion (PHP 8) reduces boilerplatereadonlyproperties (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
staticmembers belong to the class, not instances — accessed with::constdefines immutable class values; static factory methods provide alternative constructors- Magic methods like
__toString(),__destruct(),__debugInfo()are called automatically by PHP
📚 Additional Resources
- PHP Manual: Classes and Objects
- PHP Manual: Constructors and Destructors
- PHP Manual: Visibility
- PHP Manual: Static Properties and Methods
- PHP Manual: Magic Methods
🚀 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.