Serializing Enums: Getting JSON and Database Persistence Right
AI generated
8.4
PHP · Enum Serialization · PHP 8.1
Serializing Enums
Getting JSON and database persistence right

Backed enums convert to JSON almost automatically, but as soon as Doctrine, PDO, or more complex API responses enter the picture, the built in mechanisms alone are not enough. This article shows how enum values travel reliably through JSON interfaces and relational databases, and where pure enums hit a hard limit.

10 min read Enums Persistence

1. Backed enums and json_encode: what works automatically

A backed enum carrying a scalar type such as string or int gets reduced automatically to its backing value by json_encode, with no extra code required. An enum case like Status::Active with the value active ends up in the JSON output simply as the string active.

This built in support only works directly at the top level of a single enum value though, once the enum becomes part of a larger object whose serialization is itself controlled through JsonSerializable, combining both mechanisms needs to be modeled deliberately.

It is worth noting that a pure enum without a backing type behaves fundamentally differently here, as shown in detail later in this article. The automatic conversion only applies to backed enums, since json_encode simply cannot derive a meaningful representation without a scalar value.

2. Combining JsonSerializable for complex output

As soon as a value object with an enum property is serialized through json_encode, it works correctly automatically, because PHP recursively serializes every public property and applies the same mechanism to the enum as in the previous example. Explicit control over the output shape only comes with JsonSerializable.

If the surrounding class implements JsonSerializable, you can precisely control which fields appear and in what shape, for example adding a human readable label alongside the plain backing value, without changing the enum case itself for that.


final readonly class Order implements JsonSerializable
{
    public function __construct(
        private string $id,
        private Status $status,
    ) {
    }

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status->value,
            'statusLabel' => match ($this->status) {
                Status::Active => 'Active',
                Status::Archived => 'Archived',
            },
        ];
    }
}

3. Deserializing enum values from JSON

For the reverse path, going from a JSON payload back to an enum case, the static methods from and tryFrom come into play. from throws a ValueError as soon as the given value does not match any case, tryFrom instead quietly returns null.

For input from trusted internal sources, from is usually the right choice, because an invalid value there points to a real programming error that should fail loudly. For input from external API requests, tryFrom combined with a dedicated validation error message is usually the more robust option.


$payload = json_decode($request->getBody(), true);

$status = Status::tryFrom($payload['status'] ?? '');

if ($status === null) {
    throw new InvalidArgumentException('Unknown status value');
}

4. Doctrine ORM: configuring enum type mapping correctly

Doctrine has supported backed enums directly since ORM 2.11 through the enumType argument of the Column attribute, with no need to register a custom type. The database column still stores a plain scalar value, while Doctrine automatically hydrates the matching enum case when reading.

It is important that the enum always carries a backed type, a pure enum cannot be mapped directly with this mechanism, because Doctrine needs a concrete scalar value for the column that it can compare when storing and loading.


#[Entity]
final class Order
{
    #[Column(type: 'string', enumType: Status::class)]
    private Status $status;

    public function __construct(Status $status)
    {
        $this->status = $status;
    }
}

5. Plain PDO: binding enum values in prepared statements

Without an ORM, you handle the conversion between enum case and database value yourself. When writing, it is enough to bind the enum's backing value, not the enum object itself, to a prepared statement, since PDO has no native concept of enums and only accepts scalars.

When reading from the database, PDO fundamentally only returns the raw scalar value, converting it back into an enum case has to happen explicitly through tryFrom, ideally right at the point where the database row gets translated into a domain object.


$stmt = $pdo->prepare('INSERT INTO orders (id, status) VALUES (:id, :status)');
$stmt->execute(['id' => $orderId, 'status' => $status->value]);

$row = $pdo->query('SELECT status FROM orders WHERE id = 1')->fetch();
$status = Status::from($row['status']); // throws ValueError on corrupted data

6. Pure enums: no automatic serialization

A pure enum without a backing type has no scalar value that json_encode or a database column could use, so json_encode serializes a pure enum as an empty object, which in almost every case is not the intended result.

The practical workaround is an explicit method, such as getValue() or toArray(), that manually maps the case to a string or array, often through a match expression over all cases. The same applies to database persistence: a pure enum always needs a manually maintained mapping table between case and stored value.


enum Direction
{
    case North;
    case South;
    case East;
    case West;

    public function toCode(): string
    {
        // Pure enums need an explicit mapping, there is no implicit backing value
        return match ($this) {
            self::North => 'N',
            self::South => 'S',
            self::East => 'E',
            self::West => 'W',
        };
    }
}

7. Pitfall: changing an enum's backing value in the database

If the backing value of an enum case changes in code, for example a string being renamed from old_status to legacy, already stored records with the old value are left behind in the database. from subsequently fails with a ValueError for those rows, tryFrom quietly returns null.

Changes like that always require an accompanying data migration that updates existing rows to the new value before the changed code gets deployed. An enum backing value should therefore be treated from a persistence perspective with the same caution as a database column name, a change is rarely free.

8. Validating enums when loading from untrusted sources

For any enum value coming from a source that is not fully trusted, such as an external API request or a form field, tryFrom is the safer choice over from, because a controlled error response with the right HTTP status code is better than an uncaught ValueError bubbling up as a 500.

For records coming from your own database, the situation is different: there, an invalid value is usually a sign of a bug or a missing migration, which is why from with its loud failure is often the more honest choice there, since the problem becomes visible immediately instead of quietly propagating as null.

9. Practical example: a complete enum workflow from API to database

A complete workflow starts at the incoming JSON request, where tryFrom validates the raw string and converts it into a Status case. The resulting domain object carries the enum case as a typed property, not the raw string, which structurally rules out invalid states for the rest of the code.

When storing, the enum's backing value directly provides the value for the prepared statement, when serving the API response, either the automatic backed enum serialization or an explicit JsonSerializable implementation handles the conversion back. The enum case itself stays the single source of truth for the status throughout.


final class OrderService
{
    public function createFromRequest(array $payload, PDO $pdo): Order
    {
        $status = Status::tryFrom($payload['status'] ?? '')
            ?? throw new InvalidArgumentException('Unknown status value');

        $order = new Order($payload['id'], $status);

        $stmt = $pdo->prepare('INSERT INTO orders (id, status) VALUES (:id, :status)');
        $stmt->execute(['id' => $order->id, 'status' => $status->value]);

        return $order;
    }
}
Aspect Backed Enum Pure Enum
json_encode behavior Automatically the backing value Empty object, no meaningful output
Deserialization from() and tryFrom() from a scalar value Only possible through manual mapping
Doctrine mapping Directly through enumType on the Column attribute Not directly supported
PDO binding Backing value can be bound directly Requires a dedicated mapping method
Error case for an invalid value ValueError from from(), null from tryFrom() Does not exist, since there is no parsing from a scalar
Typical use API status fields, database columns Pure program logic without persistence

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

Enum Serialization

Core idea

Backed enums serialize automatically through their backing value, pure enums need manual mapping.

Doctrine

The enumType argument on the Column attribute maps backed enums directly, with no custom type.

PDO

Always bind the backing value and convert it back explicitly through from or tryFrom when reading.

Pitfall

Changed backing values require a data migration, otherwise from fails for old rows.

11. FAQ: Enum Serialization

1Do backed enums serialize to JSON automatically?
Yes, json_encode automatically converts a backed enum case into its backing value, with no extra code required.
2What happens when json_encode meets a pure enum?
A pure enum gets serialized as an empty object, since there is no scalar backing value for json_encode to use.
3When should you use from instead of tryFrom?
from fits trusted internal values, where an invalid value signals a real programming error, tryFrom fits external, untrusted input.
4How do you map backed enums in Doctrine?
Through the enumType argument on the Column attribute, directly supported since Doctrine ORM 2.11, with no need to register a custom type.
5Can Doctrine map pure enums too?
Not directly, since Doctrine needs a concrete scalar value for the column that a pure enum does not have.
6How do you bind an enum value in a PDO prepared statement?
You bind the enum's backing value, meaning enum->value, since PDO has no native concept of enum objects.
7What happens if a backing value changes in code?
Already stored records with the old value can no longer be converted back through from() without an accompanying data migration.
8How do you serialize an enum as part of a complex object?
Either rely on the automatic public property serialization, or implement JsonSerializable for full control over the output shape.
9How do you solve serialization for pure enums?
Through an explicit method like toValue() or toArray() that manually maps every case to a string or array through a match expression.
10Is tryFrom safer than from for API input?
Yes, tryFrom quietly returns null on an invalid value instead of throwing a ValueError, which allows for a controlled error response with the right status code.