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

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

- Published: 2026-05-23
- Category: Languages
- Tags: PHP, OOP
- Reading time: 9 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/php-constants-enums-readonly-decision-tree/
- Language: en-US
- Author: Muhammet Şafak

---
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)](/en/blog/php-8-3-typed-class-constants-and-new-additions/)
- [`enum` — pure + backed (PHP 8.1)](/en/blog/php-8-1-safer-domain-models-with-enums-and-readonly/)
- `readonly` property (PHP 8.1)
- [`readonly class` (PHP 8.2)](/en/blog/php-8-2-readonly-classes-and-new-types/)
- [Asymmetric visibility (PHP 8.4)](/en/blog/php-8-4-property-hooks-and-asymmetric-visibility/)

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:

| Tool | PHP | Timing | Scope | Behavior |
|---|---|---|---|---|
| `define()` | always | runtime | global | plain |
| `const` (top-level) | always | compile | namespace | plain |
| Class `const` | 7.1+ | compile | class | plain |
| Interface const | always | compile | shared | plain |
| `final` class const | 8.1+ | compile | class (no override) | plain |
| Typed class const | 8.3+ | compile | class (**type-safe**) | plain |
| `enum` | 8.1+ | compile | class | **type + methods** |
| `readonly` prop | 8.1+ | runtime | instance | plain |
| `readonly class` | 8.2+ | runtime | instance (all props) | plain |
| Asymmetric visibility | 8.4+ | runtime | instance | "soft readonly" |

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

## 1. `define()` — Runtime, Global, Plain

```php
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:

```php
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

```php
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

```php
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:

```php
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

```php
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

```php
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**)

```php
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; // Fatal error
}
```

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

```php
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

```php
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

```php
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.

```php
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"

```php
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('test@example.com', 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**

```php
// 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**

```php
// 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**

```php
// 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**

```php
// 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**

```php
// 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**

```php
// 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`**

```php
// 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**

```php
// 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](https://medium.com/@muhammetsafak/php-sabiti-const-i%CC%87fadesi-vs-define-i%CC%87%C5%9Flevi-7d044009e336). Four years have passed, PHP has shipped four 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.
