Skip to content
Muhammet Şafak
tr
Languages 16 min read

PHP Constants, Enums, and Readonly: One Decision Tree for 8 Tools

From define to asymmetric visibility — a three-axis mental model and a concrete decision matrix for getting immutability right in modern PHP.


If a PHP developer still reaches for define() when asked to “declare a constant,” they’ve missed the last six years of the language. If that same developer responds to “create an immutable object” by manually assigning in the constructor and deleting setters, they’ve missed the last three years too.

Today in PHP there are ten different ways to say “define something, then prevent it from changing”:

  • define()
  • const (top-level)
  • Class const (with visibility)
  • Interface constants
  • final class constants
  • Typed class constants (PHP 8.3)
  • enum — pure + backed (PHP 8.1)
  • readonly property (PHP 8.1)
  • readonly class (PHP 8.2)
  • Asymmetric visibility (PHP 8.4)

Most mid-senior PHP developers know half of these and use a third of them correctly. That’s not a developer problem — there simply isn’t a single resource that explains how all these tools fit under one mental model.

This post fills that gap. By the end you’ll have a single decision tree for choosing the right tool at the right time.

Three Axes: The Framework That Drives Every Choice

The answer to “which one, when?” always comes down to three axes. Once you internalize them, you can work out where each of the ten tools lands on your own.

1. When is the value determined? — Compile-time vs Runtime

Is the value resolved when the code is parsed, or computed while the program runs? const lives in memory at compile-time; define() executes at runtime. This difference affects both performance and where you can use each — for example, const cannot be declared inside a conditional block; define() can.

2. What is its scope? — Global / Namespace / Class / Instance

Where does the value live? Does it span the entire application (global), belong to a namespace (namespace-scoped), belong to a specific class (class), or to an instance of that class (instance)? The narrower the scope, the more disciplined the code.

3. Can it carry behavior? — Plain Value vs Typed Construct with Methods

Is this constant just a data point, or should it be a type with its own behavior (methods, associated constants)? A class const is a plain value; an enum is a construct that combines type, closed set, and behavior. This axis marks the exact point where overused class constants get replaced by enums.

Here’s where all ten tools sit across those three axes:

ToolPHPTimingScopeBehavior
define()alwaysruntimeglobalplain
const (top-level)alwayscompilenamespaceplain
Class const7.1+compileclassplain
Interface constalwayscompilesharedplain
final class const8.1+compileclass (no override)plain
Typed class const8.3+compileclass (type-safe)plain
enum8.1+compileclasstype + methods
readonly prop8.1+runtimeinstanceplain
readonly class8.2+runtimeinstance (all props)plain
Asymmetric visibility8.4+runtimeinstance”soft readonly”

Let’s now walk through each one briefly with examples.

1. define() — Runtime, Global, Plain

define('APP_NAME', 'MuhammetSafak');
define('MAX_RETRIES', 3);

Runtime, global scope, plain value. The original tool left over from PHP 4 days. It can be declared inside a conditional block:

if ($env === 'production') {
    define('LOG_LEVEL', 'error');
} else {
    define('LOG_LEVEL', 'debug');
}

When to use it: Almost never these days. The only legitimate case is when a value genuinely needs to be determined conditionally or via a runtime computation. Everywhere else, prefer const or a config object.

Common mistake: The habit of storing global settings with define. That’s a reflex left over from 2010-era PHP. The modern answer is a typed config class or env-injection.

2. const (Top-Level) — Compile-Time, Namespace, Plain

namespace App\Config;

const MAX_UPLOAD_SIZE = 10 * 1024 * 1024;
const SUPPORTED_LOCALES = ['tr', 'en', 'de'];

Resolved at compile-time, belongs to a namespace, plain value. It cannot be declared inside a conditional block because it must be resolved at compile-time.

When to use it: For a value that is truly constant and not logically tied to a class, scoped to a namespace. In practice: file-scoped helper constants.

Common mistake: Confusing top-level const with class-level const. The latter is more common and more appropriate — constants that are logically tied to a class should always live inside that class.

3. Class const (With Visibility) — Compile-Time, Class, Plain

class HttpClient
{
    public const DEFAULT_TIMEOUT = 30;
    protected const RETRY_BACKOFF_MS = 250;
    private const INTERNAL_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0';
}

Visibility (public, protected, private) has been supported since PHP 7.1. A plain value that’s logically related to a class, resolved at compile-time.

When to use it: For values that configure a class — things that govern the class’s internal behavior and shouldn’t change from the outside.

Common mistake: Defining status/role/type values as class constants:

class Order
{
    public const STATUS_PENDING = 1;
    public const STATUS_PAID = 2;
    public const STATUS_SHIPPED = 3;
    public const STATUS_CANCELLED = 4;
}

If you’re still writing this, pay close attention to the next section — enum exists precisely to solve this problem.

4. Interface Constants — Compile-Time, Shared, Plain

interface CacheableInterface
{
    public const DEFAULT_TTL = 3600;
    public const MAX_TTL = 86400 * 30;
}

class RedisCache implements CacheableInterface
{
    public function get(string $key, int $ttl = self::DEFAULT_TTL): mixed
    {
        // ...
    }
}

Interface constants are used for values that multiple classes need to share.

When to use it: When the same constant will be used across multiple implementations and is part of the contract.

Common mistake: Using an interface purely as a constant container. An interface is a behavioral contract, not a constant bag. An interface that holds only constants is a bad smell.

5. final Class Constants — Compile-Time, Class (No Override), Plain

class PaymentGateway
{
    final public const API_VERSION = 'v2';
}

class StripeGateway extends PaymentGateway
{
    // public const API_VERSION = 'v3'; // Fatal error
}

Since PHP 8.1, class constants can be declared final. Subclasses cannot override them.

When to use it: When you’re certain a constant must not change in subclasses — to prevent accidental overrides in inheritance hierarchies.

Common mistake: Stamping final on every constant. final represents a decision — “this doesn’t change from this point forward.” Use it where it’s warranted, not everywhere.

6. Typed Class Constants — Compile-Time, Class, Plain (Type-Safe)

class Logger
{
    public const string LEVEL_INFO = 'info';
    public const int MAX_BUFFER = 1000;
    public const array CHANNELS = ['file', 'syslog', 'stderr'];
}

class JsonLogger extends Logger
{
    // public const int LEVEL_INFO = 5; // TypeError
}

A long-awaited feature introduced in PHP 8.3. Class constants can now carry type declarations. When overridden, type compatibility is enforced.

When to use it: If you’re on PHP 8.3+, on every class constant. Writing the type costs three extra characters; the safety it provides is worth far more.

Common mistake: Leaving old, untyped class constants as-is in a PHP 8.3+ project. Migration is easy — automated tooling (Rector) can handle this in a single pass.

7. enum — Type + Methods, Closed Set

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::Pending => 'Pending',
            self::Paid => 'Paid',
            self::Shipped => 'Shipped',
            self::Cancelled => 'Cancelled',
        };
    }

    public function isTerminal(): bool
    {
        return in_array($this, [self::Shipped, self::Cancelled]);
    }
}

function processOrder(Order $order, OrderStatus $newStatus): void
{
    if ($order->status->isTerminal()) {
        throw new LogicException('A completed order cannot be modified.');
    }
    // ...
}

The biggest transformation PHP 8.1 brought. Type + closed set + behavior in a single construct. Backed enums (with string/int values) are for database/JSON mapping; pure enums (cases only) are for memory-only state.

When to use it: Anywhere you say “this value can only be one of these few things.” Status, role, type, priority, color, currency, language — all of these are enum candidates.

Common mistake: Sticking with the const STATUS_PENDING = 1 pattern shown in the previous section. If you’re still writing that: migrate today using php artisan or a similar toolchain. This is one of the biggest productivity wins of the PHP 8.x era.

8. readonly Property — Runtime, Instance, Plain

final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}

    public function add(Money $other): Money
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Cannot add amounts in different currencies.');
        }
        return new Money($this->amount + $other->amount, $this->currency);
    }
}

$price = new Money(100, 'TRY');
// $price->amount = 200; // Error: Cannot modify readonly property
$discounted = $price->add(new Money(-20, 'TRY')); // New object, original unchanged

Since PHP 8.1, properties can be declared readonly. They are set in the constructor and cannot be changed afterwards. The natural PHP equivalent of the value object pattern.

When to use it: Whenever specific properties of an object must not change after construction. DTOs, value objects, configuration objects.

Common mistake: Trying to achieve immutability by deleting setters and throwing in __set. If you’re on PHP 8.1+, that pattern is outdated — readonly delivers the same result with a language-level guarantee.

9. readonly class — Runtime, Instance (All), Plain

final readonly class UserDto
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public DateTimeImmutable $createdAt,
    ) {}
}

Since PHP 8.2, an entire class can be declared readonly. Every property automatically becomes readonly — no need to write it property by property.

When to use it: For classes where all properties are immutable. Typical candidates: DTO, value object, event payload, command/query object.

Common mistake: Storing a mutable collection (array, ArrayObject) inside a readonly class. readonly only freezes the property reference — it does not freeze the internal state of whatever that reference points to. If you hold a mutable array in a readonly property, the array’s contents can still be changed.

final readonly class TagSet
{
    public function __construct(
        public array $tags, // readonly, but the contents are still mutable!
    ) {}
}

$set = new TagSet(['a', 'b']);
// $set->tags = []; // Error
$tags = $set->tags;
$tags[] = 'c'; // $set->tags is unaffected (PHP arrays are copied on assignment)

// But with an object reference:
$obj = new stdClass();
$obj->value = 1;
$set2 = new SomeReadonly($obj);
$set2->obj->value = 999; // Works! readonly protects the reference, not the contents.

This nuance matters — readonly provides shallow, not deep, immutability.

10. Asymmetric Visibility — Runtime, Instance, “Soft Readonly”

final class User
{
    public function __construct(
        public private(set) string $email,
        public private(set) DateTimeImmutable $lastLoginAt,
    ) {}

    public function recordLogin(DateTimeImmutable $at): void
    {
        $this->lastLoginAt = $at; // Writable from inside the class
    }
}

$user = new User('[email protected]', new DateTimeImmutable());
echo $user->email; // Readable from outside
// $user->email = 'foo@bar'; // Error: Cannot modify
$user->recordLogin(new DateTimeImmutable()); // Writable from inside

A new feature introduced in PHP 8.4. A property’s read visibility and write visibility can be declared separately. public private(set) = readable from outside, writable only from within the class.

When to use it: When readonly’s “never changes” strictness is too rigid. You don’t want a property changed from the outside, but internal methods should be able to write to it.

Common mistake: Treating this as a wholesale replacement for the private property + public getter pattern. It actually is a replacement — but that doesn’t mean “convert every readonly to asymmetric visibility.” readonly and asymmetric visibility express different intentions: the first says “never changes,” the second says “doesn’t change from outside, but can change internally.”

Decision Tree: Which Tool, When?

Here’s a single flow that puts everything together:

1. Will the value be determined at runtime?
   ├─ Yes → define()
   └─ No (compile-time) → go to 2

2. Does it belong to a class?
   ├─ No (namespace-scoped) → const (top-level)
   └─ Yes → go to 3

3. Is it a closed set (status, role, color, etc.)?
   ├─ Yes → enum (backed or pure)
   └─ No → go to 4

4. Will it be shared across multiple classes?
   ├─ Yes → interface constant
   └─ No → go to 5

5. Are you on PHP 8.3+?
   ├─ Yes → typed class constant
   └─ No → class constant (with visibility)

6. Should it be non-overridable?
   └─ Yes → add final to whichever you chose above

Object-side immutability is a separate flow:

A. I want a object's properties to be immutable.
   ├─ All properties immutable? → readonly class
   └─ Only some of them → readonly property

B. A property should be readable from outside but only writable from inside.
   └─ PHP 8.4+ → public private(set) (asymmetric visibility)
   └─ PHP 8.3 and below → private property + public getter

Anti-Patterns: Mistakes I Commonly See in the PHP Ecosystem

1. Class const for status values

// Wrong (as of 2026)
class Order
{
    public const STATUS_PENDING = 1;
    public const STATUS_PAID = 2;
    public const STATUS_SHIPPED = 3;
}

// Right
enum OrderStatus: int
{
    case Pending = 1;
    case Paid = 2;
    case Shipped = 3;
}

This alone can be a migration project in its own right. It appears hundreds of times in a codebase and brings type safety, IDE autocomplete, and exhaustive match checking all at once.

2. A pile of define() calls for global config

// Wrong
define('DB_HOST', 'localhost');
define('DB_NAME', 'app');
define('REDIS_HOST', '127.0.0.1');
define('MAIL_DRIVER', 'smtp');
// ... 40 more lines

// Right
final readonly class DatabaseConfig
{
    public function __construct(
        public string $host,
        public string $name,
        public string $user,
    ) {}
}

Typed config objects are both testable and IDE-autocomplete-friendly. The less global state, the more deterministic the code.

3. “Immutability” via deleting setters

// Wrong
class Money
{
    private int $amount;
    private string $currency;

    public function __construct(int $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function getAmount(): int { return $this->amount; }
    public function getCurrency(): string { return $this->currency; }
    // no setters = what we mistakenly call "immutable"
}

// Right
final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}
}

The second version is half the length and carries a language-level guarantee. If you’re on PHP 8.2+, there’s no excuse to keep writing the first.

4. Using an interface as a constant container

// Wrong
interface Constants
{
    public const PI = 3.14;
    public const E = 2.71;
}

class Calculator implements Constants
{
    // ...
}

Interface = behavioral contract. Holding constants is legitimate — but inventing an interface purely to hold constants is the wrong abstraction.

5. Magic numbers everywhere

// Wrong
if ($user->loginAttempts >= 5) {
    $user->lock();
}
sleep(300);

// Right
final class AuthPolicy
{
    public const int MAX_LOGIN_ATTEMPTS = 5;
    public const int LOCKOUT_SECONDS = 300;
}

if ($user->loginAttempts >= AuthPolicy::MAX_LOGIN_ATTEMPTS) {
    $user->lock();
}
sleep(AuthPolicy::LOCKOUT_SECONDS);

The oldest advice in the book — and still the most frequently violated.

Migration: Moving Legacy Code Forward

From class const status values to enums

// Before
class Order
{
    public const STATUS_PENDING = 1;
    public const STATUS_PAID = 2;

    public int $status;
}

if ($order->status === Order::STATUS_PAID) { ... }

// After
enum OrderStatus: int
{
    case Pending = 1;
    case Paid = 2;
}

class Order
{
    public OrderStatus $status;
}

if ($order->status === OrderStatus::Paid) { ... }

Migration is generally straightforward — as long as you preserve the value mapping (int backed enum), the database schema doesn’t change. The one thing to watch: PDO/ORM mapping.

From setter-less class to readonly class

// Before
class UserDto
{
    private int $id;
    private string $name;

    public function __construct(int $id, string $name) { ... }
    public function getId(): int { return $this->id; }
    public function getName(): string { return $this->name; }
}

// After
final readonly class UserDto
{
    public function __construct(
        public int $id,
        public string $name,
    ) {}
}

// Callsites: $dto->getId() becomes $dto->id

This migration requires updating getter callsites throughout the codebase. IDE refactoring tools (PhpStorm’s “Inline Method”) make short work of it.

From global define calls to typed config

// Before
define('STRIPE_KEY', getenv('STRIPE_KEY'));
define('STRIPE_WEBHOOK_SECRET', getenv('STRIPE_WEBHOOK_SECRET'));

// After
final readonly class StripeConfig
{
    public function __construct(
        public string $key,
        public string $webhookSecret,
    ) {}

    public static function fromEnv(): self
    {
        return new self(
            key: getenv('STRIPE_KEY') ?: throw new RuntimeException('STRIPE_KEY missing'),
            webhookSecret: getenv('STRIPE_WEBHOOK_SECRET') ?: throw new RuntimeException('STRIPE_WEBHOOK_SECRET missing'),
        );
    }
}

// Register in your container, then inject

This is the largest migration of the three, but also the most valuable. Every reduction in global state multiplies the testability of your code.

Performance: Relevant, But Not the Deciding Factor

At the micro-benchmark level:

  • const is faster than define() (compile-time resolution)
  • Class constants are faster than variable access
  • enum instances are singletons — one object per case, === comparison is O(1)
  • readonly has minimal overhead — just a write-time check

In practice, none of these will be a bottleneck in a typical web application. Pick the right abstraction; performance is usually a byproduct of correctness. Don’t choose the wrong abstraction because “it’s faster” — the readability cost far outweighs microsecond gains.

Exception: if you’re writing hot-loop code with millions of iterations (hashing, parsers, transformations), repeated enum::label() calls accumulate match overhead — class const can be faster there. Measure first, optimize second.

Closing Thoughts

Immutability in PHP is not a single tool; it’s ten different answers to different combinations of three axes (timing, scope, behavior). The question of which to choose is really the question of what you’re trying to do:

  • Are you holding a value, or creating a type?
  • When does the value become known?
  • Is the thing that must not change a reference or content?

Those three questions lead you to the right tool. You don’t need to memorize all ten tools — memorize the three axes, and the rest follows.

One last note: I covered this topic back in 2022, through the lens of const vs define. Four years have passed, PHP has shipped seven new versions, and the ecosystem has transformed. Technical writing has a shelf life — if you’re reading this in 2030, there’s probably an updated version out there somewhere.

Tags: #PHP#OOP
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind