Перейти к основному содержимому

Встраиваемые Объекты

An embeddable (also called a value object in DDD terminology) is a PHP class whose properties are stored as columns in the parent entity's table — no join, no separate table. Weaver manages the column mapping and prefix logic via a dedicated AbstractEmbeddableMapper.

What problem embeddables solve

Consider a Customer with billing and shipping addresses. You could add all address columns directly to the CustomerMapper, but that scatters the address logic. Or you could normalise addresses into a separate table, but that adds a join for every customer query.

Embeddables give you a third option: group the address columns into a reusable value object (Address) that is transparently flattened into the parent table.

customers
─────────────────────────────────────────
id
name
billing_street ← from Address
billing_city ← from Address
billing_country ← from Address
billing_postal_code ← from Address
shipping_street ← from Address (reused with different prefix)
shipping_city
shipping_country
shipping_postal_code

Defining an embeddable class

The embeddable class is a plain PHP object with no ORM dependencies:

<?php
// src/ValueObject/Address.php

declare(strict_types=1);

namespace App\ValueObject;

final class Address
{
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $country,
public readonly ?string $postalCode = null,
) {}

public function withCity(string $city): self
{
return new self(
street: $this->street,
city: $city,
country: $this->country,
postalCode: $this->postalCode,
);
}
}

Defining an EmbeddableMapper

Create a class extending Weaver\ORM\Mapping\AbstractEmbeddableMapper:

<?php
// src/Mapper/AddressMapper.php

declare(strict_types=1);

namespace App\Mapper;

use App\ValueObject\Address;
use Weaver\ORM\Mapping\AbstractEmbeddableMapper;
use Weaver\ORM\Mapping\ColumnDefinition;

/** @extends AbstractEmbeddableMapper<Address> */
final class AddressMapper extends AbstractEmbeddableMapper
{
public function columns(): array
{
return [
ColumnDefinition::string('street', 200)->notNull(),
ColumnDefinition::string('city', 100)->notNull(),
ColumnDefinition::string('country', 3)->notNull(),
ColumnDefinition::string('postal_code', 20)->nullable(),
];
}

public function embeddableClass(): string
{
return Address::class;
}

public function hydrate(array $row): Address
{
return new Address(
street: $row['street'],
city: $row['city'],
country: $row['country'],
postalCode: $row['postal_code'] ?? null,
);
}

public function extract(Address $embeddable): array
{
return [
'street' => $embeddable->street,
'city' => $embeddable->city,
'country' => $embeddable->country,
'postal_code' => $embeddable->postalCode,
];
}
}

Embedding in an entity mapper

Use EmbedMap::embed() inside the parent mapper's columns() return value:

<?php
// src/Mapper/CustomerMapper.php

declare(strict_types=1);

namespace App\Mapper;

use App\Entity\Customer;
use Weaver\ORM\Mapping\AbstractMapper;
use Weaver\ORM\Mapping\ColumnDefinition;
use Weaver\ORM\Mapping\EmbedMap;
use Weaver\ORM\Mapping\SchemaDefinition;

final class CustomerMapper extends AbstractMapper
{
public function __construct(
private readonly AddressMapper $addressMapper,
) {}

public function table(): string
{
return 'customers';
}

public function primaryKey(): string
{
return 'id';
}

public function schema(): SchemaDefinition
{
return SchemaDefinition::define(
ColumnDefinition::integer('id')->autoIncrement()->unsigned(),
ColumnDefinition::string('name', 120)->notNull(),
// Embed Address columns twice with different prefixes
EmbedMap::embed($this->addressMapper, prefix: 'billing_'),
EmbedMap::embed($this->addressMapper, prefix: 'shipping_'),
);
}

public function hydrate(array $row): Customer
{
return new Customer(
id: (int) $row['id'],
name: $row['name'],
billingAddress: $this->addressMapper->hydrateWithPrefix($row, 'billing_'),
shippingAddress: $this->addressMapper->hydrateWithPrefix($row, 'shipping_'),
);
}

public function dehydrate(object $entity): array
{
/** @var Customer $entity */
$data = [
'id' => $entity->id,
'name' => $entity->name,
];

foreach ($this->addressMapper->extractWithPrefix($entity->billingAddress, 'billing_') as $col => $val) {
$data[$col] = $val;
}

foreach ($this->addressMapper->extractWithPrefix($entity->shippingAddress, 'shipping_') as $col => $val) {
$data[$col] = $val;
}

return $data;
}
}

Generated SQL

With billing_ and shipping_ prefixes:

CREATE TABLE customers (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
billing_street VARCHAR(200) NOT NULL,
billing_city VARCHAR(100) NOT NULL,
billing_country VARCHAR(3) NOT NULL,
billing_postal_code VARCHAR(20) NULL,
shipping_street VARCHAR(200) NOT NULL,
shipping_city VARCHAR(100) NOT NULL,
shipping_country VARCHAR(3) NOT NULL,
shipping_postal_code VARCHAR(20) NULL,
PRIMARY KEY (id)
);

Column prefix

The prefix parameter passed to EmbedMap::embed() is prepended to every column name defined in the EmbeddableMapper. The hydrate and extract methods of the embeddable mapper always use the unprefixed column names; Weaver handles prefix translation automatically via hydrateWithPrefix() and extractWithPrefix().

Nullable embeddable

When you want to represent the absence of an address as null in PHP, make all embedded columns nullable:

// In AddressMapper columns():
ColumnDefinition::string('street', 200)->nullable(),
ColumnDefinition::string('city', 100)->nullable(),
ColumnDefinition::string('country', 3)->nullable(),
ColumnDefinition::string('postal_code', 20)->nullable(),

In hydrate on the parent mapper:

billingAddress: $row['billing_street'] !== null
? $this->addressMapper->hydrateWithPrefix($row, 'billing_')
: null,

Nested embeddables

Embeddables can themselves embed other embeddables. Suppose Address contains a GeoCoordinate:

// src/ValueObject/GeoCoordinate.php
final class GeoCoordinate
{
public function __construct(
public readonly float $latitude,
public readonly float $longitude,
) {}
}
// src/Mapper/GeoCoordinateMapper.php
final class GeoCoordinateMapper extends AbstractEmbeddableMapper
{
public function columns(): array
{
return [
ColumnDefinition::decimal('latitude', 10, 7)->notNull(),
ColumnDefinition::decimal('longitude', 10, 7)->notNull(),
];
}
// ... embeddableClass, hydrate, extract
}
// In AddressMapper — embed GeoCoordinate with its own prefix
public function __construct(private readonly GeoCoordinateMapper $geoMapper) {}

public function columns(): array
{
return [
ColumnDefinition::string('street', 200)->notNull(),
ColumnDefinition::string('city', 100)->notNull(),
ColumnDefinition::string('country', 3)->notNull(),
EmbedMap::embed($this->geoMapper, prefix: 'geo_'),
];
}

When AddressMapper is embedded with prefix billing_, the nested coordinates become:

billing_street        VARCHAR(200) NOT NULL,
billing_city VARCHAR(100) NOT NULL,
billing_country VARCHAR(3) NOT NULL,
billing_geo_latitude DECIMAL(10, 7) NOT NULL,
billing_geo_longitude DECIMAL(10, 7) NOT NULL,

Prefixes are concatenated: the parent prefix (billing_) is prepended to the child prefix (geo_), giving billing_geo_.

When to use embeddables

Use embeddables when:

  • A concept (address, money, date range) recurs across multiple entities.
  • The data is always read and written together with the parent entity.
  • There is no need to query or reference the concept independently.

Avoid embeddables when:

  • You need to query by the embedded object across multiple entities (SELECT * FROM ... WHERE billing_city = 'London' is fine, but joining back to a second table would be cleaner).
  • The concept needs its own lifecycle (created, updated, deleted independently).
  • The number of columns would make the parent table excessively wide.