PHP

Chapter 6: Object-Oriented PHP — Classes, Objects, and Modern Constructors

By Ali Naqi | November 10, 2026

Chapter 6: Object-Oriented PHP — Classes, Objects, and Modern Constructors

Modern PHP Mastery: From First Steps to Production

Chapter 6: Object-Oriented PHP — Classes, Objects, and Modern Constructors

In Chapter 5, we structured reusable application logic using functions, strict type hints, and isolated variable scope. While functions are excellent for data transformation pipelines, complex applications need more than loose arrays and isolated subroutines. Real-world systems—like billing engines, authentication workflows, or inventory systems—require combining state (data) and behavior (actions) into coherent, self-governing units.

This brings us to Object-Oriented Programming (OOP).

Historically, PHP’s object model was an afterthought. PHP 3 and 4 had primitive, clunky object handling that copied objects by value. With PHP 5, the object engine was completely rewritten around the Zend Engine II. In modern PHP (8.0 through 8.4+), the language offers an expressive, robust object system featuring constructor property promotion, typed class properties, readonly modifiers, and first-class enumeration types.

In this chapter, we will break down the foundation of OOP in PHP: defining classes, instantiating objects, managing visibility modifiers (public, protected, private), working with the $this pseudo-variable, using constructor property promotion to eliminate boilerplate, and enforcing immutability.

Classes vs. Objects: The Blueprint and the House

Before writing code, let’s clarify the core mental model:

 * Class: The abstract blueprint or template. It defines what attributes an entity holds and what actions it can perform.

 * Object: The concrete instance living in memory, constructed according to that blueprint.

       [ Class: UserAccount ] <── The Blueprint

         - email: string

         - balance: int

         + credit(amount): void

                 │

      ┌──────────┴──────────┐

      ▼ ▼

[ Object #1 ] [ Object #2 ] <── Live Instances in Memory

email: "ali@..." email: "sarah@..."

balance: 5000 balance: 12500


You can define a single UserAccount class, but instantiate thousands of distinct user objects from it, each with its own independent state.

Declaring a Class and Instantiating an Object

A minimal class declaration uses the class keyword followed by a PascalCase identifier (per PSR-12 standards):

<?php

declare(strict_types=1);


class Invoice

{

    // Class properties (state)

    public string $invoiceNumber;

    public float $amount;

    public bool $isPaid = false;


    // Class method (behavior)

    public function markAsPaid(): void

    {

        $this->isPaid = true;

    }

}


// Instantiating an object using the `new` keyword

$invoice = new Invoice();

$invoice->invoiceNumber = 'INV-2026-001';

$invoice->amount = 350.00;


echo $invoice->isPaid ? 'Settled' : 'Pending'; // Output: Pending


$invoice->markAsPaid();


echo $invoice->isPaid ? 'Settled' : 'Pending'; // Output: Settled


Object Operator (->) and $this

 * To access properties or call methods on an instantiated object from the outside, use the object operator: -> (e.g., $invoice->amount).

 * Inside the class's methods, PHP provides the pseudo-variable $this. It references the specific instance calling the method, allowing an object to access its own properties and sibling methods.

Encapsulation and Visibility Modifiers

In the previous example, $invoice->invoiceNumber and $invoice->amount were marked public. This means any external code can modify them arbitrarily:

// Dangerous: External code corrupting internal state

$invoice->amount = -9999.00; 


Allowing unvetted mutation breaks encapsulation—the OOP principle that an object must guard its own internal state and guarantee its own integrity. PHP provides three visibility modifiers to manage access:

| Modifier | Access From Outside | Access Inside the Class | Access Inside Child (Extended) Classes |

|---|---|---|---|

| public | Yes | Yes | Yes |

| protected | No | Yes | Yes |

| private | No | Yes | No |

Practical Rule of Thumb

 * Mark properties private by default. If a property represents internal state, prevent external scripts from touching it directly.

 * Provide public methods (getters and business-specific mutating methods) to safely inspect or update that data with validation guards.

 * Reserve protected for properties or internal helper methods that derived child classes explicitly need to override or inspect.

The Evolution of Constructors: Eliminating Boilerplate

A constructor is a special lifecycle method (__construct()) automatically executed when a new instance is created via new. It prepares the object for immediate, valid use.

The Verbose Legacy Approach (Prior to PHP 8.0)

Historically, setting up a class required declaring class properties, repeating those names in the constructor parameter signature, and manually writing assignment lines:

<?php

// Legacy verbose boilerplate

class UserProfile

{

    private string $username;

    private string $email;

    private int $reputationScore;


    public function __construct(string $username, string $email, int $reputationScore = 0)

    {

        $this->username = $username;

        $this->email = $email;

        $this->reputationScore = $reputationScore;

    }

}


Notice the repetition: $username is typed four separate times just to initialize one property.

The Modern Solution: Constructor Property Promotion

Introduced in PHP 8.0, Constructor Property Promotion allows you to declare visibility modifiers directly inside constructor arguments. PHP automatically turns those parameters into class properties and assigns the incoming values behind the scenes:

<?php

declare(strict_types=1);


class UserProfile

{

    // Constructor Property Promotion: properties declared and assigned in one step

    public function __construct(

        private string $username,

        private string $email,

        private int $reputationScore = 0

    ) {}


    public function getUsername(): string

    {

        return $this->username;

    }


    public function getEmail(): string

    {

        return $this->email;

    }


    public function getReputationScore(): int

    {

        return $this->reputationScore;

    }

}


$user = new UserProfile('ali_shah', 'ali@example.com', 150);

echo $user->getUsername(); // Output: ali_shah


This reduces boilerplate code by more than 60% while maintaining complete type safety.

Modern Immutability: readonly Properties and Classes

In modern application architecture (such as Domain-Driven Design and microservice payloads), creating immutable value objects—objects that cannot be modified after creation—prevents unintended side effects.

1. readonly Properties (PHP 8.1+)

Marking a property readonly guarantees that it can only be written once (typically during constructor execution). Any subsequent attempt to reassign the value throws a fatal Error:

<?php

declare(strict_types=1);


class ApiCredential

{

    public function __construct(

        public readonly string $apiKey,

        public readonly string $environment

    ) {}

}


$cred = new ApiCredential('sec_live_9981a', 'production');

echo $cred->apiKey; // Read access is completely valid


// $cred->apiKey = 'tampered'; 

// Fatal Error: Cannot modify readonly property ApiCredential::$apiKey


2. readonly Classes (PHP 8.2+)

If every property in your class is immutable, you can mark the entire class readonly. This automatically makes every declared property readonly and prevents dynamic properties from ever being appended:

<?php

declare(strict_types=1);


readonly class Coordinates

{

    public function __construct(

        public float $latitude,

        public float $longitude

    ) {}

}


Object References: How PHP Manages Objects in Memory

One of the most critical differences between primitive types and objects in PHP is how they are passed:

 * Primitives (ints, strings, arrays) are passed by value (copy-on-write). Modifying an array inside a function does not mutate the original caller's array.

 * Objects are passed by handle reference. When you assign an object to another variable or pass it into a function, both variables point to the same object instance in memory.

<?php

declare(strict_types=1);


class Cart

{

    public int $itemCount = 0;

}


$cartA = new Cart();

$cartA->itemCount = 1;


$cartB = $cartA; // $cartB points to the EXACT same object instance

$cartB->itemCount = 5;


echo $cartA->itemCount; // Output: 5!


If you need a completely independent copy of an object rather than a reference pointer, use the clone keyword:

<?php

$cartC = clone $cartA;

$cartC->itemCount = 10;


echo $cartA->itemCount; // Still 5

echo $cartC->itemCount; // 10


Practical Example: Building an Encapsulated Digital Wallet

Let's apply encapsulation, constructor property promotion, strict typing, and defensive domain rules by building a production-ready DigitalWallet class.

The wallet will manage balances strictly in integer cents (preventing float rounding errors), enforce minimum transactions, guard against overdrawing, and record an immutable audit trail.

Create a file named wallet_demo.php:

<?php

declare(strict_types=1);


/**

 * Immutable Value Object representing an audit record.

 */

readonly class LedgerEntry

{

    public function __construct(

        public string $type,

        public int $amountInCents,

        public string $timestamp,

        public string $reference

    ) {}

}


/**

 * Encapsulated Digital Wallet entity.

 */

class DigitalWallet

{

    private int $balanceInCents;

    /** @var LedgerEntry[] */

    private array $ledger = [];


    public function __construct(

        private readonly string $walletId,

        private readonly string $ownerName,

        private readonly string $currency = 'PKR',

        int $initialDepositInCents = 0

    ) {

        if ($initialDepositInCents < 0) {

            throw new InvalidArgumentException("Initial deposit cannot be negative.");

        }


        $this->balanceInCents = $initialDepositInCents;


        if ($initialDepositInCents > 0) {

            $this->recordTransaction('OPENING_BALANCE', $initialDepositInCents, 'Initial deposit');

        }

    }


    public function deposit(int $amountInCents, string $reference): void

    {

        if ($amountInCents <= 0) {

            throw new InvalidArgumentException("Deposit amount must be strictly greater than zero.");

        }


        $this->balanceInCents += $amountInCents;

        $this->recordTransaction('CREDIT', $amountInCents, $reference);

    }


    public function withdraw(int $amountInCents, string $reference): void

    {

        if ($amountInCents <= 0) {

            throw new InvalidArgumentException("Withdrawal amount must be strictly greater than zero.");

        }


        if ($amountInCents > $this->balanceInCents) {

            throw new RuntimeException("Insufficient wallet funds for this operation.");

        }


        $this->balanceInCents -= $amountInCents;

        $this->recordTransaction('DEBIT', $amountInCents, $reference);

    }


    public function getBalanceInCents(): int

    {

        return $this->balanceInCents;

    }


    public function getFormattedBalance(): string

    {

        return $this->currency . ' ' . number_format($this->balanceInCents / 100, 2);

    }


    public function getOwnerName(): string

    {

        return $this->ownerName;

    }


    public function getWalletId(): string

    {

        return $this->walletId;

    }


    /**

     * @return LedgerEntry[]

     */

    public function getLedger(): array

    {

        return $this->ledger;

    }


    private function recordTransaction(string $type, int $amountInCents, string $reference): void

    {

        $this->ledger[] = new LedgerEntry(

            type: $type,

            amountInCents: $amountInCents,

            timestamp: date('Y-m-d H:i:s'),

            reference: $reference

        );

    }

}


// Running the wallet lifecycle

$wallet = new DigitalWallet(

    walletId: 'WAL-8802',

    ownerName: 'Ali Naqi',

    currency: 'PKR',

    initialDepositInCents: 100000 // PKR 1,000.00

);


try {

    $wallet->deposit(45000, 'Project milestone payment'); // PKR 450.00

    $wallet->withdraw(20000, 'Domain registration renewal'); // PKR 200.00

    // Uncommenting the line below triggers the overdraft guard:

    // $wallet->withdraw(500000, 'Unauthorized excessive withdrawal');

} catch (RuntimeException | InvalidArgumentException $e) {

    echo "Transaction Denied: " . $e->getMessage();

}

?>

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <title>Digital Wallet Overview</title>

    <style>

        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; padding: 2rem; }

        .card { max-width: 650px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 2rem; border: 1px solid #334155; }

        .balance-header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #334155; padding-bottom: 1.5rem; }

        .balance-amount { font-size: 2rem; font-weight: bold; color: #10b981; }

        .meta { color: #94a3b8; font-size: 0.85rem; margin-top: 0.25rem; }

        table { width: 100%; border-collapse: collapse; margin-top: 1.5rem; font-size: 0.9rem; }

        th, td { padding: 0.75rem 0.5rem; text-align: left; border-bottom: 1px solid #334155; }

        th { color: #94a3b8; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; }

        .credit { color: #34d399; font-weight: 600; }

        .debit { color: #f87171; font-weight: 600; }

    </style>

</head>

<body>


<div class="card">

    <div class="balance-header">

        <div>

            <h2 style="margin: 0;"><?= htmlspecialchars($wallet->getOwnerName(), ENT_QUOTES, 'UTF-8') ?></h2>

            <div class="meta">Account Ref: <?= htmlspecialchars($wallet->getWalletId(), ENT_QUOTES, 'UTF-8') ?></div>

        </div>

        <div class="balance-amount">

            <?= htmlspecialchars($wallet->getFormattedBalance(), ENT_QUOTES, 'UTF-8') ?>

        </div>

    </div>


    <h3>Transaction Ledger</h3>

    <table>

        <thead>

            <tr>

                <th>Timestamp</th>

                <th>Type</th>

                <th>Reference</th>

                <th style="text-align: right;">Amount</th>

            </tr>

        </thead>

        <tbody>

            <?php foreach ($wallet->getLedger() as $entry): ?>

                <tr>

                    <td style="color: #64748b;"><?= $entry->timestamp ?></td>

                    <td><?= $entry->type ?></td>

                    <td><?= htmlspecialchars($entry->reference, ENT_QUOTES, 'UTF-8') ?></td>

                    <td style="text-align: right;" class="<?= $entry->type === 'DEBIT' ? 'debit' : 'credit' ?>">

                        <?= $entry->type === 'DEBIT' ? '-' : '+' ?>

                        PKR <?= number_format($entry->amountInCents / 100, 2) ?>

                    </td>

                </tr>

            <?php endforeach; ?>

        </tbody>

    </table>

</div>


</body>

</html>


Run this file via php -S localhost:8000 and visit http://localhost:8000/wallet_demo.php.

Notice how the DigitalWallet class safeguards its state: no outside file can tamper with $balanceInCents directly, deposits and withdrawals are audited automatically, and each transaction entry is stored as a guaranteed immutable LedgerEntry record.

Chapter 6 Key Takeaways

 * Classes package data and behavior together, creating encapsulated domains that protect their internal state from arbitrary mutation.

 * Always mark properties private by default, exposing only clean public methods (getters and specific mutator actions) to the rest of the application.

 * Use Constructor Property Promotion (public function __construct(private string $id)) to eliminate repetitive property assignment boilerplate.

 * Enforce immutability with readonly: Mark properties or whole classes readonly when values must remain unchanged after initialization.

 * Objects are passed by handle reference: Assigning an object to a new variable points to the same underlying instance in memory. Use clone if you need an isolated copy.

In Chapter 7, we will build on this foundation by exploring OOP Inheritance, Abstract Classes, and Interfaces: sharing common behavior, designing polymorphism, and writing decoupled contracts that form the architecture of enterprise PHP frameworks.