Skip to content
beginner Phase 4 · Object-Oriented Programming Foundations

Classes and Objects

Define a class blueprint and instantiate objects from it.

40m
0 problems
Topic Progress 0%

The Array That Lied: Turning an Implicit Contract into a Class

A checkout service keeps each customer as an array:

$customer = ['id' => 17, 'email' => 'ada@example.com', 'credits' => 10];
$customer['emial'] = 'typo@example.com';   // no error, no warning
applyDiscount($customer, 1.5);

The array carries an implicit contract — which keys exist, which types they hold, which values are legal — and PHP enforces none of it. The typo'd key silently becomes a second field, applyDiscount may read credits as a string and concatenate it into nonsense, and every function that touches the array re-implements (or forgets) the same checks. A class makes the contract a single declaration:

final class Customer
{
    public function __construct(
        public int $id,
        public string $email,
        public int $credits,
    ) {
        if ($credits < 0) {
            throw new InvalidArgumentException('credits cannot be negative');
        }
    }
}

This uses constructor property promotion, added in PHP 8.0: writing public int $id in the constructor signature declares a typed property and assigns it — no separate property block, no manual $this->id = $id lines. The pre-8.0 form is worth reading once so the promotion is not magic:

class Customer
{
    public int $id;
    public string $email;
    public int $credits;

    public function __construct(int $id, string $email, int $credits)
    {
        $this->id = $id;
        $this->email = $email;
        $this->credits = $credits;
    }
}

Both forms deliver the property interviewers probe: typed properties reject wrong assignments before any method runs. new Customer('17', 'a@b.c', 5) throws a TypeError at the construction site — not three functions later when someone computes $customer->id + 1. And because validation lives in the constructor, every instance of Customer has passed the same gate; there is no partially built object for callers to defend against. With arrays, that invariant was every caller's private homework.

Two mechanics complete the blueprint. $this inside a method is the instance the method was called on: $customer->spend(4) binds $this to $customer, so the method sees that object's state. And a typed property with no default starts uninitialized — reading it throws Error: Typed property Customer::$credits must not be accessed before initialization rather than quietly yielding null. That is deliberate: null is the phantom state arrays let you smuggle past every check.

Finally, each new produces an independent instance with its own copy of the property values. Two customers built from the same database row share nothing:

$a = new Customer(17, 'ada@example.com', 10);
$b = new Customer(17, 'ada@example.com', 10);
var_dump($a === $b);          // bool(false) — two distinct instances
get_debug_type($a);           // 'Customer'

Behavior belongs next to the state it protects: a spend(int $amount): void method keeps the credits invariant inside the class, where the array version left it to memory. Whether outsiders may touch $customer->credits directly is the next decision — Public, Private, and Protected Members makes it.

One more contrast with arrays repays attention: initialization order. An array is valid at every moment of its construction — half-filled keys and all — while a class instance springs into existence already consistent, because the constructor is a single indivisible step. Frameworks exploit this: an object hydrated by an ORM passes through the same constructor gate your manual new does, so database-originated instances obey identical invariants. The habit to leave this chapter with is to treat the constructor signature as the class's public API — every parameter is a promise about what a valid Customer is, and every promoted typed property is that promise checked at the call site.

This topic picks up where Regular Expressions left off: that phase's examples carried data in loosely shaped strings and arrays, and the class blueprint above is exactly what turns those loose shapes into contracts the engine enforces.

One Object, Two Names: Assignment, clone, and the Two Equalities

Assigning an object does not copy it — objects are handles:

$a = new Customer(17, 'ada@example.com', 10);
$b = $a;                       // two names for ONE instance
$b->credits = 0;
var_dump($a->credits);         // int(0) — changed through $b

This is the sharpest behavioral difference from arrays, which have value semantics: $copy = $arr followed by mutating $copy leaves $arr alone. With objects, both variables point at the same instance, so a mutation through either is visible in both. Passing an object into a function has the same effect — the function receives the handle, so a method that mutates a property changes what the caller sees. That is the mechanism behind dependency injection and every ORM in PHP, and it is also how aliasing bugs sneak in when two modules share an object neither expected the other to touch.

When you do want an independent copy, clone makes a new instance:

$c = clone $b;                 // new Customer; scalar properties copied
$c->credits = 99;              // does not affect $b

But clone is shallow: nested objects among the properties are copied as handles, so a deep copy needs the __clone hook to re-clone the internals (public function __clone(): void { $this->address = clone $this->address; }). A second route to a fresh instance is the constructor itself — new Customer($a->id, $a->email, $a->credits) — which often states intent better than cloning a mutable object anyway.

Two operators answer different questions about objects, and conflating them is a review-blocking bug:

$a == $b    // same class AND equal property values (compared loosely, property by property)
$a === $b   // the SAME instance — identity, properties never inspected
$a !== $b   // different instances

== walks the property tables using loose comparison semantics, so an object whose id is '17' still counts equal to one whose id is 17. === never looks at a single property: it asks whether both names point at one object. The practical rule: use ===/!== for objects by default. Reserve == for deliberate value objects — money, coordinates, immutable DTOs — where structural equality is the meaningful question, and then implement it predictably.

Because identity is what matters, a few identity utilities come with the language. $a instanceof Customer is the type check. spl_object_id($a) (PHP 8.0) returns a small unique int per live object. And since arrays cannot use objects as keys, "a set of customers" or "count per customer" needs spl_object_storage or the 8.0 WeakMap, which removes entries automatically when an object is garbage-collected. Knowing that objects are handles also explains a deprecation you will meet on modern code: assigning a value to a property the class never declared. PHP 8.2 deprecated dynamic properties — the fixes, and the string/JSON faces of an object, are in Methods, Factories, and the Shape Callers See.

Identity also explains the container utilities you will reach for in real code. spl_object_storage pairs each object with attached data and answers contains() by identity; the 8.0 WeakMap does the same while removing entries automatically the moment a key is garbage-collected, which makes it the correct home for per-object caches — the array version leaks every entry until someone remembers to prune it. And when a function must test what a value is, $x instanceof Customer beats get_class($x) === 'Customer', because instanceof also answers true for subclasses — exactly the behavior polymorphism depends on, and exactly what breaks when == stands in for identity in a conditional.

Factories, Fluent Calls, and Getting an Object onto the Wire

A class is not only state plus methods; it is also the place where you decide how instances come into existence and how they appear to the outside world.

Static factories build an instance from data that is not yet a valid constructor call — an array row, a JSON payload, a database record:

public static function fromRow(array $row): self
{
    return new self((int) $row['id'], (string) $row['email'], (int) $row['credits']);
}

$customer = Customer::fromRow($pdoRow);

The name fromRow tells the caller what shape the argument must be, something new Customer(...) at a call site with six arguments never communicates. Factories are static because they produce an object; they must not touch $this, since no instance exists yet — you will see the full static-versus-instance rules in Static Properties and Methods.

Fluent methods return $this so calls chain, and since PHP 8.0 the static return type keeps chaining correct in subclasses:

public function withCredits(int $credits): static
{
    $this->credits = $credits;
    return $this;
}

$customer = (new Customer(17, 'ada@example.com', 10))->withCredits(25);

Returning static rather than self matters when a subclass overrides the method: the chained value stays the subclass type instead of being widened back to the parent.

Output and serialization are decisions, not accidents. An object is neither a string nor JSON by default:

echo $customer;                  // Error: Object of class Customer could not be converted to string
json_encode($customer);          // dumps public properties — usually not the API contract you want

Implement JsonSerializable to put the wire format inside the class, where a property rename cannot silently change what an API ships:

final class Customer implements JsonSerializable
{
    public function jsonSerialize(): array
    {
        return ['id' => $this->id, 'email' => $this->email];
    }
}

For string contexts, the magic method __toString() plays the same role — echo $customer becomes the returned string. It must not throw, and you will cover it properly in Magic Methods like __get and __toString.

Finally, the modern-PHP landmine: $customer->nickname = 'ada'; on a class that never declared $nickname creates a dynamic property, deprecated since PHP 8.2 — it emits Deprecated: Creation of dynamic property Customer::$nickname and will eventually be an error. The fix is declaring the property with a type; the explicit opt-out #[AllowDynamicProperties] exists for genuinely open containers (and stdClass already has it). Declaring the property is almost always the right answer: the property you were about to invent was part of the class's contract all along.

Two inspection helpers close the loop on everyday object work. get_debug_type($customer) returns the precise class name — or 'null', 'int', 'string' for scalars — which beats var_dump inside log lines and error reports, and $customer::class yields the class name as a string wherever a name is needed rather than an instance. Reflection is the heavyweight option: (new ReflectionClass($customer))->getProperties() enumerates the declared shape at runtime, which is how var_dump, JSON serializers, and ORM mappers work under the hood. The through-line of all three chapters is that a class is a contract made checkable: types the engine enforces, identity rules the comparison operators implement, and representations the class itself decides. Reach for arrays when you want a bag of scalars with value-copy semantics; reach for a class the moment the data has invariants, behavior, or a name worth defending in review.

Quiz

1. $b = $a; $b->credits = 0; — what is $a->credits afterwards?

Question 1 options

2. What do `$a == $b` and `$a === $b` check for two Customer objects?

Question 2 options

3. What does constructor property promotion (PHP 8.0) actually generate?

Question 3 options

4. On PHP 8.2+, adding $customer->nickname = 'ada' to a class that never declared $nickname…

Question 4 options

Flashcards

Question

What does constructor property promotion do, and what happens when an un-defaulted typed property is read before assignment?

Answer

Promoted constructor parameters (PHP 8.0) become declared typed properties plus their assignments — one block instead of boilerplate. A typed property with no default starts uninitialized: reading it throws Error ('must not be accessed before initialization') rather than yielding null.

Question

Object assignment: handle semantics, and how to get a real copy

Answer

$b = $a copies the handle — one instance, two names, mutations visible through either (arrays would copy). `clone $a` makes a new instance with scalar properties copied — a shallow copy; nested objects stay shared until __clone re-clones them. `new Customer($a->id, ...)` is the alternative when you want to state intent.

Question

== versus === for objects

Answer

== compares class plus property values using loose semantics; === (and !==) compares instance identity only and never inspects properties. Use === for objects by default; == only for deliberate value objects (money, coordinates, immutable DTOs) where structural equality is meaningful.

Question

How do you control an object's JSON and string representations?

Answer

Implement JsonSerializable::jsonSerialize() to decide the wire format (json_encode otherwise emits public properties blindly), and __toString() to define what echo/string context becomes (and it must not throw). Both move the representation decision into the class, where a property rename cannot silently break a contract.

Question

Identity utilities for objects, and the PHP 8.2 dynamic-properties rule

Answer

spl_object_id($o) gives a small unique int per live object (8.0); instanceof checks types; spl_object_storage or WeakMap provide object-keyed maps because arrays cannot use objects as keys (WeakMap self-cleans on GC). Dynamic properties are deprecated in 8.2: declare the property, or use #[AllowDynamicProperties] as the explicit opt-out.

Revision Notes

Key Takeaways

  • 1. A class makes the record's implicit contract explicit: typed promoted properties, a constructor gate, and methods that keep state consistent
  • 2. Objects are handles: assignment and argument passing share one instance; clone gives a shallow copy that needs __clone to go deep
  • 3. == compares properties loosely, === compares instances — identity is the default comparison for mutable objects
  • 4. Serialization is a decision, not an accident: JsonSerializable for JSON, __toString for string context
  • 5. PHP 8.2 deprecates dynamic properties; 8.0 added WeakMap and spl_object_id for identity work

Interview Tips

  • • "Turn this array-based function into OOP" — introduce a class with promoted typed properties, move validation into the constructor, and explain that the invariant now lives in one gate instead of being scattered across array readers
  • • "Objects vs arrays in PHP?" — arrays have value semantics on assignment and are keyed by scalars; objects are handles, compared by identity (===), and need WeakMap or spl_object_storage to serve as keys
  • • "When would you use == on objects?" — only for deliberate value objects (money, coordinates) where structural equality is meaningful; otherwise ===, because identity is the meaningful question for mutable objects
  • • "json_encode outputs too much from my class" — implement JsonSerializable so the wire format is decided once; mention __toString for string contexts and the 8.2 dynamic-properties deprecation as the related upgrade gotcha

Cheat Sheet

Classes and Objects

Blueprint → instance:

final class Customer
{
    public function __construct(
        public int $id,
        public string $email,
        public int $credits,
    ) {
        if ($credits < 0) {
            throw new InvalidArgumentException('credits cannot be negative');
        }
    }
}

$ada = new Customer(17, 'ada@example.com', 10);  // TypeError at the call site on type mismatch

Promotion (8.0) turns promoted params into declared typed properties. Typed properties without defaults are uninitialized — reading them throws Error. Put validation in the constructor: it is the one gate every instance passes.

Identity semantics:

$b = $a;                 // SAME instance — objects are handles (arrays would copy)
$c = clone $b;           // new instance, shallow — use __clone for deep copies
$a == $b                 // same class + equal (loosely compared) properties
$a === $b                // same INSTANCE — the default for objects
spl_object_id($a)        // small unique int per live object (8.0)
$a instanceof Customer   // type check

Ergonomics:

public static function fromRow(array $r): self { return new self(...); }   // factory
public function withCredits(int $c): static { …; return $this; }           // fluent
echo $obj;                        // Error unless __toString() defined
json_encode($obj);                // JsonSerializable::jsonSerialize() decides the wire format
get_debug_type($obj);  $obj::class;  var_dump($obj);

Version notes: dynamic properties deprecated in 8.2 (declare the property or #[AllowDynamicProperties]); no-arg get_class() deprecated since 8.0; arrays cannot be object-keyed — use spl_object_storage or the 8.0 WeakMap.

Continue Learning