Home PHP OOP in PHP: Classes and Objects Explained With Real-World Patterns

OOP in PHP: Classes and Objects Explained With Real-World Patterns

In Plain English 🔥
Think of a class like a cookie cutter and an object like the actual cookie. The cutter defines the shape — it's the blueprint. Every cookie you press out is a separate object made from that same blueprint. You can make a hundred cookies, each with different icing, but they all share the same shape because they came from the same cutter. In PHP, a class is that cutter, and every time you use 'new', you're pressing out a fresh cookie.
⚡ Quick Answer
Think of a class like a cookie cutter and an object like the actual cookie. The cutter defines the shape — it's the blueprint. Every cookie you press out is a separate object made from that same blueprint. You can make a hundred cookies, each with different icing, but they all share the same shape because they came from the same cutter. In PHP, a class is that cutter, and every time you use 'new', you're pressing out a fresh cookie.

Every serious PHP application you've ever used — Laravel, WordPress, Symfony — is built on one foundational idea: objects. Not arrays. Not loose functions scattered across files. Objects. The reason experienced developers reach for OOP isn't because it sounds fancy; it's because real-world problems naturally map to things that have both data and behaviour. A user doesn't just have a name — a user can also log in, update their profile, and reset their password. Bundling that data and those actions together is exactly what classes let you do.

Before OOP, PHP code tended to sprawl. You'd have a users.php with fifty functions, half of them needing the same $db variable passed around, half of them accidentally sharing global state. Bugs were hard to trace because data lived everywhere. Classes solve this by giving each concept in your application its own fenced-off space — its own properties to hold data and its own methods to act on it. Change the internals of a class without breaking anything outside it. That's the deal.

By the end of this article you'll understand not just how to define a class and instantiate an object, but why the constructor exists, what visibility keywords actually protect, how to tell a class method from an instance method, and the patterns senior developers use daily. You'll also walk away knowing the mistakes that trip up 80% of beginners so you can skip straight past them.

Defining a Class: Blueprint Before You Build Anything

A class is a template. It describes what a thing looks like (its properties) and what a thing can do (its methods). Nothing actually exists in memory until you instantiate it with 'new'. This is the most important mental model shift: writing a class doesn't create a user, it defines what a user is.

Properties are variables that belong to the class. Methods are functions that belong to the class. Both live inside the class body, and both can be marked as public, protected, or private — more on that shortly.

The constructor is a special method named __construct(). PHP calls it automatically the moment you use 'new ClassName()'. Its job is to set the object up in a valid state. If you're building a BankAccount, the constructor should insist on an opening balance. You shouldn't be able to create a BankAccount that starts in an undefined, broken state — the constructor is your gatekeeper.

Notice in the example below how $this refers to the specific object being worked with. It's the object saying 'my own property'. Every object has its own copy of properties, which is why two BankAccount objects can have different balances without interfering with each other.

BankAccount.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
<?php

class BankAccount
{
    // Properties — data the object holds
    // 'private' means ONLY code inside this class can touch these directly
    private string $ownerName;
    private float  $balance;

    // The constructor runs automatically when you write: new BankAccount(...)
    // It guarantees the object starts in a valid, known state
    public function __construct(string $ownerName, float $openingBalance)
    {
        if ($openingBalance < 0) {
            // Throw early — never let broken data into your object
            throw new InvalidArgumentException('Opening balance cannot be negative.');
        }

        $this->ownerName = $ownerName;       // $this means THIS specific object
        $this->balance   = $openingBalance;
    }

    // A method that changes internal state
    public function deposit(float $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Deposit amount must be positive.');
        }
        $this->balance += $amount;  // Update THIS object's own balance
    }

    // A method that reads and returns state
    public function getBalance(): float
    {
        return $this->balance;
    }

    public function getSummary(): string
    {
        return "{$this->ownerName}'s account — Balance: £{$this->balance}";
    }
}

// --- Instantiation: pressing the cookie cutter ---

$aliceAccount = new BankAccount('Alice', 500.00);  // Creates one object
$bobAccount   = new BankAccount('Bob',   250.00);  // Creates a SEPARATE object

// Alice deposits — only her object changes
$aliceAccount->deposit(150.00);

echo $aliceAccount->getSummary() . PHP_EOL;
echo $bobAccount->getSummary()   . PHP_EOL;

// Demonstrate the constructor guard
try {
    $brokenAccount = new BankAccount('Eve', -100);
} catch (InvalidArgumentException $e) {
    echo 'Caught: ' . $e->getMessage() . PHP_EOL;
}
▶ Output
Alice's account — Balance: £650
Bob's account — Balance: £250
Caught: Opening balance cannot be negative.
⚠️
Pro Tip: Validate in the Constructor, Not Everywhere ElseIf you validate data at the point of object creation, every method inside the class can trust that the data is already clean. You write the validation once instead of checking it in every deposit(), withdraw(), and transfer() method separately.

Visibility Keywords: public, protected, and private Actually Enforced

Visibility is the mechanism that lets you separate what an object exposes to the world from what it keeps to itself. Most beginners mark everything public because it's easier. That's a trap — it means any code anywhere in your codebase can reach in and mangle your object's state without going through your methods.

Think of it like a car dashboard. The steering wheel and pedals are public — they're designed to be used by the driver. The engine internals are private — you're not meant to reach in and adjust the fuel injectors directly while driving. That encapsulation is what makes the car safe to use.

public means anyone, anywhere can access it. protected means only this class and any class that extends it can access it (useful for inheritance). private means only code inside this exact class can access it.

The real-world pattern most PHP developers use: make all properties private, then expose only what outside code genuinely needs through carefully designed public methods. This is called encapsulation and it's one of OOP's four pillars. The payoff is that you can completely rewrite how a class stores its data internally without breaking any code that uses the class — as long as the public methods keep working the same way.

UserProfile.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
<?php

class UserProfile
{
    private string $email;          // Never exposed directly — change format internally anytime
    private string $passwordHash;  // Must NEVER be public
    private int    $loginCount = 0; // Internal bookkeeping only

    public function __construct(string $email, string $plainTextPassword)
    {
        $this->setEmail($email);  // Reuse validation logic via a private method
        // Hash immediately — plain text NEVER gets stored on the object
        $this->passwordHash = password_hash($plainTextPassword, PASSWORD_BCRYPT);
    }

    // Public setter — validates before accepting data (the only door in)
    public function updateEmail(string $newEmail): void
    {
        $this->setEmail($newEmail);  // Centralised validation
    }

    // Public getter — returns a safe view of internal data
    public function getEmail(): string
    {
        return $this->email;
    }

    // Public behaviour method — records the action internally
    public function recordLogin(): void
    {
        $this->loginCount++;
    }

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

    // Private — internal helper, NOT part of the public API
    // Outside code has no business calling this directly
    private function setEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email address: {$email}");
        }
        $this->email = strtolower(trim($email)); // Normalise on the way in
    }
}

$user = new UserProfile('  Alice@Example.COM  ', 'hunter2');

$user->recordLogin();
$user->recordLogin();

echo $user->getEmail()      . PHP_EOL;  // Normalised automatically
echo $user->getLoginCount() . PHP_EOL;

// This line would cause a Fatal Error — uncomment to see it
// echo $user->passwordHash;  // Cannot access private property

try {
    $user->updateEmail('not-an-email');
} catch (InvalidArgumentException $e) {
    echo 'Caught: ' . $e->getMessage() . PHP_EOL;
}
▶ Output
alice@example.com
2
Caught: Invalid email address: not-an-email
⚠️
Watch Out: Public Properties Break Encapsulation SilentlyIf $passwordHash were public, nothing stops a developer six months from now writing $user->passwordHash = 'letmein' and unknowingly bypassing your hashing logic entirely. The bug won't throw an error — it'll just silently corrupt data. Private properties prevent this class of mistake at the language level.

Static Methods and Properties: When the Class Itself Needs to Know Things

Every object you've seen so far has its own independent copy of its properties. That's usually what you want. But sometimes a piece of data or behaviour belongs to the class itself — not to any one instance of it. That's what static is for.

A classic example is a counter tracking how many objects of a class have been created. You can't store that on any single object because no single object knows about the others. The class needs to hold it centrally.

Another common use case is factory methods — static methods that construct and return a new instance with a specific configuration. Laravel and many modern PHP frameworks use this pattern heavily: User::create([...]), Carbon::now(), Response::json(...).

Access static members with the :: operator (called the scope resolution operator), not ->. Inside the class, use self:: to refer to the class itself rather than $this. Using $this inside a static method is a fatal error because there is no 'this' — no object is involved.

Use static sparingly. Overusing it leads you back toward procedural code with global state. The sweet spot is factory methods and genuine class-level metadata like the counter below.

DatabaseConnection.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
<?php

class DatabaseConnection
{
    // Static property — belongs to the CLASS, shared across all instances
    private static int    $connectionCount = 0;
    private static ?self  $primaryInstance = null;  // For the singleton pattern

    private string $dsn;
    private bool   $isConnected = false;

    // Private constructor — forces use of the factory method below
    private function __construct(string $dsn)
    {
        $this->dsn = $dsn;
        self::$connectionCount++;  // self:: targets the class, not an instance
    }

    // Static factory method — the only way to create an instance from outside
    // This pattern lets you add caching, logging, or validation in one place
    public static function create(string $dsn): self
    {
        return new self($dsn);  // 'new self()' creates an instance of THIS class
    }

    // Singleton factory — returns the same instance every time (common for DB connections)
    public static function getPrimaryConnection(string $dsn): self
    {
        if (self::$primaryInstance === null) {
            self::$primaryInstance = new self($dsn);
        }
        return self::$primaryInstance;  // Returns the SAME object on subsequent calls
    }

    public function connect(): void
    {
        // Simulate connection — real code would use PDO here
        $this->isConnected = true;
        echo "Connected to: {$this->dsn}" . PHP_EOL;
    }

    // Static method — usable without any instance at all
    public static function getTotalConnections(): int
    {
        return self::$connectionCount;
    }
}

// Factory method in action — clean, readable, can validate internally
$readReplica  = DatabaseConnection::create('mysql:host=replica1;dbname=shop');
$writeReplica = DatabaseConnection::create('mysql:host=primary;dbname=shop');

$readReplica->connect();
$writeReplica->connect();

// Access static data via the class name — no object needed
echo 'Total connections created: ' . DatabaseConnection::getTotalConnections() . PHP_EOL;

// Singleton — both variables point to the EXACT same object
$connA = DatabaseConnection::getPrimaryConnection('mysql:host=primary;dbname=shop');
$connB = DatabaseConnection::getPrimaryConnection('mysql:host=primary;dbname=shop');

echo 'Same instance? ' . ($connA === $connB ? 'Yes' : 'No') . PHP_EOL;
▶ Output
Connected to: mysql:host=replica1;dbname=shop
Connected to: mysql:host=primary;dbname=shop
Total connections created: 2
Same instance? Yes
🔥
Interview Gold: self:: vs $this — Know the Difference Cold$this refers to the current object instance at runtime. self:: refers to the class in which the code is written at compile time. There's also static:: (late static binding) which resolves to whatever class was actually called at runtime — crucial when you have static methods in an inheritance chain. If an interviewer asks about this, mention late static binding and watch their eyes light up.

Object Cloning and Comparison: Two Gotchas That Bite in Production

Objects in PHP are passed by reference-like handles. This trips up developers who come from a JavaScript or Python background and also those who've only worked with PHP primitives. When you assign an object to a new variable, you don't get a copy — both variables point at the same object. Change it through one variable and the other sees the change too.

To get a true independent copy, you use the clone keyword. PHP then calls the magic __clone() method on the new copy if you've defined one — use that to deep-clone any nested objects the class holds, because clone is shallow by default.

Comparison has its own wrinkle. == checks if two objects have the same class and same property values. === checks if both variables point to the exact same instance in memory. This matters in tests and in any logic where identity (not just equality) matters.

This section pulls together everything from the article — you'll see a class with a constructor, private properties, a public API, and now cloning all working together. Think of this as the capstone example.

ShoppingCart.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
<?php

class CartItem
{
    public function __construct(
        public readonly string $sku,
        public int $quantity
    ) {}
}

class ShoppingCart
{
    private array  $items    = [];
    private string $currency;

    public function __construct(string $currency = 'GBP')
    {
        $this->currency = $currency;
    }

    public function addItem(CartItem $item): void
    {
        // Store the item — note: this stores a REFERENCE to the CartItem object
        $this->items[$item->sku] = $item;
    }

    public function getItemCount(): int
    {
        return array_sum(array_column(
            array_map(fn($i) => ['qty' => $i->quantity], $this->items),
            'qty'
        ));
    }

    // __clone is called automatically after PHP does the shallow copy
    // Without this, $cart->items would still point to the SAME CartItem objects
    public function __clone()
    {
        $clonedItems = [];
        foreach ($this->items as $sku => $item) {
            // Deep clone each nested object so the copy is truly independent
            $clonedItems[$sku] = clone $item;
        }
        $this->items = $clonedItems;
    }
}

$originalCart = new ShoppingCart('GBP');
$originalCart->addItem(new CartItem('TSHIRT-RED-M', 2));
$originalCart->addItem(new CartItem('MUG-FORGE', 1));

// Without clone — BOTH variables point to the same object
$sameCart = $originalCart;
$sameCart->addItem(new CartItem('HOODIE-BLUE-L', 3));  // This modifies $originalCart too!

echo 'Same-reference cart items: ' . $originalCart->getItemCount() . PHP_EOL; // 6, not 3

// With clone — completely independent copy
$giftCart = clone $originalCart;  // __clone() fires, deep-copies the items array
$giftCart->addItem(new CartItem('GIFT-WRAP', 1));

echo 'Original cart items: ' . $originalCart->getItemCount() . PHP_EOL; // Unchanged
echo 'Gift cart items:     ' . $giftCart->getItemCount()     . PHP_EOL; // Has the extra item

// Comparison demo
$anotherRef = $originalCart;           // Same instance
$clonedCopy = clone $originalCart;     // Different instance, same values

echo PHP_EOL;
echo '$anotherRef == $originalCart:  '  . var_export($anotherRef == $originalCart, true)  . PHP_EOL; // true
echo '$anotherRef === $originalCart: '  . var_export($anotherRef === $originalCart, true) . PHP_EOL; // true
echo '$clonedCopy == $originalCart:  '  . var_export($clonedCopy == $originalCart, true)  . PHP_EOL; // true
echo '$clonedCopy === $originalCart: '  . var_export($clonedCopy === $originalCart, true) . PHP_EOL; // false!
▶ Output
Same-reference cart items: 6
Original cart items: 6
Gift cart items: 7

$anotherRef == $originalCart: true
$anotherRef === $originalCart: true
$clonedCopy == $originalCart: true
$clonedCopy === $originalCart: false
⚠️
Watch Out: clone Is Shallow by DefaultIf your class holds other objects as properties and you don't define __clone(), cloning gives you a new outer object but the nested objects are still shared. Mutate a nested object on the clone and you've mutated the original too. Always implement __clone() when your class contains object properties.
AspectProcedural PHP (functions)OOP PHP (classes and objects)
Data + behaviour bundlingSeparate — arrays passed between functionsTogether — properties and methods on one object
State managementGlobal variables or function parametersEncapsulated in object properties
Code reuseCopy-paste or include filesInstantiate new objects; use inheritance
Validation locationScattered — each function must check inputsCentralised in constructor and setters
TestabilityHard — functions depend on global stateEasy — inject dependencies, mock objects
Access controlNone — all data is accessible everywherepublic / protected / private enforced by PHP
Typical Laravel route handlerRare — used only for tiny utility scriptsStandard — controllers, models, services are all classes

🎯 Key Takeaways

  • A class is a blueprint that costs nothing in memory — objects are the real things created from it with 'new'. Write the blueprint once, stamp out as many objects as you need.
  • The constructor is your gatekeeper: validate and assign all required data there so every method inside the class can trust the object is always in a valid state.
  • Mark properties private by default — expose only what outside code genuinely needs through public methods. Encapsulation is what lets you refactor internals without breaking callers.
  • Assigning an object to a variable does NOT copy it — both variables point to the same instance. Use clone when you need independence, and define __clone() when the class holds nested objects.

⚠ Common Mistakes to Avoid

  • Mistake 1: Forgetting that object assignment copies the reference, not the object — Symptom: you modify what you think is a copy but the original changes too, causing ghost mutations that are painful to debug — Fix: use the clone keyword when you need an independent copy, and implement __clone() to deep-clone any nested object properties.
  • Mistake 2: Making all properties public to avoid writing getters — Symptom: external code starts depending on internal property names, so renaming or restructuring the property breaks code all over the project — Fix: mark properties private, expose only what outside code actually needs via specific public methods, and name those methods after the intent (getFormattedPrice()) not the storage (getPriceInPence()).
  • Mistake 3: Using $this inside a static method — Symptom: PHP throws a Fatal error: Using $this when not in object context — Fix: static methods have no object instance, so use self:: to access static properties or methods. If you find yourself needing $this, the method shouldn't be static in the first place.

Interview Questions on This Topic

  • QWhat is the difference between a class and an object in PHP, and can you give a real-world analogy to illustrate it?
  • QExplain the three visibility modifiers in PHP — public, protected, and private — and describe a concrete scenario where you'd choose private over public for a property.
  • QWhat is the difference between self:: and static:: in a PHP class, and in what scenario would using self:: give you the wrong result when inheritance is involved?

Frequently Asked Questions

What is the difference between a class and an object in PHP?

A class is the blueprint or template — it defines what properties and methods something has. An object is a specific instance created from that blueprint using the 'new' keyword. You define a class once but can create as many objects from it as you like, each with its own independent property values.

When should I use a static method instead of a regular method in PHP?

Use a static method when the behaviour belongs to the class itself rather than to any particular instance. Common use cases are factory methods (User::create()), singleton accessors, and utility functions that don't need to read or write any object properties. If the method touches $this or any instance property, it should not be static.

Why does changing an object through one variable affect another variable pointing to the same object?

PHP objects are accessed through handles, so assigning an object to a new variable gives both variables a handle to the exact same object in memory — not a copy. To get a genuinely independent copy you must use the clone keyword. If the class holds other objects as properties, you should also implement __clone() to deep-clone those nested objects, because the default shallow clone still shares them.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPHP File HandlingNext →Inheritance in PHP
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged