Week 3: Object-Oriented PHP

The last two weeks were procedural — top-to-bottom scripts with functions and arrays. This week moves to object-oriented PHP: classes as the unit of organization, interfaces as contracts, traits for shared behavior, and namespaces so class names don't collide as a codebase grows. Every framework from Week 9 onward assumes this vocabulary.

Module 3 of 28 Week 3 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define classes with typed properties, constructors & visibility modifiers
  • Use inheritance, interfaces & traits to share and constrain behavior
  • Organize code with namespaces & understand the PSR-4 autoloading convention

1. Classes & Properties

A class bundles related data (properties) and behavior (methods) into one unit. PHP 8 lets you type-hint properties directly, the same way you type-hint function parameters:

Product.php
<?php
declare(strict_types=1);

class Product
{
    public string $name;
    public float $price;
    public bool $inStock = true; // default value

    public function formattedPrice(): string
    {
        return '$' . number_format($this->price, 2);
    }
}

$keyboard = new Product();
$keyboard->name = "Mechanical Keyboard";
$keyboard->price = 89.99;

echo $keyboard->formattedPrice(); // "$89.99"

$this inside a method refers to the specific object the method was called on — $keyboard->formattedPrice() runs with $this === $keyboard. Every property access from outside the class uses ->, PHP's object operator (not the dot used in many other languages).

2. Constructors & Visibility

Setting every property manually after new is error-prone — a constructor lets you require values up front. PHP 8's constructor property promotion collapses the common declare-property-then-assign-in-constructor pattern into one line per property:

Product.php — promoted properties
<?php
declare(strict_types=1);

class Product
{
    public function __construct(
        public string $name,
        public float $price,
        private int $stockCount = 0,
    ) {}

    public function isInStock(): bool
    {
        return $this->stockCount > 0;
    }
}

$keyboard = new Product("Mechanical Keyboard", 89.99, 5);
echo $keyboard->name;          // "Mechanical Keyboard" -- public, accessible outside
echo $keyboard->isInStock();   // true
// $keyboard->stockCount;      // Error: Cannot access private property

Visibility modifiers control where a property or method can be accessed from:

  • public — accessible from anywhere, including outside the class
  • protected — accessible from within the class and its subclasses
  • private — accessible only from within the declaring class itself
Default to private, widen when you need to

Starting properties as private and only exposing what callers actually need (through methods, or by widening deliberately) keeps a class's internal representation free to change without breaking code elsewhere — a smaller, more honest public surface is easier to reason about and to refactor safely.

3. Inheritance & Interfaces

A class can extend exactly one parent class, inheriting its public and protected members and optionally overriding methods:

inheritance.php
<?php
declare(strict_types=1);

class DigitalProduct extends Product
{
    public function __construct(
        string $name,
        float $price,
        public string $downloadUrl,
    ) {
        parent::__construct($name, $price, stockCount: PHP_INT_MAX); // never out of stock
    }
}

An interface declares a contract — method signatures with no implementation — that any implementing class must fulfill. Unlike single inheritance, a class can implement multiple interfaces:

interfaces.php
<?php
declare(strict_types=1);

interface Discountable
{
    public function applyDiscount(float $percentOff): float;
}

class Product implements Discountable
{
    public function __construct(public string $name, public float $price) {}

    public function applyDiscount(float $percentOff): float
    {
        return $this->price * (1 - $percentOff / 100);
    }
}

function printSalePrice(Discountable $item, float $percentOff): void
{
    echo number_format($item->applyDiscount($percentOff), 2);
}

Typing the parameter as Discountable rather than Product means printSalePrice() works with any class that implements the interface — this is the basis of dependency injection, which shows up constantly once Laravel enters the picture in Week 9.

4. Traits

PHP has no multiple inheritance — a class can only extend one parent. A trait is a way to share method implementations across unrelated classes without inheritance, by literally copying the trait's methods into the class at compile time:

traits.php
<?php
declare(strict_types=1);

trait Timestamps
{
    private ?\DateTimeImmutable $createdAt = null;

    public function markCreated(): void
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function createdAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }
}

class Product
{
    use Timestamps;

    public function __construct(public string $name) {}
}

class Order
{
    use Timestamps;

    public function __construct(public int $id) {}
}

$product = new Product("Keyboard");
$product->markCreated();
echo $product->createdAt()->format('Y-m-d'); // today's date

Product and Order share no inheritance relationship, but both get the exact same markCreated()/createdAt() behavior by using the same trait — Laravel's Eloquent models use this pattern heavily (its own SoftDeletes trait works exactly this way).

5. Namespaces & PSR-4

A namespace is a prefix that groups related classes and avoids name collisions — two libraries can both define a Logger class as long as they live in different namespaces:

src/Shop/Product.php
<?php
declare(strict_types=1);

namespace App\Shop;

class Product
{
    public function __construct(public string $name, public float $price) {}
}
src/index.php
<?php
declare(strict_types=1);

require __DIR__ . '/Shop/Product.php';

use App\Shop\Product;

$item = new Product("Keyboard", 89.99);
// Or, without the 'use' import: new \App\Shop\Product(...)

Manually require-ing every file doesn't scale. PSR-4 is the community convention — implemented by Composer's autoloader, covered in full next week — that maps a namespace prefix directly onto a directory structure, so App\Shop\Product resolves to src/Shop/Product.php automatically, with no manual require calls at all.

6. Hands-on Exercise

Hands-on

Model a small shopping cart with OOP

Apply classes, interfaces and a trait to a self-contained cart domain.

Requirements:

  1. Define a Sellable interface with a price(): float method.
  2. Create a Product class implementing Sellable, using constructor property promotion for name, price and private stockCount.
  3. Create a DigitalProduct class that extends Product and adds a downloadUrl property, calling parent::__construct().
  4. Write a Loggable trait with a log(string $message): void method that echoes a timestamped message, and use it in both classes.
  5. Create a Cart class with an array of Sellable items, an addItem(Sellable $item): void method, and a total(): float method that sums every item's price().
  6. Put everything under the namespace App\Shop; and confirm the script runs correctly with a mix of Product and DigitalProduct items in the cart.
Hint

Typing Cart::addItem(Sellable $item) against the interface, not the concrete Product class, is exactly what lets the same method accept both Product and DigitalProduct without any special-casing.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does constructor property promotion actually save you from writing?

The separate property declaration and the $this->name = $name; assignment line inside the constructor body. Prefixing a constructor parameter with a visibility modifier (public string $name) declares the property and assigns it from the argument in one step — pure boilerplate reduction with no behavior difference from writing it out longhand.

Q2

Why type a function parameter as an interface (Discountable) instead of a concrete class (Product)?

Typing against the interface means the function accepts any class that implements it, not just Product — a new class implementing Discountable six months later works with that function automatically, no changes required. This is the core idea behind dependency injection, which every Laravel controller and service you'll write from Week 9 onward leans on.

Q3

Why reach for a trait instead of a shared base class?

PHP classes can only extend one parent, but a class can use several traits. When two otherwise-unrelated classes (like Product and Order) need identical behavior but shouldn't be forced into an artificial shared parent class, a trait shares that implementation without imposing an inheritance relationship between them.

Q4

What problem do namespaces solve that plain class names alone don't?

Name collisions. Without namespaces, only one class named Logger (or Product, or User) could exist across your entire codebase and every installed library combined. A namespace prefix (App\Shop\Product vs. a library's Vendor\Package\Product) lets identically-named classes coexist safely, and — via PSR-4 — maps cleanly onto a predictable directory structure.