OOP — Classes, Objects, Namespaces
The mental model
Quick mental model that actually helps. Classes are like blueprints — they describe what an object will have (its data, called properties) and what it can do (its operations, called methods). Objects are the actual things built from the blueprint. The blueprint for "House" describes that houses have rooms and doors and you can open or close them. The blueprint can be used to build many actual houses, each with their own state (whose house is it, are the doors open right now), but they share the same shape and capabilities.
PHP's object system has been growing for decades and you can feel that history a bit. But modern PHP (8.0+) is genuinely good — type hints, readonly properties, enums, constructor promotion. We're focusing on the modern subset, not every feature in the manual.
A class, an object
class Medication {
public string $name;
public int $dose_mg;
public string $status;
public function __construct(string $name, int $dose_mg, string $status) {
$this->name = $name;
$this->dose_mg = $dose_mg;
$this->status = $status;
}
public function describe(): string {
return "$this->name $this->dose_mg mg — $this->status";
}
}
$ibu = new Medication('Ibuprofen', 200, 'due');
echo $ibu->describe();
Reading that piece by piece. class Medication defines a new class (the blueprint). public string $name declares a property — a piece of data each Medication will have. public means anyone can read/write it. __construct is a special method called automatically when you create a new instance with new. $this refers to "the current instance" — inside a method, $this->name means "this particular medication's name field." new Medication(...) creates a new instance.
Things to internalize: $this is special (not self, not bare this). Method and property access uses -> (not .). Typed properties (public string $name) are PHP 7.4+ — always type them.
🐍 Python translation table:
- PHP
$this->name≈ Pythonself.name - PHP
->≈ Python. - PHP
__construct≈ Python__init__ - PHP
new Medication(...)≈ PythonMedication(...)(nonewkeyword in Python) - PHP
self::for static ≈ Pythoncls.inside @classmethod
Constructor promotion (PHP 8+) — way cleaner
The constructor in the example above is repetitive — we list each property three times (in the property declaration, in the constructor parameter, in the assignment). PHP 8 added a shortcut:
class Medication {
public function __construct(
public string $name,
public int $dose_mg,
public string $status,
) {}
public function describe(): string {
return "$this->name $this->dose_mg mg — $this->status";
}
}
The constructor parameters now have public in front of them. That tells PHP "this is a parameter AND a property AND assign it automatically." Same behavior as the verbose version, way less typing. Always write classes this way on PHP 8+.
🐍 Python: Constructor promotion ≈ Python's @dataclass. Both skip the boilerplate of self.x = x; self.y = y in __init__. PHP: public string $name in constructor params. Python: @dataclass class Thing: name: str. Same idea, different syntax.
Visibility — public, private, protected
Class members can have three visibility levels controlling who can access them:
- public — anyone can read/write. The default "API" of your class.
- private — only this class can see it. Internal implementation details.
- protected — this class and its subclasses can see it.
class BankAccount {
public function __construct(private int $balance_cents = 0) {}
public function deposit(int $cents): void {
if ($cents <= 0) throw new InvalidArgumentException('Positive only.');
$this->balance_cents += $cents;
}
public function balance(): int {
return $this->balance_cents;
}
}
$acc = new BankAccount();
$acc->deposit(1000);
echo $acc->balance(); // 1000
$acc->balance_cents = -1_000_000; // ☠ Error: private property
Why does visibility matter? Because it lets you enforce invariants. In the BankAccount example, the only way to change the balance is through deposit(), which validates the input. By making balance_cents private, you guarantee nobody can directly set it to a negative value or bypass the validation.
Default to private. Promote to protected only when subclasses genuinely need access. Use public for the deliberate API surface — the methods other code is supposed to call.
Analogy: like only giving guests access to the living room (public) of your house, not your sock drawer (private). The bathroom (protected) is accessible to family (subclasses) but not random visitors.
🐍 Python: Python uses naming conventions for visibility — _field ≈ "treat as private," __field triggers name mangling. PHP enforces private/protected at runtime. PHP is less polite (yells at you for poking internals); Python trusts you to follow conventions. Different philosophies.
readonly — immutable properties (PHP 8.1+)
PHP 8.1 added the readonly modifier — a property that can be set once (in the constructor) and never changed afterward:
class Point {
public function __construct(
public readonly float $x,
public readonly float $y,
) {}
}
$p = new Point(1.0, 2.0);
echo $p->x; // OK to read
$p->x = 99.0; // ☠ Error: readonly
Why immutability matters: it prevents a whole class of bugs where shared objects get unexpectedly mutated. If you pass a Point to ten different functions, none of them can change its X coordinate. The Point you handed out is the same Point everyone sees.
Use readonly for "value objects" — small immutable things that represent values (money amounts, coordinates, date ranges, IDs). Anywhere mutation would actually be a bug instead of a feature, slap readonly on it.
Inheritance vs composition
// Inheritance — for "is-a"
class Dog extends Animal { /* ... */ }
// Composition — for "has-a"
class Car {
public function __construct(private Engine $engine) {}
}
Composition beats inheritance. This is one of those rules that took the industry decades to fully agree on, but it's solid now. Use inheritance for genuine "is-a" relationships you're certain about. Use composition (one class holds another as a property) by default for "has-a" relationships. Don't build six-story class hierarchies. Most "extends" reaches in real code should have been "holds as property" instead.
Interfaces — contracts without implementation
An interface declares what methods a class must have, without saying how they work. It's a "contract" — any class that "implements" the interface promises to provide those methods.
interface Logger {
public function log(string $level, string $message): void;
}
class FileLogger implements Logger {
public function __construct(private string $path) {}
public function log(string $level, string $message): void {
file_put_contents($this->path, "[$level] $message\n", FILE_APPEND);
}
}
class NullLogger implements Logger {
public function log(string $level, string $message): void {
// discard, do nothing
}
}
function process(Logger $log): void {
$log->log('info', 'Processing started');
}
process(new FileLogger('/tmp/app.log'));
process(new NullLogger()); // also works — same interface
The magic: the process function takes "anything that implements Logger." It doesn't care whether you pass a FileLogger, a NullLogger, or some future DatabaseLogger. As long as the class implements the interface, it works.
Why this matters: swappable implementations (file logger today, database logger tomorrow), testing (inject a fake logger that records what was logged), and clean abstraction boundaries. Don't go interface-crazy on small projects — reach for them when there's a real need.
Enums — fixed sets of values (PHP 8.1+)
enum Status: string {
case Due = 'due';
case Taken = 'taken';
case Overdue = 'overdue';
case Skipped = 'skipped';
public function color(): string {
return match($this) {
self::Due => '#5bf0ff',
self::Taken => '#4eff7a',
self::Overdue => '#ff4e6a',
self::Skipped => '#ffa64e',
};
}
}
$s = Status::Due;
echo $s->value; // 'due'
echo $s->color(); // '#5bf0ff'
$s = Status::from('pending'); // throws if invalid
$s = Status::tryFrom('pending'); // returns null if invalid
Each case is a distinct value. Type-safe (a function parameter typed as Status can only accept one of the four cases). Can have methods that take advantage of the fixed set. Works great with match.
Anywhere you have a fixed set of options ("status", "role", "channel", "priority"), use an enum instead of magic strings. Way safer, way more refactorable, and the compiler catches typos.
🐍 Python: Almost identical to enum.Enum. Status::Due->value ≈ Status.DUE.value. Status::from('done') ≈ Status('done') (raises ValueError if invalid). Status::tryFrom('done') has no direct Python parallel — Python would raise; you'd wrap in try/except. PHP's tryFrom is honestly nicer here.
Namespaces — folders for class names
As your project grows, you'll have lots of classes — and at some point, two classes will want the same name. User in admin vs User in the public area. Namespaces solve this. A namespace is like a folder for class names — it gives each class a unique fully-qualified address.
// File: src/Medtrack/Medication.php
<?php
namespace Medtrack;
class Medication {
// ...
}
// File: src/main.php
<?php
use Medtrack\Medication;
$m = new Medication(/* ... */);
The namespace Medtrack; declaration says "this file's classes live under the Medtrack namespace." So the full name of the class is Medtrack\Medication. The use statement in the other file imports it so you can use the short name.
Convention: VendorName\PackageName\Subnamespace. So an open-source library might be Symfony\Component\HttpFoundation. Your own project might be YourCompany\YourProject\Whatever.
Composer autoload — no more require statements
Here's the huge payoff for namespaces. Once you set up Composer and add a psr-4 entry in composer.json:
{
"autoload": {
"psr-4": {
"Medtrack\\": "src/"
}
}
}
...then any class Medtrack\Foo\Bar is automatically loaded from src/Foo/Bar.php the first time you mention it. No require statements anywhere in your code — Composer handles it all behind the scenes.
composer dump-autoload # rebuild the autoloader after adding files
This setup transforms what PHP projects feel like to work in. From here on, you're writing the same kind of structured, modular code that Java, C#, or modern TypeScript devs write — just with PHP's flavor.
🐍 Python: PHP namespaces ≈ Python packages. namespace Medtrack; ≈ being inside the medtrack/ package directory. use Medtrack\Medication; ≈ from medtrack import Medication. PSR-4 autoload ≈ Python's import system finding modules by folder structure. composer.json ≈ pyproject.toml. composer install ≈ pip install -r requirements.txt. vendor/ ≈ a virtualenv's site-packages.
Build: MedTrack OOP Refactor (The Final Boss)
Time to refactor MedTrack to use proper classes. Same UI, dramatically cleaner internals. This is what real PHP projects look like — controllers are thin, logic lives in classes, repositories handle database access, value objects represent data.
- Set up Composer autoload:
Opencd /home/erictey/server composer init --no-interaction --name=eric/medtrackcomposer.jsonand add:
Then"autoload": { "psr-4": { "Medtrack\\\\": "src/" } }composer dump-autoload. - Create the Status enum at
src/Status.phpusing the enum example earlier in this chapter (the one with color() and label() methods). - Create the Medication value object at
src/Medication.php:<?php declare(strict_types=1); namespace Medtrack; class Medication { public function __construct( public readonly int $id, public readonly int $user_id, public readonly string $name, public readonly int $dose_mg, public readonly Status $status, public readonly string $created_at, ) {} public function format_dose(): string { if ($this->dose_mg >= 1000) { return ($this->dose_mg / 1000) . 'g'; } return $this->dose_mg . 'mg'; } public static function from_row(array $row): self { return new self( id: (int)$row['id'], user_id: (int)$row['user_id'], name: (string)$row['name'], dose_mg: (int)$row['dose'], status: Status::from($row['status']), created_at: (string)$row['created_at'], ); } } - Create the MedicationRepository at
src/MedicationRepository.php:<?php declare(strict_types=1); namespace Medtrack; use PDO; class MedicationRepository { public function __construct(private PDO $pdo) {} /** @return Medication[] */ public function for_user(int $user_id): array { $stmt = $this->pdo->prepare( "SELECT * FROM meds WHERE user_id = ? ORDER BY created_at DESC" ); $stmt->execute([$user_id]); return array_map(Medication::from_row(...), $stmt->fetchAll()); } public function add(int $user_id, string $name, int $dose, Status $status): int { $stmt = $this->pdo->prepare( "INSERT INTO meds (user_id, name, dose, status) VALUES (?, ?, ?, ?)" ); $stmt->execute([$user_id, $name, $dose, $status->value]); return (int) $this->pdo->lastInsertId(); } public function delete(int $user_id, int $med_id): void { $stmt = $this->pdo->prepare( "DELETE FROM meds WHERE id = ? AND user_id = ?" ); $stmt->execute([$med_id, $user_id]); } } - Refactor
add-med.phpto use the new classes — require Composer's autoload, use the Medtrack classes, use the repository for all DB access, use the enum for status validation. The controller becomes mostly "read input, call repo method, render template." - Run
composer dump-autoloadto register the new classes. - Test: log in, add meds, delete meds. Same behavior, way cleaner code.
Stretch goals:
- Add an
update()method to the repository. - Create a
UserRepositorywith the same pattern — same code shape, different table. - Extract the HTML into a separate
views/medtrack.phpfile that the controller includes. - Add unit tests with PHPUnit for the Medication value object and the format_dose method.
What you flexed: constructor promotion, readonly properties, enums with methods, repository pattern, Composer autoload, named arguments, first-class callable syntax in array_map. This is shipping-grade PHP. The same shape works at any scale.
You finished Part 2!
Real talk — take a moment and look at what you can do now. You can write modern, typed PHP with strict types on. Control flow with match, foreach, early returns. Manipulate arrays and strings fluently. Write reusable typed functions. Handle forms safely with validation, CSRF, and escaped output. Query a database with PDO and prepared statements (no injection holes). Build authentication with proper password hashing. Structure code into classes, namespaces, and repositories. Use Composer autoload like a pro.
That's the actual core of PHP. Everything else — frameworks (Laravel, Symfony), templating engines (Twig, Blade), test runners (PHPUnit) — is just built on top of this foundation. Pick a small real project, build it end-to-end using what you've learned here, and you'll go from "knows PHP" to "ships PHP" faster than you'd expect. Or, if you want to keep building, head to Part 3 (HOMESTREAM) and build a media streaming server with everything you just learned. Either path is a great use of your next weekend.
Now go build something cool. I'm rooting for you.