PHP::OOP_Mastery
Overview / Session 05 / Session 06
Session 06 of 10  ·  2 hrs + 15 min review

Static Methods,
Properties & Constants

⏱ 2-hour session 📋 15-min review ⚠ Requires Sessions 01–05 🗂 4 topics

1 — Static Properties & Methods

Everything covered so far has been instance state — each object gets its own copy. Static properties and methods belong to the class itself, not to any particular instance. They exist once, shared across all instances and accessible without creating an object at all.

Access them with the scope resolution operator :: — called "double colon" or "Paamayim Nekudotayim" (you'll see that in PHP error messages). From inside the class, use self:: to refer to the current class statically.

PHP — static properties and methods
class Counter
{
    // One shared value across ALL Counter instances
    private static int $count = 0;

    public function __construct()
    {
        self::$count++;   // increment shared counter on each new instance
    }

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

    public static function reset(): void
    {
        self::$count = 0;
    }
}

new Counter();
new Counter();
new Counter();

echo Counter::getCount();  // 3 — no object needed to call this
Counter::reset();
echo Counter::getCount();  // 0

Static is class-level — the consequences

  • Static methods have no $this — there is no object context
  • Static properties are shared in memory — every part of the program sees the same value
  • They persist for the life of the request (PHP's process model), which can cause state leakage between tests
  • Static makes code harder to mock/test — use sparingly and with purpose

Legitimate uses for static methods

Not all static is bad. The right uses are stateless utility methods and named constructors (factory methods) that improve readability:

PHP — Named constructors (static factory methods)
class Color
{
    private function __construct(  // private! Forces use of named constructors
        private int $r,
        private int $g,
        private int $b
    ) {}

    // Named constructors — each tells you exactly what it creates
    public static function fromRgb(int $r, int $g, int $b): static
    {
        return new static($r, $g, $b);
    }

    public static function fromHex(string $hex): static
    {
        $hex = ltrim($hex, '#');
        return new static(
            hexdec(substr($hex, 0, 2)),
            hexdec(substr($hex, 2, 2)),
            hexdec(substr($hex, 4, 2))
        );
    }

    public static function red():   static { return new static(255, 0,   0);   }
    public static function green(): static { return new static(0,   255, 0);   }
    public static function blue():  static { return new static(0,   0,   255); }

    public function toHex(): string
    {
        return sprintf('#%02x%02x%02x', $this->r, $this->g, $this->b);
    }
}

// Much more readable than new Color(255, 0, 0):
$crimson   = Color::fromHex('#DC143C');
$primary   = Color::blue();
$custom    = Color::fromRgb(100, 200, 50);

Notice new static instead of new self in the factory methods. This is the setup for the next topic — late static binding.

2 — Constants

Class constants are values that are fixed at compile time and never change. They are declared with the const keyword and accessed via ::. Unlike static properties they cannot be reassigned — they're truly immutable.

PHP — Class constants (visibility since PHP 7.1)
class HttpStatus
{
    // Constants are SCREAMING_SNAKE_CASE by convention
    public const int    OK                    = 200;
    public const int    CREATED               = 201;
    public const int    NO_CONTENT            = 204;
    public const int    BAD_REQUEST           = 400;
    public const int    UNAUTHORIZED          = 401;
    public const int    NOT_FOUND             = 404;
    public const int    UNPROCESSABLE_ENTITY  = 422;
    public const int    INTERNAL_SERVER_ERROR = 500;

    // Group related constants into an array constant (PHP 5.6+)
    public const array  SUCCESS_CODES = [200, 201, 204];
}

// Access without an instance:
echo HttpStatus::NOT_FOUND;          // 404

// Type-safe status checks:
if ($response->getStatus() === HttpStatus::UNAUTHORIZED) {
    redirectToLogin();
}

// Inside a method — use self::CONSTANT
class ApiClient
{
    private const string BASE_URL    = 'https://api.example.com/v2';
    private const int    TIMEOUT_SEC = 30;

    public function get(string $path): array
    {
        return $this->http->request(
            self::BASE_URL . $path,
            ['timeout' => self::TIMEOUT_SEC]
        );
    }
}

Interface constants

Interfaces can also declare constants, and any class implementing the interface inherits them. This is a clean way to share domain-level constants without coupling to a concrete class.

PHP — Constants in interfaces
interface HasRoles
{
    public const string ROLE_ADMIN  = 'admin';
    public const string ROLE_EDITOR = 'editor';
    public const string ROLE_VIEWER = 'viewer';

    public function hasRole(string $role): bool;
}

class User implements HasRoles
{
    public function __construct(private array $roles) {}

    public function hasRole(string $role): bool
    {
        return in_array($role, $this->roles, true);
    }
}

$user = new User([HasRoles::ROLE_EDITOR]);
var_dump($user->hasRole(HasRoles::ROLE_ADMIN));   // bool(false)
var_dump($user->hasRole(HasRoles::ROLE_EDITOR));  // bool(true)

PHP 8.1 Enums — the modern constant group

PHP 8.1 introduced native enums, which replace many patterns that previously used class constants. Where you once wrote const STATUS_PENDING = 'pending', you now write a proper type-safe enum.

PHP 8.1 — Backed enum (replaces constant groups)
enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::Pending   => 'Awaiting payment',
            self::Paid      => 'Payment received',
            self::Shipped   => 'On the way',
            self::Cancelled => 'Cancelled',
        };
    }

    public function isFinal(): bool
    {
        return $this === self::Shipped || $this === self::Cancelled;
    }
}

class Order
{
    private OrderStatus $status = OrderStatus::Pending;

    public function ship(): void
    {
        if ($this->status !== OrderStatus::Paid) {
            throw new LogicException('Can only ship paid orders');
        }
        $this->status = OrderStatus::Shipped;
    }
}

echo OrderStatus::Shipped->label();    // "On the way"
var_dump(OrderStatus::Paid->isFinal()); // bool(false)

// Type safety — this is now a compile error, not a runtime surprise:
// $order->ship(); // works
// $order->status = 'invalid'; // Fatal: type mismatch

3 — self:: vs static::: Late Static Binding

This is one of the most misunderstood PHP features — and one that bites developers regularly when using factory methods in inheritance hierarchies.

self:: always refers to the class where the method is physically written, determined at compile time.
static:: refers to the class that was actually called at runtime — the "late" in "late static binding" means the binding happens at call time, not compile time.

PHP — self:: vs static:: in practice
class Model
{
    protected static string $table = 'models';

    // ── Using self:: ──────────────────────────────────────────
    public static function findWithSelf(int $id): static
    {
        echo "SELECT * FROM " . self::$table;
        // self:: always resolves to Model — even when called on User!
        return new self();   // Always creates a Model, not the subclass
    }

    // ── Using static:: ────────────────────────────────────────
    public static function find(int $id): static
    {
        echo "SELECT * FROM " . static::$table;
        // static:: resolves to whichever class was actually called
        return new static();  // Creates the correct subclass
    }
}

class User extends Model
{
    protected static string $table = 'users';  // override the table name
}

class Post extends Model
{
    protected static string $table = 'posts';
}

// ── Calling via a subclass ────────────────────────────────
User::findWithSelf(1);
// Prints: "SELECT * FROM models" ← WRONG (self:: locked to Model)

User::find(1);
// Prints: "SELECT * FROM users" ← CORRECT (static:: resolved at runtime)

Post::find(2);
// Prints: "SELECT * FROM posts" ← CORRECT

// Also: new static() returns the correct subclass type
$user = User::find(1);
echo get_class($user); // "User" — not "Model"

Quick reference

  • self:: — the class the method is written in (compile time)
  • static:: — the class the method is called on (runtime)
  • parent:: — the immediate parent class
  • new static() — creates an instance of whatever class was called
  • new self() — always creates the class where the code lives

Rule: in factory/clone methods that might be inherited, always use static:: and new static(). Use self:: only when you specifically want to lock behaviour to the declaring class, regardless of subclassing.

4 — The Singleton & Factory Patterns

These are the two most common real-world uses of static. You'll encounter both in framework source code constantly.

Singleton — one instance, globally accessible

The Singleton pattern ensures a class has exactly one instance and provides a global access point to it. Classic use-cases: database connections, configuration, application registry. Use sparingly — singletons are notoriously hard to test. Prefer dependency injection instead when possible.

PHP — Thread-safe Singleton pattern
class Config
{
    // Holds the single instance — null until first call
    private static ?self $instance = null;
    private array $data = [];

    // Private constructor — nobody can call new Config()
    private function __construct() {}

    // Private clone — prevent copying the singleton
    private function __clone() {}

    public static function getInstance(): static
    {
        if (static::$instance === null) {
            static::$instance = new static();
        }
        return static::$instance;
    }

    public function set(string $key, mixed $value): void
    {
        $this->data[$key] = $value;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }
}

// Always returns the same object:
Config::getInstance()->set('debug', true);
$debug = Config::getInstance()->get('debug'); // true — same instance

Factory pattern — decouple creation from use

A Factory creates objects without the caller needing to know the concrete class. The caller asks for "a payment gateway" — the factory decides whether to give Stripe, PayPal, or a test double.

PHP — Static factory method pattern
class NotificationFactory
{
    // Returns correct subclass based on type — caller doesn't need to know which
    public static function create(string $type, string $recipient, string $message): Notification
    {
        return match($type) {
            'email' => new EmailNotification($recipient, $message, 'Notification'),
            'sms'   => new SmsNotification($recipient, $message, $recipient),
            'push'  => new PushNotification($recipient, $message),
            default => throw new InvalidArgumentException("Unknown type: {$type}"),
        };
    }
}

// Consumer asks for a notification — doesn't care about the subclass:
$channel = $user->preferredChannel();   // 'email', 'sms', or 'push'
$notif   = NotificationFactory::create($channel, $user->getEmail(), 'Order shipped!');
$notif->send();

// Switch user to SMS? Zero code changes in the consumer.

When static is the wrong choice

The pattern you should avoid: using static to avoid passing dependencies, creating hidden global state, or making classes that are impossible to swap for testing.

  • ❌ DB::query(...) — hidden database coupling, impossible to test without a real DB
  • ❌ Log::error(...) — no way to capture logs in tests
  • ✅ Inject a Logger interface and call $this->logger->error(...) — swappable, testable

15-Minute Review — Session 06

Switch the sidebar timer to review mode. No scrolling up — answer from memory.

Q1 — A static property is shared:

Q2 — What is the key difference between self:: and static::?

Q3 — In the Singleton pattern, why is the constructor made private?

Q4 — What is the difference between a class constant and a static property?

Q5 — In a named constructor like Color::fromHex(), why use new static() instead of new self()?

✓ Key concepts checklist

Next: Traits →