PHP 8.3 Features Every Backend Team Should Be Using
Run php --version on a modern Symfony application and you will likely see PHP 8.2 or 8.3. Open the source files and you will often see PHP 7.4 code: nullable parameters passed around without types, constructors that manually assign eight properties, constants declared without types, switch statements for logic that belongs in a match expression. The runtime has moved on. The code has not.
This gap is common, and it is costly. Every PHP 8.3 feature that remains unused is a missed opportunity to eliminate a class of runtime bugs, reduce boilerplate, or improve performance. Backend teams working in Symfony, Laravel, or custom PHP applications benefit directly from adopting these features—not as a stylistic exercise, but because they encode correctness into the type system and push errors from production into the editor.
The PHP 8.3 features that deliver the highest practical value fall into five categories: enums for domain modeling, readonly properties and classes for immutability, typed class constants for safety, match expressions for exhaustive logic, and fibers for cooperative concurrency. This post covers each of them with realistic before-and-after examples from Symfony applications.
Enums: Replacing String Constants with Real Types
Before PHP 8.1 introduced enums, teams modeled finite sets of values using class constants or free-floating strings. This worked, but it was weakly typed—nothing stopped a developer from passing 'ACTIVE' where the code expected 'active', and type hints could only narrow the parameter to string.
A typical PHP 7 pattern for order statuses looks like this:
class Order
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_SHIPPED = 'shipped';
public const STATUS_CANCELLED = 'cancelled';
public function setStatus(string $status): void
{
$this->status = $status; // Accepts any string, including typos
}
}
Rewritten with a backed enum, the same code becomes self-documenting and impossible to misuse:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function isTerminal(): bool
{
return match ($this) {
self::Shipped, self::Cancelled => true,
self::Pending, self::Paid => false,
};
}
}
class Order
{
public function setStatus(OrderStatus $status): void
{
$this->status = $status;
}
}
The type system now enforces that only valid statuses reach setStatus(). The isTerminal() method encapsulates domain logic next to the data it concerns. Doctrine supports enum types natively, so persistence is transparent. API Platform serializes enums without custom normalizers. This is the pattern every modern PHP codebase should default to for any finite domain set—roles, permissions, states, event types, feature flags.
Pure enums (without a backing value) are appropriate when the values themselves are implementation details. Backed enums are the right choice when the values appear in databases, APIs, or configuration.
Readonly Properties and Readonly Classes: Immutability by Default
Constructor property promotion introduced in PHP 8.0 already cut constructor boilerplate significantly. PHP 8.1 added readonly properties. PHP 8.2 added readonly classes, which apply the modifier to every property automatically.
Value objects, DTOs, command payloads, and query results are almost always safer as immutable types. An immutable Money object cannot be mutated in one service and unexpectedly reflect that change in another. A readonly command DTO cannot be modified after validation.
Before:
class CreateInvoiceCommand
{
private string $customerId;
private int $amountInCents;
private string $currency;
private \DateTimeImmutable $issuedAt;
public function __construct(
string $customerId,
int $amountInCents,
string $currency,
\DateTimeImmutable $issuedAt,
) {
$this->customerId = $customerId;
$this->amountInCents = $amountInCents;
$this->currency = $currency;
$this->issuedAt = $issuedAt;
}
public function getCustomerId(): string { return $this->customerId; }
public function getAmountInCents(): int { return $this->amountInCents; }
public function getCurrency(): string { return $this->currency; }
public function getIssuedAt(): \DateTimeImmutable { return $this->issuedAt; }
}
After, with a readonly class and promoted constructor parameters:
readonly class CreateInvoiceCommand
{
public function __construct(
public string $customerId,
public int $amountInCents,
public string $currency,
public \DateTimeImmutable $issuedAt,
) {}
}
The behavior is identical for consumers, but the intent is explicit: this object cannot be mutated after construction. Any attempt to reassign a property throws an Error at runtime, and static analyzers catch it before the code runs. Combined with constructor property promotion, a 25-line class becomes eight lines with stronger guarantees.
The caveat worth knowing: deep cloning a readonly object requires the with pattern, implemented as methods that return new instances with modified values. This is a small cost in exchange for immutability, and Symfony's UrlGenerator, Laravel's query builders, and Doctrine's NativeQuery already use this pattern.
Typed Class Constants: A PHP 8.3 Signature Feature
PHP 8.3 introduced typed class constants, closing a long-standing gap in the type system. Before 8.3, class constants were the last place in a typed codebase where the type could drift silently:
class ConnectionPool
{
public const MAX_CONNECTIONS = 50; // No type enforcement
// A subclass can override this as a string, array, or anything
}
class BrokenPool extends ConnectionPool
{
public const MAX_CONNECTIONS = 'fifty'; // Accepted until runtime
}
In PHP 8.3, constants declare their type, and subclass overrides must match:
class ConnectionPool
{
public const int MAX_CONNECTIONS = 50;
public const string DEFAULT_DRIVER = 'pdo_mysql';
public const array SUPPORTED_DRIVERS = ['pdo_mysql', 'pdo_pgsql'];
}
This matters most in library code with abstract base classes where subclasses are expected to override constants. Typed constants prevent subtle bugs where a subclass introduces a type mismatch that only appears when that constant reaches a type-sensitive caller.
Match Expressions: Exhaustive and Type-Safe Branching
Match expressions, introduced in PHP 8.0 and refined since, replace most uses of switch with stricter semantics: strict comparison by default, no fall-through, required exhaustiveness for the default case, and expression-level usage (they return values).
A common legacy pattern that benefits enormously from match is dispatching logic based on a type or status:
// Before: switch with implicit loose comparison and missing default
switch ($event->getType()) {
case 'order.created':
$handler = $this->orderCreatedHandler;
break;
case 'order.paid':
$handler = $this->orderPaidHandler;
break;
case 'order.shipped':
$handler = $this->orderShippedHandler;
break;
}
$handler->handle($event); // Undefined if type did not match
// After: match with exhaustiveness enforced by PHPStan/Psalm
$handler = match ($event->getType()) {
EventType::OrderCreated => $this->orderCreatedHandler,
EventType::OrderPaid => $this->orderPaidHandler,
EventType::OrderShipped => $this->orderShippedHandler,
};
$handler->handle($event);
Combined with backed enums, static analyzers (PHPStan level 9 or Psalm strict) will flag any case you forgot to handle. This turns a category of "we forgot to handle this event type" bugs from runtime errors into build failures.
Fibers: Cooperative Concurrency Without Extensions
Fibers, introduced in PHP 8.1, are the foundation for async PHP without external extensions like Swoole or ReactPHP's userland event loop. Most application developers will not write fibers directly—they will use fiber-backed libraries like AMPHP v3, ReactPHP 3.x, or upcoming Symfony components that leverage them under the hood.
The practical impact: concurrent HTTP requests, parallel database queries, and streaming responses become possible in plain PHP without rewriting the application around an event loop. A Symfony console command that fetches data from four upstream APIs sequentially can be rewritten with AMPHP to fetch all four concurrently, reducing wall-clock time from the sum of latencies to the maximum single latency.
use Amp\Future;
use function Amp\async;
$futures = [
async(fn() => $this->orderService->fetchOrders($customerId)),
async(fn() => $this->invoiceService->fetchInvoices($customerId)),
async(fn() => $this->subscriptionService->fetchSubscriptions($customerId)),
async(fn() => $this->supportService->fetchTickets($customerId)),
];
[$orders, $invoices, $subscriptions, $tickets] = Future\await($futures);
This is not a drop-in replacement for synchronous code everywhere, but for I/O-bound workloads—report generation, batch processing, webhook fan-out—fibers plus a modern async library make PHP genuinely competitive with Node.js in throughput per process.
What to Adopt First
For teams auditing an existing codebase, the practical ordering is: introduce enums for any finite domain set that currently lives as string constants, then apply readonly to value objects and DTOs, then migrate switch statements to match where the branching returns a value, then upgrade to typed class constants wherever a base class declares constants intended to be overridden. Fibers come last—they are impactful but require choosing a concurrency library and rewriting the specific code paths that benefit.
A pragmatic way to drive this adoption is combining a static analyzer upgrade (PHPStan level 8 or higher, Psalm strict) with a short internal style guide. Static analysis flags the gaps; the style guide makes the new patterns the expected default in code review. In our experience running code quality audits on Symfony codebases, applying these four patterns alone typically eliminates an entire category of type-related bugs and reduces the constructor/DTO boilerplate in the repository by 30–50%.
Teams that have been carrying PHP 7 style code on a PHP 8.3 runtime are paying the cost of the older style without collecting the benefits of the newer one. Modernizing is not a rewrite—it is a gradual, test-driven refactor that can happen file by file, PR by PR, without stopping feature work. If your team is looking at a codebase that should feel more modern than it does, or planning a broader legacy code optimization initiative, we are happy to help. Contact us at hello@wolf-tech.io or visit wolf-tech.io for a free consultation.

