Classes and Object Basics: Properties, Methods, Constructors, Visibility

Understand what a class and an object are in PHP, how to declare properties and methods, how constructors and destructors control an object's lifecycle, how visibility (public/protected/private) shapes an API, and how constants and static members differ from instance state.

Everything in PHP's object-oriented layer starts from one idea: a class is a blueprint, and an object is a concrete thing built from that blueprint. A class User declaration describes what every user *could* have — a name, an email, a way to greet someone — but declares nothing that actually exists yet. Only when you write new User(...) does PHP allocate a real object in memory with its own copy of those properties. This lesson builds the vocabulary — properties, methods, constructors, destructors, visibility, constants, static members, final and abstract — that every later OOP lesson (encapsulation, inheritance, interfaces, traits) assumes you already have solid.

Declaring a class: properties and methods

A class is declared with the class keyword followed by a name (by convention, UpperCamelCase) and a body in braces. Inside that body, properties are variables that belong to each object (declared with a visibility keyword and optionally a type and default), and methods are functions that belong to the class and usually operate on that object's properties.

Modern PHP (7.4+) lets you *type* properties, just like you type function parameters: private string $name; means every User object's $name property must hold a string (or, if you write ?string, a string or null) — PHP enforces this at write time and throws a TypeError if you try to assign the wrong kind of value. Typed properties are not optional flourish; they are how you stop a class's internal state from silently drifting into an inconsistent shape as a codebase grows.

Inside a method, the current object is accessed through the pseudo-variable $this, which is automatically bound to whichever object the method was called on. $this->name reads that particular object's $name property — a different User object has its own separate $this->name, even though both objects share the exact same method code.

• Class names are UpperCamelCase by convention; property and method names are camelCase. • $this inside a non-static method always refers to the object the method was called on. • A property declared without a default value and without being set in the constructor is 'uninitialized' — reading it before assignment throws an Error for typed properties.

Constructors, destructors, and the object lifecycle

The constructor is a special method named __construct() that PHP calls automatically the moment new ClassName(...) runs. It exists to put a newly created object into a valid state before anyone can touch it — typically by accepting arguments and assigning them to properties. If a class has no __construct(), new ClassName() still works, but every property must then already have a default or be set some other way before use.

PHP 8.0 introduced constructor property promotion: writing a visibility keyword directly on a constructor parameter (public function __construct(private string $name, private string $email) {}) declares the property *and* assigns it from the argument in one line, eliminating the old three-line-per-property boilerplate (declare, accept parameter, assign). This is now the idiomatic way to write simple, data-holding constructors in modern PHP.

The destructor, __destruct(), is called automatically when an object is about to be destroyed — either because no variable references it anymore (PHP uses reference counting) or because the script is ending. Destructors are far rarer in everyday PHP than constructors: PHP's garbage collector and short-lived request lifecycle mean you rarely need to manually release resources, but a destructor is the right place to do so when a class wraps something external, like closing a file handle or a raw socket that wasn't already closed explicitly. You should never rely on a destructor for anything time-critical, since exactly *when* it runs (especially with circular references) is not always predictable.

• __construct() runs once, automatically, at new; it is never called directly by name in normal code. • Constructor property promotion (PHP 8.0+) merges property declaration + assignment into the parameter list. • __destruct() runs when the object is garbage-collected or the script ends — not on a fixed, guaranteed schedule.

Visibility: public, protected, private

Every property and method has a visibility: public (accessible from anywhere — inside the class, from subclasses, and from outside code), protected (accessible inside the class and any subclass, but not from outside code), or private (accessible only inside the exact class that declared it — not even subclasses can reach it directly). Visibility is PHP's mechanism for controlling *which code is allowed to touch what*, and it is the foundation the next lesson's encapsulation discussion builds on.

If you omit a visibility keyword on a property, PHP defaults it to public; methods default to public too. Explicit visibility is still considered good practice everywhere, because it documents intent — a reader shouldn't have to guess whether omission was deliberate.

Constants and static members

A class constant, declared with const NAME = value;, belongs to the class itself rather than to any object, and its value can never change after being defined. Constants are accessed with ClassName::NAME (or self::NAME/static::NAME from inside the class). Since PHP 7.1 constants can have visibility too (private const, protected const), letting a class keep some constants as purely internal implementation detail.

A static property or method, declared with the static keyword, also belongs to the class rather than to any single object — all objects of that class share the *same* static property, and a static method can be called without ever creating an object at all (ClassName::method()). Static state is useful for things that are genuinely class-wide (a shared counter of how many objects have been created, a registry, a cache) but should be used sparingly: because it's shared and long-lived, static state is a common source of hidden coupling and hard-to-test code — most everyday behavior should live on instances, not statics.

Inside a static method, $this is not available (there is no specific object), but you can refer to the class's own static properties and other static methods with self:: or static::.

• const = fixed value belonging to the class, never changes. • static property = one shared value across every instance of the class. • static method = callable without an object (ClassName::method()); has no $this.

final and abstract members

The final keyword, applied to a method (or an entire class), forbids overriding: final public function calculateTotal() {} means no subclass may redefine calculateTotal(). This is a deliberate design tool — you use final when a method's correctness depends on it always behaving exactly one way, and you want the language itself to enforce that rather than trusting every future maintainer to remember not to override it.

The abstract keyword marks a method that has *no body* — only a signature — and declares that any concrete (non-abstract) subclass must provide an implementation. A class containing even one abstract method must itself be declared abstract class, and an abstract class can never be instantiated directly with new; it exists only to be extended. Abstract classes are covered in depth as part of interfaces and traits, but the keyword itself belongs to this lesson's vocabulary of class-level modifiers.

Class anatomy at a glance

self::$instanceCount refers to the static property of the class the code is written in. spl_object_hash() is a built-in that returns a unique id string for an object instance.

class ClassName
{
    // class constant
    public const MAX_LEVEL = 100;

    // static property, shared across all instances
    private static int $instanceCount = 0;

    // typed instance properties
    private string $name;
    protected int $level = 1;

    public function __construct(string $name)
    {
        $this->name = $name;
        self::$instanceCount++;
    }

    public function __destruct()
    {
        // runs when this object is garbage-collected
    }

    // instance method
    public function getName(): string
    {
        return $this->name;
    }

    // static method, called via ClassName::method()
    public static function getInstanceCount(): int
    {
        return self::$instanceCount;
    }

    // final: cannot be overridden by any subclass
    final public function id(): string
    {
        return spl_object_hash($this);
    }
}

Code Examples

The constructor uses promotion to declare and assign $name and $price in one step. fromArray() is a static 'named constructor' — a common pattern for building an object from data that doesn't map 1:1 to the primary constructor's arguments. self::$created is one counter shared by every Product, incremented once per construction regardless of which path (new directly, or fromArray) was used. Expected result: 59.99 23.4 2

$balance and $owner are private, so the *only* way outside code can change or read them is through the methods BankAccount chooses to expose — deposit() and getBalance(). This is what makes deposit()'s validation ($amount <= 0 guard) actually meaningful: there is no back door that lets a caller push the balance negative by writing to the property directly. Expected result: 100 private float $balance = 0.0;

class Product
{
    public const TAX_RATE = 0.20;

    private static int $created = 0;

    public function __construct(
        private string $name,
        private float $price,
    ) {
        self::$created++;
    }

    public function priceWithTax(): float
    {
        return round($this->price * (1 + self::TAX_RATE), 2);
    }

    public static function fromArray(array $data): self
    {
        return new self($data['name'], (float) $data['price']);
    }

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

$p1 = new Product('Keyboard', 49.99);
$p2 = Product::fromArray(['name' => 'Mouse', 'price' => '19.5']);

echo $p1->priceWithTax(), PHP_EOL;
echo $p2->priceWithTax(), PHP_EOL;
echo Product::howManyCreated(), PHP_EOL;
class BankAccount
{
    private float $balance = 0.0;

    public function __construct(private readonly string $owner) {}

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

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

$acct = new BankAccount('Priya');
$acct->deposit(100);
echo $acct->getBalance(), PHP_EOL;   // 100

// $acct->balance += 1000000;        // Fatal error: Cannot access private property
// echo $acct->owner;                 // Fatal error: Cannot access private property

One class, many objects

Both objects run the exact same greet() method code, but each has its own independent copy of $name and $email.

class User (blueprint)
        +----------------------+
        | -name: string        |
        | -email: string       |
        | +greet(): string     |
        +----------------------+
                  |
        new User('Ana', 'a@x.com')   new User('Leo', 'l@x.com')
                  |                             |
                  v                             v
        +----------------+           +----------------+
        | name: 'Ana'    |           | name: 'Leo'    |
        | email: a@x.com |           | email: l@x.com |
        +----------------+           +----------------+
         object #1 (its own state)     object #2 (its own state)

Important Rules

A class defines structure and behavior; an object is one concrete instance created with new.

Prefer typed properties — they catch state-shape bugs at the point of assignment, not weeks later.

Use constructor property promotion for simple data-holding constructors; write it out longhand only when the constructor needs extra logic beyond assignment.

Default to the most restrictive visibility (private) and only widen it (protected, then public) when something genuinely needs that access.

Reach for static only for state or behavior that is truly class-wide, not per-object — overusing static members reintroduces global-variable-style coupling.

Why this matters in real codebases

Nearly every framework class you will ever extend — an Eloquent model, a Symfony controller, a PSR-7 request object — is built from exactly these primitives: typed properties for state, a constructor that establishes a valid starting point, visibility that draws a line between 'my internal bookkeeping' and 'the API other code is allowed to call', and static/const members for the handful of things that are genuinely shared across every instance. Getting comfortable with plain classes before adding inheritance, interfaces, or traits means you can read framework source code and immediately recognize what's core-language behavior versus what's framework-specific magic.

class Counter { public static $count = 0; function inc() { $count++; } } class Counter { public static int $count = 0; function inc() { self::$count++; } }

Inside a method, a static property is never a plain local $count — it must be addressed through self::$count (or static::$count). Without the self:: prefix, PHP treats $count as an unrelated local variable that starts at null/undefined and is silently discarded when the method returns.

public function __construct($name) { $name; } // forgot to assign to $this->name public function __construct($name) { $this->name = $name; }

A constructor parameter is just a local variable unless you explicitly assign it onto $this. Forgetting the assignment leaves the object's property at its default (or uninitialized, throwing on typed properties) even though the constructor 'looks' like it accepted the value.

[multiple-choice] What does new User('Ana') actually do? A. It defines the User class for the first time B. It calls User::__construct('Ana') and returns a new, independent User object C. It permanently changes the User class's default property values D. It is only valid if User has no properties

B. It calls User::__construct('Ana') and returns a new, independent User object — new allocates a fresh object of that class and immediately invokes its constructor with the given arguments; the class declaration itself was already parsed earlier and is not re-defined by new.

[multiple-choice] Which statement about static properties is correct? A. Each object gets its own separate copy of a static property B. A static property is shared by all instances of the class and can be accessed without any instance via ClassName::$prop C. Static properties cannot have a default value D. Static properties are automatically private

B. A static property is shared by all instances of the class and can be accessed without any instance via ClassName::$prop — Static properties belong to the class, not to any instance — every object (and code with no object at all) sees and shares the same value via ClassName::$prop or self::$prop/static::$prop from inside the class.

[predict-output] class Box { public function __construct(private int $items = 0) {} public function add(int $n): void { $this->items += $n; } public function count(): int { return $this->items; } } $b = new Box(); $b->add(3); $b->add(4); echo $b->count(); A. 0 B. 3 C. 4 D. 7

D. 7 — $items starts at 0 (the promoted parameter's default). add(3) makes it 3, then add(4) makes it 7. count() returns the object's current $items, so the output is 7.

Write a Temperature class with a private, typed float $celsius property, a constructor that accepts a starting Celsius value, a public method toFahrenheit(): float that returns the Fahrenheit equivalent, a class constant FREEZING_C = 0.0, and a static method fromFahrenheit(float $f): self that builds a Temperature from a Fahrenheit value.

class Temperature { public const FREEZING_C = 0.0; public function __construct(private float $celsius) {} public function toFahrenheit(): float { return $this->celsius * 9 / 5 + 32; } public static function fromFahrenheit(float $f): self { return new self(($f - 32) * 5 / 9); } }

Fahrenheit-to-Celsius: (F - 32) * 5/9. Celsius-to-Fahrenheit: C * 9/5 + 32. The static factory should compute Celsius and pass it to new self(...).