Zum Hauptinhalt springen

Entity-Mapping

Weaver ORM trennt Domain-Objekte von Persistenz-Metadaten, indem alle Mapping-Informationen in einer dedizierten Mapper-Klasse abgelegt werden. Diese Seite behandelt jeden Aspekt der Mapper-Konfiguration.

Warum Mapper statt Attribute?

Doctrine ORM platziert Mapping-Metadaten direkt auf der Entity-Klasse über PHP-8-Attribute:

// Doctrine-Ansatz — Entity kennt die Datenbank
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
}

Weaver hält sie strikt getrennt:

Entity-Klasse      →  einfaches PHP-Objekt, keinerlei ORM-Abhängigkeiten
Mapper-Klasse → das gesamte Persistenzwissen lebt hier

Vorteile:

  • Keine Laufzeit-Reflection. Der Mapper ist reines PHP, das Arrays und Skalare zurückgibt.
  • Keine Proxy-Klassen. Keine Codegenerierung auf der Festplatte erforderlich.
  • Worker-sicher. Mapper halten keinen anfragespezifischen Zustand.
  • Isoliert testbar. Einen Mapper in einem Unit-Test instanziieren und prüfen, ohne Symfony zu booten.
  • Vollständig durchsuchbar. Jeder Spaltenname, jeder Typ, jede Option erscheint im Klartext und taucht in git diff auf.

Mapper vs. Entity: Verantwortlichkeiten

AnliegenLebt in
Geschäftslogik, InvariantenEntity-Klasse
Eigenschaften und PHP-TypenEntity-Klasse
Tabellenname und SchemaMapper
Spaltennamen, Typen, OptionenMapper
Indizes und ConstraintsMapper
Hydration (Zeile → Entity)Mapper
Extraktion (Entity → Zeile)Mapper
BeziehungenMapper

Grundlegende Entity-Definition

Eine Entity ist eine beliebige PHP-Klasse. Sie erweitert nichts, implementiert nichts und importiert nichts aus 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,
);
}
}

Die Entity kann sein:

  • Unveränderlich (empfohlen) — mutierende Methoden geben neue Instanzen zurück
  • Veränderlich — öffentliche Eigenschaften oder Setter sind erlaubt
  • Abstrakt — für Vererbungshierarchien

AbstractMapper

Jede Entity benötigt genau einen Mapper. Erstellen Sie eine Klasse, die Weaver\ORM\Mapping\AbstractMapper erweitert, und implementieren Sie die erforderlichen Methoden.

<?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;
}
}

Erforderliche Mapper-Methoden

MethodeZweck
table(): stringTabellenname in der Datenbank
primaryKey(): string|arraySpaltenname(n) für den Primärschlüssel
schema(): SchemaDefinitionAlle Spaltendefinitionen für DDL und Migrationen
hydrate(array $row): objectEntity aus einer rohen Datenbankzeile erstellen
dehydrate(object $entity): arrayEntity in ein Spalte => Wert-Array serialisieren

Optionale Mapper-Methoden

MethodeZweck
readOnly(): booltrue zurückgeben für view-basierte Entities (kein INSERT/UPDATE/DELETE)
discriminatorColumn(): ?stringWird für Single Table Inheritance verwendet
discriminatorMap(): arrayWird für Single Table Inheritance verwendet
parentMapper(): ?stringWird für Class Table Inheritance verwendet

Spaltentypen

Alle Spaltendefinitionen verwenden statische Factory-Methoden auf ColumnDefinition. Jede Methode gibt eine ColumnDefinition-Instanz mit einer Fluent-Konfigurations-API zurück.

string

Wird auf VARCHAR(n) abgebildet. Standardlänge ist 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 und decimal

Verwenden Sie decimal für Finanzwerte; float für Koordinaten und Messungen.

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')

Hydratisieren Sie decimal als String, um die Genauigkeit zu erhalten:

price: $row['price'],  // als String behalten, an ein Money-Value-Object übergeben

boolean

Wird auf TINYINT(1) bei MySQL, BOOLEAN bei PostgreSQL/SQLite abgebildet.

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

Immer explizit in hydrate casten:

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 gibt ein veränderliches \DateTime zurück. Bevorzugen Sie datetimeImmutable für neuen 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,

Extraktion:

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

json

Wird auf JSON abgebildet (MySQL 5.7.8+, PostgreSQL, SQLite). Die Kodierung/Dekodierung kontrollieren Sie 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,

Extraktion:

'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 als CHAR(36))

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

Primärschlüsseltypen

Auto-Increment-Integer

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

Weaver lässt id bei INSERT weg, wenn der Wert null ist, und liest den generierten Wert automatisch zurück.

UUID v4 (zufällig)

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

Die UUID in der Entity-Factory-Methode vor dem Persistieren generieren:

use Symfony\Component\Uid\Uuid;

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

UUID v7 (zeitgeordnet, empfohlen)

UUID v7 enthält ein Millisekundengenauigkeits-Zeitstempel-Präfix, wodurch Schlüssel monoton steigend sind und B-Baum-Seitenteilungen im Vergleich zu zufälligen UUIDs drastisch reduziert werden.

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);
}

Natürlicher String-Schlüssel

Wenn der Geschäftsschlüssel von Natur aus eindeutig ist (Ländercode, Währungscode, Slug):

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

Zusammengesetzter Primärschlüssel

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

Spaltenoptionen

Alle Optionen sind als Fluent-Methoden auf ColumnDefinition verfügbar:

MethodeWirkung
->nullable()Spalte akzeptiert NULL-Werte
->default($value)Setzt eine DEFAULT-Klausel im DDL
->unsigned()Wendet UNSIGNED an (nur Integer-Typen)
->unique()Fügt einen UNIQUE-Constraint hinzu
->primaryKey()Markiert die Spalte als Teil des Primärschlüssels
->autoIncrement()Fügt AUTO_INCREMENT hinzu (nur Integer-PKs)
->generated()Spalte wird von der Datenbank berechnet; von INSERT/UPDATE ausgeschlossen
->comment(string)Fügt einen DDL-Kommentar auf Spaltenebene hinzu

PHP-8.1-Enum-Mapping

PHP-gestützte Enums (string- oder int-Backing-Typ) werden natürlich auf Datenbankspalten abgebildet.

String-gestützter 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']),

Extraktion:

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

Int-gestützter 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,
tipp

Speichern Sie immer ->value (z. B. 'pending'), niemals ->name (z. B. 'Pending'). Labels können frei in PHP umbenannt werden; Werte können es ohne Migration nicht.

Generierte / berechnete Spalten

Spalten, die von der Datenbank-Engine befüllt werden (z. B. GENERATED ALWAYS AS), müssen von INSERT- und UPDATE-Anweisungen ausgeschlossen werden.

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

Weaver entfernt generated-Spalten automatisch aus Schreib-Payloads. Sie erscheinen weiterhin in hydrate.

Spalten-Aliase

Verwenden Sie einen Alias, wenn der PHP-Eigenschaftsname vom Datenbankspaltennamen abweicht:

// PHP-Eigenschaft 'email' wird auf DB-Spalte 'usr_email' abgebildet
ColumnDefinition::string('email')->alias('usr_email')

In hydrate den Spaltennamen (den Alias) als Array-Schlüssel verwenden:

email: $row['usr_email'],

In dehydrate den Spaltennamen als Schlüssel zurückgeben:

'usr_email' => $entity->email,

Mapper in Symfony registrieren

Wenn autoconfigure: true in config/services.yaml gesetzt ist (Symfony-Standard), wird jede Klasse, die AbstractMapper in den konfigurierten mapper_paths erweitert, automatisch getaggt und registriert — keine manuelle Service-Definition erforderlich.

Für explizite Registrierung oder zum Überschreiben von Standardwerten:

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