How to Build End-to-End Type-Safe Value Objects with Symfony and PostgreSQL JSONB

Modeling structured domain values in PHP, persisting them as PostgreSQL JSONB through custom Doctrine types, and validating the same contract in TypeScript with Zod.
Ever represented money with two unrelated fields such as $amount and $currency, only to discover that one part of the application updated the amount and forgot the currency?
Primitive values are convenient, but they lose meaning quickly. A string can represent an email address, a currency, a country code, or an arbitrary label. An integer can represent money, a quantity, a percentage, or an identifier.
The compiler only sees string and int. My business sees different concepts.
This is where value objects become useful. In my Symfony application, I use them for concepts such as money, date ranges, account lifecycle, legal metadata, and structured payment metadata.
Some of these values fit naturally in one PostgreSQL jsonb column. Instead of mapping every property with a Doctrine embeddable, I register a custom Doctrine type that converts one JSON document into one typed PHP object.
In this post, I’ll show you how to carry that type safety across the complete system:
Value Objects in a Nutshell
A value object represents a descriptive concept without an independent identity.
Two users with the same name are still different users because users have identities. Two money values containing 100 and USD are equal because their values are equal.
A useful value object is generally:
-
Immutable: changing it creates a new object.
-
Self-validating: invalid values cannot be constructed.
-
Compared by value: its properties define equality.
-
Behavior-oriented: operations use domain language instead of manipulating primitives elsewhere.
-
Small and cohesive: its values belong together and normally change together.
Money is a classic example. An amount without a currency is incomplete, so I model both as one value.
Why Not Keep It as an Array?
I could store and pass money like this:
But every consumer must now remember the shape and validate it again:
if (!isset($money['amount'], $money['currency'])) {
throw new InvalidArgumentException('Invalid money');
}Arrays also allow impossible states:
$money = ['amount' => -100];
$money = ['currency' => 'anything'];
$money['amount'] = 'free';A value object validates once at construction and exposes a reliable type everywhere else.
Step 1: Model Currency Explicitly
I start with the supported currencies:
namespace App\Billing\Domain\ValueObject;
enum Currency: string
{
case Cdf = 'CDF';
case Usd = 'USD';
public static function fromString(string $currency): self
{
return self::from(strtoupper(trim($currency)));
}
}The enum removes arbitrary strings from the domain. If the business supports two currencies, the type should not pretend that every three-letter string is accepted.
Adding a new currency becomes a visible domain change instead of an undocumented database value.
Step 2: Create the Money Value Object
Now I combine the amount and currency:
namespace App\Billing\Domain\ValueObject;
use JsonSerializable;
use Override;
final readonly class Money implements JsonSerializable
{
public function __construct(
public int $amount,
public Currency $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException(
'Money amount must be zero or greater.',
);
}
}
/** @param array{amount: int, currency: string} $data */
public static function fromArray(array $data): self
{
return new self(
amount: $data['amount'],
currency: Currency::fromString($data['currency']),
);
}
public static function fromJson(string $json): self
{
$data = json_decode(
$json,
true,
flags: JSON_THROW_ON_ERROR,
);
if (!is_array($data)) {
throw new InvalidArgumentException(
'Money must be represented by a JSON object.',
);
}
return self::fromArray($data);
}
/** @return array{amount: int, currency: string} */
public function toArray(): array
{
return [
'amount' => $this->amount,
'currency' => $this->currency->value,
];
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException(
'Money values must use the same currency.',
);
}
return new self(
$this->amount + $other->amount,
$this->currency,
);
}
public function scale(int $quantity): self
{
if ($quantity < 1) {
throw new InvalidArgumentException(
'Money quantity must be positive.',
);
}
return new self($this->amount * $quantity, $this->currency);
}
#[Override]
public function jsonSerialize(): array
{
return $this->toArray();
}
}The rest of the domain can now require Money instead of accepting a disconnected amount and currency:
final class Price
{
public function __construct(
public readonly PriceId $id,
public readonly ProductId $productId,
public readonly Money $amount,
) {}
}This gives me an important guarantee: a Price can never have an amount without a currency.
If your system supports decimal money, decide on the representation before continuing. A common solution is storing the smallest currency unit as an integer. Avoid binary floating-point values for financial calculations.
Why PostgreSQL JSONB?
The database representation of Money is one small document:
{
"amount": 1250,
"currency": "USD"
}PostgreSQL jsonb is useful here because:
-
The value is stored atomically as one cohesive concept.
-
PostgreSQL validates that the stored value is valid JSON.
-
I can still filter, sort, index, and aggregate individual keys.
-
The document can evolve with optional fields when necessary.
-
PHP and TypeScript can use the same external shape.
But jsonb alone is not type-safe. PostgreSQL will happily accept this unless I add constraints:
{
"amount": "a lot",
"currency": false
}End-to-end type safety requires validation at every boundary, including the database.
Step 3: Create a Custom Doctrine JSONB Type
The custom type is the translator between PostgreSQL and the domain.
With Doctrine DBAL 4, extend JsonbType so schema declarations use PostgreSQL JSONB:
namespace App\Billing\Infrastructure\Persistence\DBAL\Type;
use App\Billing\Domain\ValueObject\Money;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\SerializationFailed;
use Doctrine\DBAL\Types\JsonbType;
use Override;
use Throwable;
final class MoneyType extends JsonbType
{
public const string NAME = 'billing_money';
#[Override]
public function convertToPHPValue(
mixed $value,
AbstractPlatform $platform,
): ?Money {
if ($value instanceof Money) {
return $value;
}
$value = parent::convertToPHPValue($value, $platform);
if ($value === null) {
return null;
}
if (!is_array($value)) {
throw InvalidType::new(
$value,
self::NAME,
['null', 'JSON object', Money::class],
);
}
try {
return Money::fromArray($this->normalize($value));
} catch (Throwable $throwable) {
throw SerializationFailed::new(
$value,
self::NAME,
$throwable->getMessage(),
$throwable,
);
}
}
#[Override]
public function convertToDatabaseValue(
mixed $value,
AbstractPlatform $platform,
): ?string {
if ($value === null) {
return null;
}
if (!$value instanceof Money) {
throw InvalidType::new(
$value,
self::NAME,
['null', Money::class],
);
}
return parent::convertToDatabaseValue(
$value->toArray(),
$platform,
);
}
/**
* @param array<mixed, mixed> $value
* @return array{amount: int, currency: string}
*/
private function normalize(array $value): array
{
$amount = $value['amount'] ?? null;
$currency = $value['currency'] ?? null;
if (!is_int($amount)) {
throw InvalidType::new(
$amount,
self::NAME,
['integer amount'],
);
}
if (!is_string($currency)) {
throw InvalidType::new(
$currency,
self::NAME,
['string currency'],
);
}
return [
'amount' => $amount,
'currency' => $currency,
];
}
}The two conversion methods have opposite responsibilities:
The type lives in Infrastructure because Doctrine is an implementation detail. The Money class remains a plain domain object with no ORM dependency.
If you use an older Doctrine DBAL version without JsonbType, extend JsonType and override getSQLDeclaration() to return JSONB for PostgreSQL.
Step 4: Register the Custom Type
Symfony’s Doctrine configuration associates the logical name with the PHP class:
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
billing_money: App\Billing\Infrastructure\Persistence\DBAL\Type\MoneyTypeDoctrine can now use billing_money in entity metadata.
Step 5: Map the Value Object as One Field
I keep Doctrine metadata outside the domain and use XML mapping:
<?xml version="1.0" encoding="UTF-8"?>
<!-- config/doctrine/Billing/Entity.Price.orm.xml -->
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
>
<entity
name="App\Billing\Domain\Entity\Price"
table="prices"
>
<id name="id" type="price_id" column="id">
<generator strategy="NONE"/>
</id>
<field name="productId" type="product_id" column="product_id"/>
<field name="amount" type="billing_money" column="amount"/>
</entity>
</doctrine-mapping>From Doctrine’s perspective, amount is one field. From PHP’s perspective, it is always a Money object. From PostgreSQL’s perspective, it is one jsonb column.
No #[ORM\Embeddable], #[ORM\Embedded], or Doctrine attribute is required inside the domain.
If your project uses attributes, the equivalent mapping is:
#[ORM\Column(type: MoneyType::NAME)]
private Money $amount;The custom type still owns conversion; the mapping style does not change the design.
Step 6: Create the JSONB Column with Database Constraints
Generate a migration with Doctrine, then define the jsonb column and its invariants:
CREATE TABLE prices (
id UUID NOT NULL,
product_id UUID NOT NULL,
amount JSONB NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_price_amount_is_object
CHECK (jsonb_typeof(amount) = 'object'),
CONSTRAINT chk_price_amount_has_required_keys
CHECK (amount ?& ARRAY['amount', 'currency']),
CONSTRAINT chk_price_amount_is_non_negative_integer
CHECK (
(amount ->> 'amount') ~ '^[0-9]+$'
AND (amount ->> 'amount')::bigint >= 0
),
CONSTRAINT chk_price_currency_is_supported
CHECK (amount ->> 'currency' IN ('CDF', 'USD'))
);Now the same invariants exist at two important boundaries:
-
PHP prevents invalid domain objects.
-
PostgreSQL prevents invalid stored documents, including values written outside Doctrine.
This is defense in depth. Database constraints are especially important for JSONB because the column otherwise accepts many unrelated shapes.
Custom Type vs Doctrine Embeddable
Doctrine embeddables flatten a value object into columns on the owning table:
prices
├── amount_value INTEGER
└── amount_currency VARCHAR(3)My custom JSONB type stores it as one column:
prices
└── amount JSONB
├── amount: 1250
└── currency: USDNeither approach is universally better.
Use a custom JSONB type when:
-
The properties form one cohesive value and change together.
-
You want one reusable conversion boundary.
-
The structure contains optional or evolving metadata.
-
Most operations load or save the complete value.
-
Occasional key-level queries are enough.
Use an embeddable or ordinary columns when:
-
Individual properties are frequently filtered, joined, or sorted.
-
A property needs a foreign key.
-
The database must enforce rich relational constraints.
-
Independent column updates are common.
-
Portability beyond PostgreSQL matters more than JSONB features.
The custom type also gives me tighter domain separation. Doctrine sees one field and delegates conversion to Infrastructure; the domain value object does not need Doctrine mapping attributes.
The trade-off is that relational structure becomes JSON structure. Use that intentionally, not as a shortcut for avoiding schema design.
Immutability Is Important for Doctrine Change Tracking
Doctrine detects that a custom-type field changed when the property receives a different value.
Do not mutate a value object internally:
// Avoid this design.
$price->amount->amount = 1500;Use a readonly object and replace it:
$price->reviseAmount(
new Money(1500, $price->amount->currency),
);This matches value-object semantics and makes ORM change tracking predictable.
In my pricing model, revising an active price creates a replacement price rather than rewriting commercial history. The broader rule is the same: domain behavior creates a new Money value instead of modifying one in place.
Querying JSONB without Losing the Value Object
JSONB does not prevent efficient reads. PostgreSQL can address individual keys:
SELECT *
FROM prices
WHERE amount ->> 'currency' = 'USD'
ORDER BY (amount ->> 'amount')::integer DESC;For a CQRS read model, I can select the complete document and reconstruct the same value object:
$row = $connection->fetchAssociative(<<<'SQL'
SELECT
id::text AS id,
amount::text AS amount
FROM prices
WHERE id = :id
SQL,
['id' => (string) $priceId],
);
$price = new PriceView(
id: (string) $row['id'],
amount: Money::fromJson((string) $row['amount']),
);Or extract only the fields needed by an aggregate query:
SELECT
amount ->> 'currency' AS currency,
SUM((amount ->> 'amount')::integer) AS total
FROM payment_transactions
WHERE status = 'succeeded'
GROUP BY amount ->> 'currency';If these expressions become common, add expression indexes:
CREATE INDEX idx_prices_currency
ON prices ((amount ->> 'currency'));
CREATE INDEX idx_prices_amount_value
ON prices (((amount ->> 'amount')::integer));Use a GIN index when your main operations use JSONB containment such as @>. Use expression indexes when you repeatedly filter or sort by specific extracted values.
Always confirm the choice with EXPLAIN ANALYZE; an index is not automatically useful because a column contains JSON.
Step 7: Validate Incoming HTTP Data
The database and domain are typed, but untrusted HTTP input still arrives as JSON.
Define a transport model at the Presentation boundary:
namespace App\Billing\Presentation\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class MoneyRequest
{
public function __construct(
#[Assert\PositiveOrZero]
public int $amount,
#[Assert\Choice(['CDF', 'USD'])]
public string $currency,
) {}
public function toMoney(): Money
{
return new Money(
amount: $this->amount,
currency: Currency::fromString($this->currency),
);
}
}The containing request validates the nested value:
final readonly class CreatePriceRequest
{
public function __construct(
#[Assert\Uuid]
public string $productId,
#[Assert\Valid]
public MoneyRequest $amount,
) {}
}The controller maps JSON into the transport model, then creates the domain value:
#[Route('/admin/prices', methods: ['POST'])]
final class CreatePriceController
{
public function __invoke(
#[MapRequestPayload] CreatePriceRequest $request,
): JsonResponse {
$priceId = $this->commandBus->handle(new CreatePrice(
productId: new ProductId($request->productId),
amount: $request->amount->toMoney(),
));
return new JsonResponse(
['id' => (string) $priceId],
Response::HTTP_CREATED,
);
}
}Transport validation produces useful client errors. The value object remains the final authority for domain validity.
Step 8: Serialize the Same Stable Shape
Because Money implements JsonSerializable, its API representation is predictable:
{
"id": "019b5de3-...",
"amount": {
"amount": 1250,
"currency": "USD"
}
}The public JSON shape is a contract. Do not expose private object internals accidentally and hope the serializer continues producing the same output after a refactor.
toArray() and jsonSerialize() make the representation explicit.
Step 9: Validate and Infer the Type in TypeScript
A TypeScript type disappears at runtime. If an API returns an invalid shape, a type assertion will not protect the application:
const price = (await response.json()) as Price;Use a runtime schema and infer the static type from it:
import { z } from "zod";
export const supportedCurrencies = ["CDF", "USD"] as const;
export const currencySchema = z.enum(supportedCurrencies);
export const moneySchema = z.object({
amount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
currency: currencySchema,
});
export type Money = z.infer<typeof moneySchema>;Compose it inside larger API contracts:
export const priceSchema = z.object({
id: z.string().uuid(),
productId: z.string().uuid(),
amount: moneySchema,
});
export type Price = z.infer<typeof priceSchema>;Then parse the network response:
export async function getPrice(priceId: string): Promise<Price> {
const response = await fetch(`/api/admin/prices/${priceId}`);
if (!response.ok) {
throw new Error("Unable to load price");
}
return priceSchema.parse(await response.json());
}Now an API contract mismatch fails at the network boundary instead of producing a UI error several components later.
The same schema can validate outgoing data:
const payload = moneySchema.parse({
amount: form.amount,
currency: form.currency,
});
await fetch("/api/admin/prices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: payload }),
});This is type-safe at each boundary, but the PHP and Zod definitions are still two declarations that can drift. If you maintain a reliable OpenAPI schema, generating the client schema or types can reduce that duplication. Runtime parsing should remain at untrusted boundaries.
Step 10: Use the Value without Flattening It
The frontend can keep money cohesive too:
export function formatMoney(money: Money): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: money.currency,
maximumFractionDigits: 0,
}).format(money.amount);
}export function PriceAmount({ price }: { price: Price }) {
return <strong>{formatMoney(price.amount)}</strong>;
}I do not pass amount and currency separately through every component. The same conceptual boundary survives from the domain to the UI.
Evolving a JSONB Value Object Safely
JSONB makes adding fields easy. That does not make every shape change safe.
Imagine adding a precision field:
{
"amount": 1250,
"currency": "USD",
"precision": 2
}A backward-compatible reader can provide a default:
$precision = isset($data['precision'])
? (int) $data['precision']
: 0;For larger changes, version the stored document:
{
"version": 2,
"amount": 1250,
"currency": "USD"
}Then keep legacy normalization inside the custom type or a dedicated upcaster:
private function upcast(array $data): array
{
return match ($data['version'] ?? 1) {
1 => [
'version' => 2,
'amount' => $data['amount'],
'currency' => $data['currency'],
],
2 => $data,
default => throw new UnexpectedValueException(
'Unsupported money document version.',
),
};
}Use a database migration to backfill large datasets rather than relying forever on read-time conversion. The custom type is a good compatibility boundary, but it should not become a museum of every historical schema.
Most importantly, do not silently turn malformed required data into harmless defaults. A missing optional field can receive a default. A missing currency should fail loudly because the stored value is corrupted.
Testing the Complete Contract
End-to-end type safety needs more than one unit test.
Test the Value Object
public function testItRejectsNegativeAmounts(): void
{
$this->expectException(InvalidArgumentException::class);
new Money(-1, Currency::Usd);
}
public function testItAddsMoneyWithTheSameCurrency(): void
{
$total = new Money(100, Currency::Usd)
->add(new Money(50, Currency::Usd));
self::assertEquals(new Money(150, Currency::Usd), $total);
}Test the Doctrine Conversion
public function testMoneySurvivesDatabaseConversion(): void
{
$type = new MoneyType();
$platform = new PostgreSQLPlatform();
$money = new Money(1250, Currency::Usd);
$databaseValue = $type->convertToDatabaseValue($money, $platform);
$restored = $type->convertToPHPValue($databaseValue, $platform);
self::assertSame('{"amount":1250,"currency":"USD"}', $databaseValue);
self::assertEquals($money, $restored);
}Test ORM Persistence against PostgreSQL
Persist an entity containing Money, clear the entity manager, load it again, and assert that the property is still a Money instance. This verifies the mapping, registered type, real platform, and database column together.
Test the Database Constraints
Attempt to insert malformed JSON with DBAL and verify that PostgreSQL rejects it. This proves that scripts and integrations cannot bypass the invariant.
Test the Frontend Schema
it("rejects an unsupported currency", () => {
expect(() =>
moneySchema.parse({ amount: 1250, currency: "BTC" }),
).toThrow();
});Finally, keep one API contract test that calls the endpoint and parses the response with the same schema used by the client.
Common Mistakes
Treating JSONB as a Schemaless Escape Hatch
JSONB still has a schema; it is simply enforced by your code and constraints instead of a column per property. Document it, validate it, and migrate it deliberately.
Accepting Arrays in the Domain
Arrays belong at serialization boundaries. Convert them into a value object before business logic uses them.
Putting Doctrine inside the Value Object
The domain should not know how it is stored. Keep the custom type in Infrastructure and the value object in Domain.
Making the Value Object Mutable
Mutable objects weaken value semantics and can confuse Doctrine change tracking. Prefer readonly values and replacement.
Duplicating Validation Inconsistently
Symfony validation, the value-object constructor, PostgreSQL constraints, and Zod serve different boundaries, but they must describe the same accepted values.
Using JSONB for Relational Data
If a key needs foreign keys, frequent joins, independent updates, or complex uniqueness constraints, use relational columns. JSONB should model a cohesive value, not hide an entire data model.
Forgetting JavaScript’s Integer Limit
PHP and PostgreSQL integers can exceed JavaScript’s safe integer range. If that can happen, serialize the amount as a decimal string and validate it as a string in Zod instead of silently losing precision.
Trusting Schema Diff Tools Blindly
PostgreSQL reports the physical column as jsonb, not as your logical billing_money type. Depending on your Doctrine version, a schema diff may not recover the per-column custom type and may propose noisy changes. Generate migrations, inspect their SQL, and keep custom-type registration explicit. Do not globally map every jsonb column to billing_money when the database contains other JSON documents.
Wrapping Up
A value object gives primitives a business meaning. A custom Doctrine type lets that meaning survive persistence without coupling the domain to the ORM.
-
PHP constructs one immutable, validated
Moneyvalue. -
Doctrine converts that value through an Infrastructure adapter.
-
PostgreSQL stores it atomically as
jsonband enforces its shape with constraints. -
Read models can query individual JSONB keys when necessary.
-
Symfony validates untrusted input before constructing the domain value.
-
The API exposes one stable JSON representation.
-
Zod validates the response and infers the TypeScript type.
-
UI components continue passing money as one cohesive value.
Doctrine embeddables remain useful when relational columns are the better model. But for small, cohesive, structured values, a custom JSONB type provides a clean boundary with less persistence knowledge inside the domain.
The goal is not merely to store an object in JSON. It is to preserve one business concept, with the same meaning, from the database to the user interface.
Happy coding!