Free Handbook · Every example compiled & verified

Classes & Objects

Classes, constructor promotion, visibility, typed and readonly properties, static members and constants, object handles and clone, enums and PHP 8.4 property hooks.

0 / 148 lessons🔥 0 day streak
ShareXLinkedIn

Module 06 · what you'll be able to do

  • Write a class with typed properties, methods and a promoted constructor, and create objects with new
  • Choose public, protected, private and private(set), and protect an object's rules behind methods
  • Build immutable value objects with readonly properties, readonly classes and clone
  • Use static members, class constants and self::, and explain why assigning an object shares it
  • Model fixed sets of values with pure and backed enums, and add computed properties with __toString and property hooks
01

Classes, properties and methods

A class is a blueprint: it declares the data an object holds (properties) and what it can do (methods). new ClassName() creates an object (an instance). Inside a method, $this is the object the method was called on, and -> reaches a property or method. Note there is no $ after the arrow: $this->balance, not $this->$balance. Class names are PascalCase, one class per file in real projects.

phpmain.php
<?php
class BankAccount
{
    public string $owner = '';
    public float $balance = 0;

    public function deposit(float $amount): void
    {
        $this->balance += $amount;
    }

    public function describe(): string
    {
        return "{$this->owner} has {$this->balance}";
    }
}

$acc = new BankAccount();
$acc->owner = 'Asha';
$acc->deposit(500);
$acc->deposit(250.5);
echo $acc->describe(), "\n";

$other = new BankAccount();            // a separate object, its own data
$other->owner = 'Ravi';
echo $other->describe(), "\n";

var_dump($acc instanceof BankAccount);
echo get_class($acc), "\n";
Outputcompiled & run with real PHP
Asha has 750.5
Ravi has 0
bool(true)
BankAccount
Your turn

Add a withdraw(float $amount): bool method that refuses (returns false) when the balance is too low.

Arrays or objects?
Beginners often pass associative arrays around ($user['email']). A class gives the same data a name, typed fields your editor can autocomplete, and methods that keep the rules next to the data. Rule of thumb: once the same array shape appears in three places, make it a class.
02

Constructors and constructor property promotion

The __construct method runs when new creates the object, so it is where you make the object valid from the start. PHP 8.0 added constructor property promotion: put a visibility keyword in front of a constructor parameter and PHP declares the property and assigns it for you. The two classes below are equivalent in style; promotion is what modern code uses.

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

// the long way
class Product
{
    public string $name;
    public float $price;

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

// constructor property promotion: declare + assign in one place
class Customer
{
    public function __construct(
        public string $name,
        public string $email,
        private int $points = 0,
    ) {}

    public function addPoints(int $n): static
    {
        $this->points += $n;
        return $this;              // returning $this allows chaining
    }

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

$p = new Product('Keyboard', 1499.0);
echo "$p->name costs $p->price\n";

$c = new Customer('Asha', '[email protected]');
echo $c->addPoints(10)->addPoints(5)->points(), "\n";

$d = new Customer(email: '[email protected]', name: 'Ravi', points: 100);
echo "{$d->name}: {$d->points()}\n";
Outputcompiled & run with real PHP
Keyboard costs 1499
15
Ravi: 100

Named arguments (Module 05) work with new too, which makes constructors with several parameters readable.

03

Visibility: public, protected and private

Every property and method has a visibility. public is reachable from anywhere; protected from the class and its subclasses (Module 07); private only from inside the class that declared it. Making data private and changing it only through methods is encapsulation: the class can then guarantee its rules, such as "a balance never goes negative". PHP 8.4 added asymmetric visibility: public private(set) lets anyone read a property but only the class write it.

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

class Account
{
    private float $balance = 0;
    public private(set) array $history = [];   // read anywhere, write inside only

    public function deposit(float $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Deposit must be positive");
        }
        $this->balance += $amount;
        $this->log("deposit $amount");
    }

    public function balance(): float
    {
        return $this->balance;
    }

    private function log(string $entry): void
    {
        $this->history[] = $entry;
    }
}

$acc = new Account();
$acc->deposit(100);
$acc->deposit(40);
echo $acc->balance(), "\n";
echo implode(" | ", $acc->history), "\n";

try {
    $acc->deposit(-5);
} catch (InvalidArgumentException $e) {
    echo "rejected: ", $e->getMessage(), "\n";
}
Outputcompiled & run with real PHP
140
deposit 100 | deposit 40
rejected: Deposit must be positive
KeywordSame classSubclassOutside code
publicyesyesyes
protectedyesyesno
privateyesnono
public private(set)read + writeread onlyread only
Error you will hit

Error: Cannot access private property

php
<?php
class Account
{
    private float $balance = 0;
}

$acc = new Account();
echo $acc->balance;
Fatal error: Uncaught Error: Cannot access private property Account::$balance in main.php:8
Stack trace:
#0 {main}
  thrown in main.php on line 8
Why PHP said that

Line 8 is outside the class, and $balance is private. This is the visibility rule doing its job: outside code cannot read or change the balance behind the class's back.

The fix

Add a public method that exposes what callers are allowed to see — or, if reading is fine but writing is not, declare it public private(set).

php
<?php
class Account
{
    private float $balance = 0;

    public function balance(): float
    {
        return $this->balance;
    }
}

$acc = new Account();
echo $acc->balance();
04

Typed properties, readonly and immutable objects

A typed property (public string $name;) only ever holds that type. A typed property without a default starts uninitialised — not null — and reading it before assignment is an error. A readonly property (PHP 8.1) can be assigned exactly once, from inside the class, usually in the constructor; after that it is frozen. A readonly class (PHP 8.2) makes every property readonly. Immutable "value objects" like Money or DateRange are built this way: to "change" one you create a new one, and PHP 8.5's clone($obj, [...]) copies an object with some properties replaced.

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

final class Money
{
    public function __construct(
        public readonly int $amount,        // in paise: never float for money
        public readonly string $currency,
    ) {}

    public function add(Money $other): Money
    {
        return new Money($this->amount + $other->amount, $this->currency);
    }
}

$a = new Money(50000, 'INR');
$b = $a->add(new Money(2550, 'INR'));
echo $a->amount, " ", $b->amount, " ", $b->currency, "\n";

readonly class Point                       // every property is readonly
{
    public function __construct(public int $x, public int $y) {}

    public function withX(int $x): static
    {
        return clone($this, ['x' => $x]);  // PHP 8.5: clone with new values
    }
}
$p = new Point(1, 2);
$q = $p->withX(10);
echo "($p->x, $p->y) ($q->x, $q->y)\n";

class Draft
{
    public ?string $title = null;   // nullable with a default: initialised
    public string $body;            // no default: uninitialised
}
$d = new Draft();
var_dump($d->title, isset($d->body));
Outputcompiled & run with real PHP
50000 52550 INR
(1, 2) (10, 2)
NULL
bool(false)

The withX() method is the "wither" pattern: the original $p is untouched. Before PHP 8.5 you wrote new static(...) with every argument instead.

Error you will hit

Error: Typed property must not be accessed before initialization

php
<?php
class User
{
    public string $email;
}

$u = new User();
echo $u->email;
Fatal error: Uncaught Error: Typed property User::$email must not be accessed before initialization in main.php:8
Stack trace:
#0 {main}
  thrown in main.php on line 8
Why PHP said that

An untyped property defaults to null, but a typed property with no default has no value at all until something assigns one. PHP refuses to invent one, because null is not a string.

The fix

Require the value in the constructor so every object is complete from the start — or give the property a default (= '', or ?string ... = null if "unknown" is a real state). isset($u->email) tests it safely.

php
<?php
class User
{
    public function __construct(public string $email) {}
}

$u = new User('[email protected]');
echo $u->email;
Error you will hit

Error: Cannot modify readonly property

php
<?php
final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}
}

$price = new Money(500, 'INR');
$price->amount = 450;
Fatal error: Uncaught Error: Cannot modify readonly property Money::$amount in main.php:11
Stack trace:
#0 {main}
  thrown in main.php on line 11
Why PHP said that

A readonly property is written once, in the constructor, and never again — even by the class itself. That is the whole promise: anyone holding a Money knows it cannot change under them.

The fix

Create a new object with the new value instead of changing the old one.

php
<?php
final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}
}

$price = new Money(500, 'INR');
$sale = new Money(450, $price->currency);
05

Static members, class constants and self::

A static property or method belongs to the class itself, not to any one object, and is reached with :: — Order::count() from outside, self::$created from inside. There is no $this in a static method. A class constant (const TAX_RATE = 0.18;) is a fixed value attached to the class; since PHP 8.3 it can have a type. A common use of static methods is the named constructor: Order::fromArray($row) reads better than a constructor with conversion logic in it.

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

class Order
{
    public const string STATUS_NEW = 'new';    // typed constant, PHP 8.3
    public const TAX_RATE = 0.18;

    private static int $created = 0;           // shared by all orders

    public function __construct(
        public readonly int $id,
        public string $status = self::STATUS_NEW,
    ) {
        self::$created++;
    }

    public static function count(): int
    {
        return self::$created;
    }

    public static function fromArray(array $row): self   // named constructor
    {
        return new self((int) $row['id'], $row['status'] ?? self::STATUS_NEW);
    }

    public function total(float $subtotal): float
    {
        return round($subtotal * (1 + self::TAX_RATE), 2);
    }
}

$a = new Order(1);
$b = Order::fromArray(['id' => '2', 'status' => 'paid']);
echo $a->status, " ", $b->status, " ", $b->id, "\n";
echo Order::count(), " orders, tax ", Order::TAX_RATE, "\n";
echo $a->total(1000), "\n";
Outputcompiled & run with real PHP
new paid 2
2 orders, tax 0.18
1180
Your turn

Add public static function reset(): void that sets the counter back to 0, and call it between two batches of orders.

In real jobs
Static methods for named constructors and pure helpers are everywhere. Static properties are shared, mutable state — hard to test and a source of bugs in long-running workers — so reviewers push back on them. In frameworks like Laravel, configuration and services come from a container instead (Module 12). static::, the late-binding cousin of self::, is covered in Module 07.
06

Objects are handles: assignment, clone and comparison

Arrays are copied on assignment (Module 04); objects are not. A variable holds a handle to the object, so $b = $a gives you two handles to one object, and a function that receives an object can change it. clone makes a real, new object. The clone is shallow: properties that hold other objects still point at the same inner objects, unless you deep-copy them in a __clone() method.

VisualizeTwo handles, one object — and a cloneStep 1 / 7
<?php
$a = new stdClass();
$a->n = 1;
$b = $a;
$b->n = 2;
$c = clone $a;
$c->n = 3;
echo $a->n, $b->n, $c->n;
Line 2

new creates object #1; $a holds a handle to it.

Variables now
$aobject #1
All 7 steps as a table
StepLineWhat happenedVariables now
12new creates object #1; $a holds a handle to it.$a = object #1
23Set n on object #1.$a = object #1 {n: 1}
34Copies the handle, not the object. Both variables point at object #1.$a = object #1 {n: 1} $b = object #1
45Writing through $b changes object #1, which $a also sees.$a = object #1 {n: 2} $b = object #1
56clone creates object #2 with a copy of every property.$a = object #1 {n: 2} $c = object #2 {n: 2}
67Changing the clone leaves object #1 alone.$a = object #1 {n: 2} $c = object #2 {n: 3}
78$a and $b both read object #1; $c reads object #2.
phpmain.php
<?php
class Customer
{
    public function __construct(public string $name) {}
}

class Cart
{
    public array $items = [];

    public function __construct(public Customer $owner) {}

    public function __clone()
    {
        $this->owner = clone $this->owner;   // deep-copy the inner object
    }
}

$c1 = new Cart(new Customer('Asha'));
$c2 = $c1;                                   // same object
$c2->items[] = 'pen';
echo count($c1->items), "\n";

$c3 = clone $c1;                             // new object (runs __clone)
$c3->items[] = 'ink';
$c3->owner->name = 'Ravi';
echo count($c1->items), " ", count($c3->items), "\n";
echo $c1->owner->name, " ", $c3->owner->name, "\n";

var_dump($c1 === $c2, $c1 === $c3, $c1 == $c3);

function renameCustomer(Customer $c): void
{
    $c->name = 'Changed';                    // no & needed
}
$x = new Customer('Kiran');
renameCustomer($x);
echo $x->name, "\n";
Outputcompiled & run with real PHP
1
1 2
Asha Ravi
bool(true)
bool(false)
bool(false)
Changed

=== on objects asks "the same object?"; == asks "same class and equal properties?". Remove __clone() and the clone's rename leaks into $c1, because both carts would share one Customer.

Your turn

Delete the __clone() method and predict the third output line before you run it.

07

Enums: pure and backed

An enum (PHP 8.1) is a type with a fixed list of possible values, called cases. It replaces string constants like 'active' that any typo could break. A pure enum has only names. A backed enum gives each case a string or int value, which is what you store in a database or JSON. from() turns a stored value back into a case (and throws if it is not valid); tryFrom() returns null instead. Enums can have methods and constants, and match over an enum reads like a table.

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

enum Suit
{
    case Hearts;
    case Spades;
}

enum Status: string
{
    case Active = 'active';
    case Suspended = 'suspended';
    case Closed = 'closed';

    public function label(): string
    {
        return match ($this) {
            Status::Active => 'Active account',
            Status::Suspended => 'Temporarily blocked',
            Status::Closed => 'Closed for good',
        };
    }

    public function canLogIn(): bool
    {
        return $this === self::Active;
    }
}

$s = Suit::Hearts;
var_dump($s === Suit::Hearts, $s instanceof Suit);
echo $s->name, "\n";

$status = Status::from('suspended');           // from a database value
echo $status->name, " / ", $status->value, " / ", $status->label(), "\n";
var_dump($status->canLogIn());

var_dump(Status::tryFrom('deleted'));          // null instead of an error
echo implode(", ", array_map(fn(Status $s) => $s->value, Status::cases())), "\n";

function describe(Status $s): string           // only a Status fits
{
    return $s->label();
}
echo describe(Status::Closed), "\n";
Outputcompiled & run with real PHP
bool(true)
bool(true)
Hearts
Suspended / suspended / Temporarily blocked
bool(false)
NULL
active, suspended, closed
Closed for good

Each case is a single object, so === compares cases correctly. Because the match lists every case, adding a new case without updating label() throws UnhandledMatchError the first time it is used — loud, not silent.

Error you will hit

ValueError: not a valid backing value for enum

php
<?php
enum Status: string
{
    case Active = 'active';
    case Banned = 'banned';
}

$s = Status::from('deleted');
Fatal error: Uncaught ValueError: "deleted" is not a valid backing value for enum Status in main.php:8
Stack trace:
#0 main.php(8): Status::from('deleted')
#1 {main}
  thrown in main.php on line 8
Why PHP said that

from() is strict: 'deleted' matches no case, so it throws a ValueError. This usually happens when the database holds a value the code no longer knows about, or when user input is passed straight in.

The fix

Use tryFrom() for untrusted input and decide what null means; keep from() for data that must be valid, so a bad row fails loudly.

php
<?php
enum Status: string
{
    case Active = 'active';
    case Banned = 'banned';
}

$s = Status::tryFrom('deleted') ?? Status::Active;
echo $s->value;
08

__toString and property hooks

Methods whose names start with two underscores are magic methods: PHP calls them for you at special moments. You have met __construct and __clone. __toString() runs when an object is used as a string — in echo, interpolation or a (string) cast — and any class that has it automatically implements the Stringable interface. PHP 8.4 added property hooks: a property can declare get and set code, so a computed or validated value still looks like a plain property to callers. They replace most uses of the older __get / __set magic.

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

class Temperature
{
    public function __construct(public float $celsius) {}

    public float $fahrenheit {                  // no stored value: computed
        get => $this->celsius * 9 / 5 + 32;
        set(float $value) {
            $this->celsius = ($value - 32) * 5 / 9;
        }
    }

    public function __toString(): string
    {
        return sprintf("%.1f C", $this->celsius);
    }
}

class Subscriber
{
    public string $email {
        set(string $value) {                    // validate on every write
            if (!str_contains($value, '@')) {
                throw new InvalidArgumentException("Not an email: $value");
            }
            $this->email = strtolower($value);
        }
    }
}

$t = new Temperature(100);
echo $t->fahrenheit, "\n";
$t->fahrenheit = 50;
echo $t, "\n";                                  // calls __toString
echo "Now: $t\n";
var_dump($t instanceof Stringable);

$s = new Subscriber();
$s->email = '[email protected]';
echo $s->email, "\n";
try {
    $s->email = 'nope';
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}
Outputcompiled & run with real PHP
212
10.0 C
Now: 10.0 C
bool(true)
[email protected]
Not an email: nope

Callers write $t->fahrenheit = 50 as if it were a normal property; the hook runs the conversion. Start with plain public properties and add a hook later without changing any calling code — no getters and setters written "just in case".

Class / object
A class is the blueprint; an object is one instance created with new.
$this
Inside a method, the object the method was called on.
Constructor promotion
Declaring and assigning properties from constructor parameters: __construct(private int $id). PHP 8.0+.
Encapsulation
Keeping data private and changing it only through methods that enforce the rules.
Uninitialised property
A typed property with no default that has not been assigned yet; reading it throws an Error.
readonly
A property that can be assigned once, from inside the class; readonly class makes all properties readonly.
Static member
A property or method that belongs to the class, reached with ClassName:: or self::.
Object handle
What a variable holds for an object; copying the variable copies the handle, not the object.
Backed enum
An enum whose cases carry a string or int value; from() / tryFrom() convert values back to cases.
Property hook
PHP 8.4 get / set code attached to a property, for computed or validated values.
Quick check

$a = new Point(1, 2); $b = $a; $b->x = 5; (Point has a public, non-readonly $x.) What is $a->x?

Frequently asked questions

What is constructor property promotion in PHP?
A PHP 8.0 shorthand: adding a visibility keyword to a constructor parameter, as in public function __construct(private string $name) {}, declares the property and assigns the argument to it in one step. It removes the repeated property declarations and $this->name = $name lines.
Are PHP objects passed by reference?
Not exactly. Variables hold a handle to the object, and the handle is passed by value. The effect is that a function can change the object's properties and the caller sees it, but assigning a brand new object to the parameter inside the function does not change the caller's variable. Use clone when you need an independent copy.
When should I use a PHP enum instead of class constants?
Use an enum whenever a value must be one of a fixed set, such as order status or user role. Unlike string constants, an enum is its own type, so a function parameter typed Status can only receive a valid case, and backed enums convert to and from database values with from() and tryFrom().

Finish the PHP handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.