Skip to main content

Entity Mapping

Weaver ORM separates domain objects from persistence metadata by putting all mapping information in a dedicated mapper class. This page covers every aspect of mapper configuration.

Why mappers instead of attributes?

Doctrine ORM puts mapping metadata directly on the entity class via PHP 8 attributes:

// Doctrine approach — entity knows about the database
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
}

Weaver keeps them strictly separated:

Entity class        →  plain PHP object, zero ORM dependencies
Mapper class → all persistence knowledge lives here

Benefits:

  • Zero runtime reflection. The mapper is plain PHP returning arrays and scalars.
  • No proxy classes. No on-disk code generation needed.
  • Worker-safe. Mappers hold no per-request state.
  • Testable in isolation. Instantiate and inspect a mapper in a unit test without booting Symfony.
  • Fully greppable. Every column name, every type, every option appears in plain text and shows up in git diff.

Mapper vs entity: responsibilities

ConcernLives in
Business logic, invariantsEntity class
Properties and PHP typesEntity class
Table name and schemaMapper
Column names, types, optionsMapper
Indexes and constraintsMapper
Hydration (row → entity)Mapper
Extraction (entity → row)Mapper
RelationsMapper

Basic entity definition

An entity is any PHP class. It does not extend anything, implement anything, or import anything from Weaver\ORM.

<?php
// src/Entity/User.php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;

final class User
{
public function __construct(
public readonly ?int $id,
public readonly string $name,
public readonly string $email,
public readonly bool $isActive,
public readonly DateTimeImmutable $createdAt,
) {}

public function withEmail(string $email): self
{
return new self(
id: $this->id,
name: $this->name,
email: $email,
isActive: $this->isActive,
createdAt: $this->createdAt,
);
}
}

The entity can be:

  • Immutable (recommended) — mutating methods return new instances
  • Mutable — public properties or setters are fine
  • Abstract — for inheritance hierarchies

AbstractMapper

Every entity needs exactly one mapper. Create a class extending Weaver\ORM\Mapping\AbstractMapper and implement the required methods.

<?php
// src/Mapper/UserMapper.php

declare(strict_types=1);

namespace App\Mapper;

use App\Entity\User;
use DateTimeImmutable;
use Weaver\ORM\Mapping\AbstractMapper;
use Weaver\ORM\Mapping\ColumnDefinition;
use Weaver\ORM\Mapping\SchemaDefinition;

final class UserMapper extends AbstractMapper
{
public function table(): string
{
return 'users';
}

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

public function schema(): SchemaDefinition
{
return SchemaDefinition::define(
ColumnDefinition::integer('id')->autoIncrement()->unsigned(),
ColumnDefinition::string('name', 120)->notNull(),
ColumnDefinition::string('email', 254)->unique()->notNull(),
ColumnDefinition::boolean('is_active')->notNull()->default(true),
ColumnDefinition::datetime('created_at')->notNull(),
);
}

public function hydrate(array $row): User
{
return new User(
id: (int) $row['id'],
name: $row['name'],
email: $row['email'],
isActive: (bool) $row['is_active'],
createdAt: new DateTimeImmutable($row['created_at']),
);
}

public function dehydrate(object $entity): array
{
/** @var User $entity */
$data = [
'name' => $entity->name,
'email' => $entity->email,
'is_active' => $entity->isActive,
'created_at' => $entity->createdAt->format('Y-m-d H:i:s'),
];

if ($entity->id !== null) {
$data['id'] = $entity->id;
}

return $data;
}
}

Required mapper methods

MethodPurpose
table(): stringTable name in the database
primaryKey(): string|arrayColumn name(s) for the primary key
schema(): SchemaDefinitionAll column definitions for DDL and migrations
hydrate(array $row): objectBuild an entity from a raw database row
dehydrate(object $entity): arraySerialize an entity to a column => value array

Optional mapper methods

MethodPurpose
readOnly(): boolReturn true for view-backed entities (no INSERT/UPDATE/DELETE)
discriminatorColumn(): ?stringUsed for Single Table Inheritance
discriminatorMap(): arrayUsed for Single Table Inheritance
parentMapper(): ?stringUsed for Class Table Inheritance

Column types

All column definitions use static factory methods on ColumnDefinition. Each method returns a ColumnDefinition instance with a fluent configuration API.

string

Maps to VARCHAR(n). Default length is 255.

ColumnDefinition::string('username')                    // VARCHAR(255) NOT NULL
ColumnDefinition::string('slug', 100) // VARCHAR(100) NOT NULL
ColumnDefinition::string('nickname')->nullable() // VARCHAR(255) NULL

integer, bigint, smallint

ColumnDefinition::integer('sort_order')                 // INT NOT NULL
ColumnDefinition::integer('quantity')->default(0) // INT NOT NULL DEFAULT 0
ColumnDefinition::integer('stock')->unsigned() // INT UNSIGNED NOT NULL
ColumnDefinition::bigint('view_count')->default(0) // BIGINT NOT NULL DEFAULT 0
ColumnDefinition::smallint('priority')->unsigned() // SMALLINT UNSIGNED NOT NULL

float and decimal

Use decimal for financial values; float for coordinates and measurements.

ColumnDefinition::float('latitude')
ColumnDefinition::float('longitude')
ColumnDefinition::decimal('price', 10, 2) // DECIMAL(10,2) NOT NULL
ColumnDefinition::decimal('tax_rate', 5, 4)->default('0.0000')

Hydrate decimal as a string to preserve precision:

price: $row['price'],  // keep as string, pass to a Money value object

boolean

Maps to TINYINT(1) on MySQL, BOOLEAN on PostgreSQL/SQLite.

ColumnDefinition::boolean('is_active')->default(true)
ColumnDefinition::boolean('email_verified')->default(false)

Always cast explicitly in hydrate:

isActive: (bool) $row['is_active'],

datetime, date, time

ColumnDefinition::datetime('published_at')->nullable()   // DATETIME NULL
ColumnDefinition::date('birth_date')->nullable() // DATE NULL
ColumnDefinition::time('opens_at') // TIME NOT NULL

datetime returns a mutable \DateTime. Prefer datetimeImmutable for new code:

ColumnDefinition::datetimeImmutable('created_at')        // DATETIME NOT NULL
ColumnDefinition::datetimeImmutable('updated_at')->nullable()

Hydration:

createdAt: new \DateTimeImmutable($row['created_at']),
updatedAt: isset($row['updated_at']) ? new \DateTimeImmutable($row['updated_at']) : null,

Extraction:

'created_at' => $entity->createdAt->format('Y-m-d H:i:s'),
'updated_at' => $entity->updatedAt?->format('Y-m-d H:i:s'),

json

Maps to JSON (MySQL 5.7.8+, PostgreSQL, SQLite). You control encoding/decoding in hydrate / dehydrate.

ColumnDefinition::json('metadata')->nullable()
ColumnDefinition::json('settings')

Hydration:

metadata: $row['metadata'] !== null
? json_decode($row['metadata'], true, 512, JSON_THROW_ON_ERROR)
: null,

Extraction:

'metadata' => $entity->metadata !== null
? json_encode($entity->metadata, JSON_THROW_ON_ERROR)
: null,

text, blob

ColumnDefinition::text('body')                           // TEXT NOT NULL
ColumnDefinition::text('description')->nullable() // TEXT NULL
ColumnDefinition::blob('thumbnail') // BLOB NOT NULL

guid (UUID as CHAR(36))

ColumnDefinition::guid('external_ref')->nullable()       // CHAR(36) NULL

Primary key types

Auto-increment integer

ColumnDefinition::integer('id')->autoIncrement()->unsigned()
id  INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)

Weaver omits id from INSERT when the value is null and reads back the generated value automatically.

UUID v4 (random)

ColumnDefinition::guid('id')->primaryKey()

Generate the UUID in the entity factory method before persisting:

use Symfony\Component\Uid\Uuid;

public static function create(string $name): self
{
return new self(id: (string) Uuid::v4(), name: $name);
}

UUID v7 includes a millisecond timestamp prefix, making keys monotonically increasing and dramatically reducing B-tree page splits compared to random UUIDs.

ColumnDefinition::guid('id')->primaryKey()
use Symfony\Component\Uid\Uuid;

public static function create(string $name): self
{
return new self(id: (string) Uuid::v7(), name: $name);
}

Natural string key

When the business key is naturally unique (country code, currency code, slug):

ColumnDefinition::string('code', 3)->primaryKey()

Composite primary key

ColumnDefinition::integer('user_id')->primaryKey(),
ColumnDefinition::integer('role_id')->primaryKey(),
ColumnDefinition::datetimeImmutable('assigned_at'),
PRIMARY KEY (user_id, role_id)

Column options

All options are available as fluent methods on ColumnDefinition:

MethodEffect
->nullable()Column accepts NULL values
->default($value)Sets a DEFAULT clause in DDL
->unsigned()Applies UNSIGNED (integer types only)
->unique()Adds a UNIQUE constraint
->primaryKey()Marks column as part of the primary key
->autoIncrement()Adds AUTO_INCREMENT (integer PKs only)
->generated()Column is DB-computed; excluded from INSERT/UPDATE
->comment(string)Adds a column-level DDL comment

PHP 8.1 enum mapping

PHP backed enums (string or int backing type) map naturally to database columns.

String-backed enum

enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}

Mapper:

ColumnDefinition::string('status', 20)
->comment('pending|confirmed|shipped|delivered|cancelled')

Hydration:

status: OrderStatus::from($row['status']),

Extraction:

'status' => $entity->status->value,

Int-backed enum

enum Priority: int
{
case Low = 1;
case Normal = 2;
case High = 3;
case Urgent = 4;
}

Mapper:

ColumnDefinition::smallint('priority')->unsigned()

Hydration:

priority: Priority::from((int) $row['priority']),

Nullable enum

ColumnDefinition::string('resolution', 20)->nullable()

Hydration:

resolution: $row['resolution'] !== null
? Resolution::from($row['resolution'])
: null,
tip

Always store ->value (e.g. 'pending'), never ->name (e.g. 'Pending'). Labels can be renamed freely in PHP; values cannot without a migration.

Generated / computed columns

Columns populated by the database engine (e.g. GENERATED ALWAYS AS) must be excluded from INSERT and UPDATE statements.

ColumnDefinition::string('full_name', 162)->generated(),
ColumnDefinition::decimal('total', 10, 2)->generated(),

Weaver strips generated columns from write payloads automatically. They still appear in hydrate.

Column aliases

Use an alias when the PHP property name differs from the database column name:

// PHP property 'email' maps to DB column 'usr_email'
ColumnDefinition::string('email')->alias('usr_email')

In hydrate, use the column name (the alias) as the array key:

email: $row['usr_email'],

In dehydrate, return the column name as the key:

'usr_email' => $entity->email,

Registering mappers in Symfony

If autoconfigure: true is set in config/services.yaml (the Symfony default), any class extending AbstractMapper in the configured mapper_paths is automatically tagged and registered — no manual service definition needed.

For explicit registration or to override defaults:

# config/services.yaml
services:
App\Mapper\UserMapper:
tags:
- { name: weaver.mapper }