Objetos Embebidos
Un embeddable (también llamado objeto de valor en terminología DDD) es una clase PHP cuyas propiedades se almacenan como columnas en la tabla de la entidad padre — sin join, sin tabla separada. Weaver gestiona el mapeo de columnas y la lógica de prefijos mediante un AbstractEmbeddableMapper dedicado.
Qué problema resuelven los embeddables
Considera un Customer con direcciones de facturación y envío. Podrías añadir todas las columnas de dirección directamente al CustomerMapper, pero eso dispersa la lógica de dirección. O podrías normalizar las direcciones en una tabla separada, pero eso añade un join para cada consulta de cliente.
Los embeddables te dan una tercera opción: agrupar las columnas de dirección en un objeto de valor reutilizable (Address) que se aplana de forma transparente en la tabla padre.
customers
─────────────────────────────────────────
id
name
billing_street ← de Address
billing_city ← de Address
billing_country ← de Address
billing_postal_code ← de Address
shipping_street ← de Address (reutilizado con diferente prefijo)
shipping_city
shipping_country
shipping_postal_code
Definir una clase embeddable
La clase embeddable es un objeto PHP simple sin dependencias del ORM:
<?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,
);
}
}
Definir un EmbeddableMapper
Crea una clase que extienda 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,
];
}
}
Embeber en un mapper de entidad
Usa EmbedMap::embed() dentro del valor de retorno de columns() del mapper padre:
<?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(),
// Embeber columnas de Address dos veces con diferentes prefijos
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;
}
}
SQL generado
Con prefijos billing_ y shipping_:
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)
);
Prefijo de columna
El parámetro prefix pasado a EmbedMap::embed() se antepone a cada nombre de columna definido en el EmbeddableMapper. Los métodos hydrate y extract del mapper embeddable siempre usan los nombres de columna sin prefijo; Weaver maneja la traducción de prefijos automáticamente mediante hydrateWithPrefix() y extractWithPrefix().
Embeddable nullable
Cuando quieres representar la ausencia de una dirección como null en PHP, haz que todas las columnas embebidas sean nullable:
// En las columns() de AddressMapper:
ColumnDefinition::string('street', 200)->nullable(),
ColumnDefinition::string('city', 100)->nullable(),
ColumnDefinition::string('country', 3)->nullable(),
ColumnDefinition::string('postal_code', 20)->nullable(),
En hydrate en el mapper padre:
billingAddress: $row['billing_street'] !== null
? $this->addressMapper->hydrateWithPrefix($row, 'billing_')
: null,
Embeddables anidados
Los embeddables pueden a su vez embeber otros embeddables. Supón que Address contiene un 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
}
// En AddressMapper — embeber GeoCoordinate con su propio prefijo
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_'),
];
}
Cuando AddressMapper se embebe con prefijo billing_, las coordenadas anidadas se convierten en:
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,
Los prefijos se concatenan: el prefijo padre (billing_) se antepone al prefijo hijo (geo_), dando billing_geo_.
Cuándo usar embeddables
Usa embeddables cuando:
- Un concepto (dirección, dinero, rango de fechas) se repite en múltiples entidades.
- Los datos siempre se leen y escriben juntos con la entidad padre.
- No hay necesidad de consultar o referenciar el concepto de forma independiente.
Evita embeddables cuando:
- Necesitas consultar por el objeto embebido a través de múltiples entidades (
SELECT * FROM ... WHERE billing_city = 'Madrid'está bien, pero hacer un join de vuelta a una segunda tabla sería más limpio). - El concepto necesita su propio ciclo de vida (creado, actualizado, eliminado de forma independiente).
- El número de columnas haría que la tabla padre sea excesivamente ancha.