THU.JUN.18
2026
23:35:29
← back to modules MODULE · 02 · PHP PART 2
0 / 10 chapters complete · 0%

OOP — Classes, Objects, Namespaces

Object-oriented programming is a way to organize code by grouping related data and the operations on that data into "objects." This chapter is the working set of modern PHP OOP — constructor promotion, readonly, enums, namespaces, Composer autoload. By the end, you'll refactor MedTrack into a clean class-based design. This is what real PHP projects look like.
Classes = blueprints. Objects = instances. Use constructor promotion. Default to private. readonly for value objects. Composition beats inheritance. Enums for fixed sets. Namespaces + Composer autoload for real projects.

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 ≈ Python self.name
  • PHP -> ≈ Python .
  • PHP __construct ≈ Python __init__
  • PHP new Medication(...) ≈ Python Medication(...) (no new keyword in Python)
  • PHP self:: for static ≈ Python cls. 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->valueStatus.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.jsonpyproject.toml. composer installpip 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.

  1. Set up Composer autoload:
    cd /home/erictey/server
    composer init --no-interaction --name=eric/medtrack
    Open composer.json and add:
    "autoload": {
        "psr-4": {
            "Medtrack\\\\": "src/"
        }
    }
    Then composer dump-autoload.
  2. Create the Status enum at src/Status.php using the enum example earlier in this chapter (the one with color() and label() methods).
  3. 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'],
            );
        }
    }
  4. 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]);
        }
    }
  5. Refactor add-med.php to 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."
  6. Run composer dump-autoload to register the new classes.
  7. Test: log in, add meds, delete meds. Same behavior, way cleaner code.

Stretch goals:

  • Add an update() method to the repository.
  • Create a UserRepository with the same pattern — same code shape, different table.
  • Extract the HTML into a separate views/medtrack.php file 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.