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.
Table of Contents
- 1. Backed enums and json_encode: what works automatically
- 2. Combining JsonSerializable for complex output
- 3. Deserializing enum values from JSON
- 4. Doctrine ORM: configuring enum type mapping correctly
- 5. Plain PDO: binding enum values in prepared statements
- 6. Pure enums: no automatic serialization
- 7. Pitfall: changing an enum's backing value in the database
- 8. Validating enums when loading from untrusted sources
- 9. Practical example: a complete enum workflow from API to database
- 10. Summary
- 11. FAQ
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.