Using Value Objects as Doctrine Entity IDs: A Deep Dive
Introduction
I discovered this architectural pattern while working at Yousign, the European leader in electronic signature solutions. With around 200 talented people including ~80 senior developers, it's an exceptional company where technical excellence meets a collaborative culture. The engineering team embraces modern software architecture patterns, making it an incredible place to grow as a developer. (And yes, they're hiring!)
When working with Domain-Driven Design (DDD) and Doctrine ORM, one of the most impactful architectural decisions is how to handle entity identifiers. While many developers default to primitive types like string or int, using Value Objects for IDs offers significant advantages in type safety, domain expressiveness, and code maintainability.
In this article, we'll explore why and how to use Value Objects as entity identifiers in Doctrine, examining real-world examples, implementation patterns, and the trade-offs involved. The examples use generic entities (posts, users, categories) for illustration, but the patterns apply universally.
What Are Value Objects?
Value Objects are immutable objects that represent a descriptive aspect of the domain with no conceptual identity. Unlike entities, two Value Objects are considered equal if all their attributes are equal. In the context of entity IDs, a Value Object encapsulates the identifier's value and provides type safety and domain meaning.
Why Use Value Objects for Entity IDs?
1. Type Safety and Prevention of Primitive Obsession
Consider this common scenario where primitive types are used:
class PostRepository
{
public function findById(string $id): Post
{
// What if someone passes a UserId instead of a PostId?
// The type system won't catch this error!
}
}With Value Objects, the type system prevents these mistakes:
class PostRepository
{
public function findById(PostId $id): Post
{
// Compiler error if wrong ID type is passed
}
}2. Domain Expressiveness
Value Objects make your code self-documenting and express domain concepts clearly:
interface CommentRepository
{
public function findAllByPostId(
PostId $postId,
?UserId $authorId = null,
): array;
}The method signature immediately communicates that it requires specific domain identifiers, not just any strings.
3. Preventing ID Confusion
In complex domains, you often have multiple entity types. Value Objects prevent accidentally mixing IDs:
// Without Value Objects - dangerous!
function assignPostToCategory(string $postId, string $categoryId) {
// Is $postId really a PostId or could it be a CategoryId?
// Easy to swap these accidentally
}
// With Value Objects - safe!
function assignPostToCategory(PostId $postId, CategoryId $categoryId) {
// Crystal clear what these parameters represent
}4. Encapsulation of ID Generation Logic
Value Objects can encapsulate creation logic and validation:
final class PostId implements ContentId, PublishableId
{
use UuidTrait;
public static function fromContentId(ContentId $contentId): self
{
return self::fromString($contentId->toString());
}
public static function getContentType(): ContentType
{
return ContentType::Post;
}
public function getPublishableType(): PublishableType
{
return PublishableType::Post;
}
}5. Interface Implementation for Polymorphism
Value Objects can implement interfaces, enabling polymorphic behavior:
final class UserId implements
AuthorId,
TrackableId,
NotifiableId,
AuditableId
{
use UuidTrait;
public function getId(): string
{
return $this->toString();
}
public function getTrackableType(): string
{
return 'user';
}
}This allows different ID types to be used interchangeably where appropriate, while maintaining type safety.
Implementation Patterns
The UuidTrait Pattern
A common pattern is to use a trait for shared UUID functionality:
trait UuidTrait
{
private string $uuid;
private function __construct(string $uuid)
{
// Validation logic here
if (!Uuid::isValid($uuid)) {
throw new InvalidArgumentException("Invalid UUID format");
}
$this->uuid = $uuid;
}
public static function generate(): static
{
return new static(Uuid::uuid7()->toString());
}
public static function fromString(string $uuid): static
{
return new static($uuid);
}
public function toString(): string
{
return $this->uuid;
}
public function equals(self $other): bool
{
return $this->uuid === $other->uuid;
}
}Simple Value Object Implementation
For straightforward cases:
final class CategoryId implements Id
{
use UuidTrait;
}This creates a type-safe, immutable ID with minimal boilerplate.
Extended Value Object with Domain Logic
For more complex scenarios:
final class PostId implements ContentId, PublishableId
{
use UuidTrait;
public static function fromContentId(ContentId $contentId): self
{
return self::fromString($contentId->toString());
}
public static function getContentType(): ContentType
{
return ContentType::Post;
}
public function getPublishableType(): PublishableType
{
return PublishableType::Post;
}
}Doctrine Integration
Custom Doctrine Type
To use Value Objects with Doctrine, you need to create a custom type:
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
class PostIdType extends Type
{
public const NAME = 'post_id';
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getGuidTypeDeclarationSQL($column);
}
public function convertToPHPValue($value, AbstractPlatform $platform): ?PostId
{
if ($value === null || $value instanceof PostId) {
return $value;
}
return PostId::fromString($value);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof PostId) {
return $value->toString();
}
throw new \InvalidArgumentException('Invalid type');
}
public function getName(): string
{
return self::NAME;
}
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
{
return true;
}
}Entity Mapping
In your entity:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Post
{
#[ORM\Id]
#[ORM\Column(type: 'post_id')]
private PostId $id;
public function __construct(PostId $id)
{
$this->id = $id;
}
public function getId(): PostId
{
return $this->id;
}
}Type Registration
Register the type in your Doctrine configuration:
// In your bootstrap or configuration file
use Doctrine\DBAL\Types\Type;
Type::addType('post_id', PostIdType::class);
// For DBAL platform
$platform = $connection->getDatabasePlatform();
$platform->registerDoctrineTypeMapping('post_id', 'post_id');Repository Pattern Benefits
Using Value Objects in repositories creates clear, type-safe APIs:
interface CommentRepository
{
public function getById(CommentId $commentId): Comment;
public function findByPostId(
PostId $postId,
?UserId $authorId = null
): array;
public function removeByIds(
PostId $postId,
CommentId ...$commentIds
): int;
}Benefits include:
- Compile-time verification: Wrong ID types are caught immediately
- Auto-completion: IDEs can suggest only relevant ID types
- Refactoring safety: Renaming or changing ID types updates all usages
- Clear intent: No ambiguity about what type of ID is expected
Advanced Patterns
ID Conversion Between Types
Sometimes you need to convert between related ID types:
final class UserId implements AuthorId, MemberId
{
public static function fromAuthorId(AuthorId $authorId): UserId
{
return UserId::fromString($authorId->toString());
}
public static function fromMemberId(MemberId $memberId): UserId
{
return UserId::fromString($memberId->toString());
}
}This pattern maintains type safety while allowing controlled conversions.
Factory Pattern Integration
Factories work beautifully with Value Object IDs:
class CommentFactory
{
public function create(
PostId $postId,
UserId $authorId,
string $content
): Comment {
$commentId = CommentId::generate();
return new Comment(
id: $commentId,
postId: $postId,
authorId: $authorId,
content: $content
);
}
}Variadic Parameters
Value Objects work excellently with variadic parameters:
public function removeByIds(
PostId $postId,
CommentId ...$commentIds
): int {
// Type-safe variadic IDs
}Advantages
1. Type Safety
The compiler prevents ID type confusion, catching errors at development time rather than runtime. This is perhaps the most significant benefit of using Value Objects.
With primitive types, the following code compiles without warnings:
public function assignPostToCategory(string $postId, string $categoryId): void
{
// Oops! Parameters swapped, but compiler doesn't care
$this->repository->assign($categoryId, $postId);
}With Value Objects, this becomes impossible:
public function assignPostToCategory(
PostId $postId,
CategoryId $categoryId
): void {
// Compiler error if you try to swap these!
$this->repository->assign($postId, $categoryId);
}This type safety extends to array operations as well:
// With primitives - no type safety
$postIds = ['abc-123', 'def-456', 'ghi-789'];
$postIds[] = 'wrong-type-id'; // No error, but could be a UserId!
// With Value Objects - type safe
/** @var PostId[] $postIds */
$postIds = [PostId::fromString('abc-123'), PostId::fromString('def-456')];
$postIds[] = UserId::fromString('wrong'); // PHPStan/Psalm will catch this2. Domain Clarity
Code reads like the domain language, making it easier for developers to understand business logic. When you see PostId, you immediately know what domain concept you're dealing with.
Compare these two method signatures:
// Primitive approach - unclear
public function publishContent(
string $id1,
string $id2,
string $id3,
?string $id4 = null
): bool;
// Value Object approach - self-documenting
public function publishContent(
PostId $postId,
UserId $authorId,
CategoryId $categoryId,
?TagId $tagId = null
): bool;The second version is immediately understandable without reading documentation or implementation details. This clarity extends to debugging:
// In a debugger or var_dump
var_dump($postId);
// object(PostId) { private string $uuid = "abc-123" }
// vs
var_dump($id);
// string(7) "abc-123" - what kind of ID is this?3. Refactoring Confidence
Changing ID structures or adding validation is centralized in one place, making refactoring safer and faster.
Imagine you need to add validation to ensure IDs follow a specific format. With primitives:
// Need to add validation in 50+ places
public function findById(string $id): Post
{
if (!preg_match('/^[a-f0-9-]{36}$/i', $id)) {
throw new InvalidArgumentException('Invalid ID format');
}
// ...
}With Value Objects, you change it once:
final class PostId
{
private function __construct(string $uuid)
{
if (!Uuid::isValid($uuid)) {
throw new InvalidArgumentException('Invalid UUID format');
}
$this->uuid = $uuid;
}
}Now all 50+ usages automatically benefit from the validation. Need to change from UUID v4 to UUID v7? Change it in the Value Object, and the type system ensures you haven't missed any usage.
4. Interface Segregation
IDs can implement multiple interfaces based on their roles in the domain, enabling polymorphism while maintaining type safety.
This is powerful for cross-cutting concerns:
interface TrackableId
{
public function getId(): string;
public function getTrackableType(): string;
}
interface NotifiableId
{
public function getId(): string;
}
final class UserId implements TrackableId, NotifiableId
{
// Can be used anywhere these interfaces are required
public function getTrackableType(): string
{
return 'user';
}
}
// Later in tracking code:
function trackEvent(TrackableId $entityId, string $event): void
{
// Works with UserId, but also with other ID types that implement TrackableId
}This allows you to write generic code that works with multiple ID types without sacrificing type safety.
5. Testing
Creating test fixtures is straightforward with factory methods, and tests become more readable and maintainable.
// Without Value Objects - magic strings everywhere
public function testUserCanComment(): void
{
$comment = new Comment(
'550e8400-e29b-41d4-a716-446655440000',
'660e8400-e29b-41d4-a716-446655440001',
'770e8400-e29b-41d4-a716-446655440002'
);
// What are these IDs? Need to check constructor signature
}
// With Value Objects - clear and explicit
public function testUserCanComment(): void
{
$comment = new Comment(
CommentId::generate(),
PostId::generate(),
UserId::generate()
);
// Crystal clear what each parameter represents
}You can also create named constructors for common test scenarios:
final class UserId
{
public static function forTesting(string $suffix = '001'): self
{
return self::fromString('test-user-' . $suffix);
}
}
// In tests
$user1 = UserId::forTesting('001');
$user2 = UserId::forTesting('002');7. IDE Support and Autocomplete
Modern IDEs provide exceptional support for Value Objects. When you type $postId->, your IDE suggests only methods relevant to PostId. With primitive strings, you get generic string methods that aren't relevant to your domain.
The IDE also helps with refactoring:
- Rename a Value Object class → all usages are updated
- Find usages → see exactly where each ID type is used
- Type hints → instant feedback if you use the wrong ID type
8. Validation at Construction
Value Objects allow you to enforce invariants at construction time, ensuring invalid IDs never exist in your system:
final class PostId
{
private function __construct(string $uuid)
{
if (!Uuid::isValid($uuid)) {
throw new InvalidArgumentException(
"Invalid UUID format: {$uuid}"
);
}
if (Uuid::fromString($uuid)->getVersion() !== 7) {
throw new InvalidArgumentException(
"Only UUID v7 is supported for PostId"
);
}
$this->uuid = $uuid;
}
}final class EmailAddress
{
private function __construct(string $email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address: {$email}");
}
$this->email = $email;
}
}Once a PostId or EmailAddress exists, you can be confident it's valid. This reduces the need for repetitive validation checks throughout your codebase.
Disadvantages
1. Initial Boilerplate
You need to create custom Doctrine types for each ID type. This is the most significant upfront cost.
For each ID Value Object, you need:
// 1. The Value Object itself (~30-50 lines)
final class PostId implements Id
{
use UuidTrait;
// + any domain-specific methods
}
// 2. The Doctrine custom type (~80-100 lines)
class PostIdType extends Type
{
public const NAME = 'post_id';
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { }
public function convertToPHPValue($value, AbstractPlatform $platform): ?PostId { }
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string { }
public function getName(): string { }
public function requiresSQLCommentHint(AbstractPlatform $platform): bool { }
}
// 3. Registration in bootstrap
Type::addType('post_id', PostIdType::class);For a medium-sized application with 15-20 entity types, this means creating 30-40 new classes. While the Doctrine type classes are mostly boilerplate (and can be generated), it's still a significant initial investment.
Mitigation strategies:
- Create a base abstract
UuidDoctrineTypeclass to reduce duplication - Use code generation tools or scripts to generate Doctrine types
- Start with core domain entities and gradually expand
2. Learning Curve
Team members need to understand Value Objects, custom Doctrine types, and DDD concepts. This can be challenging for teams new to these patterns.
Common confusion points:
// Junior developers might try this:
$id = new PostId($uuid); // Error! Constructor is private
// Instead of this:
$id = PostId::fromString($uuid);
// Or might not understand why this is necessary:
$id->toString() // "Why can't I just use $id as a string?"Onboarding new team members requires explaining:
- Why we don't use primitive types
- How Doctrine custom types work
- When to use
fromString()vsgenerate() - How to work with IDs in tests
This adds to the onboarding time and requires good documentation and code examples.
Time investment:
- Initial learning: 1-2 days for developers new to the pattern
- Team alignment: Several code review cycles to establish conventions
- Documentation: Comprehensive examples and guides needed
3. Performance Overhead
There is a measurable (though usually negligible) performance overhead from object creation vs primitive types.
// Primitive: ~0.0001ms
$id = 'abc-123';
// Value Object: ~0.0005ms (5x slower, but still incredibly fast)
$id = PostId::fromString('abc-123');In a typical request processing 100 entities, this adds ~0.05ms total. For most applications, this is completely negligible.
When it might matter:
- High-throughput batch processing (millions of entities)
- Real-time systems with microsecond requirements
- Memory-constrained environments (embedded systems)
For a typical web application, you'll never notice the difference. Database queries and network calls dwarf this overhead.
Benchmark example:
// Processing 10,000 posts
// With primitives: ~250ms
// With Value Objects: ~252ms (+0.8% overhead)
// Database queries in same request: ~1,500ms (85% of total time)4. Serialization Complexity
APIs, JSON serialization, and data export require explicit conversion to primitive types. This adds verbosity throughout your application layer.
// API Response - manual conversion needed
return [
'id' => $post->getId()->toString(),
'authorId' => $author->getId()->toString(),
'categoryId' => $category->getId()->toString(),
// Every ID needs explicit toString()
];
// With Symfony Serializer, you need custom normalizers:
class PostIdNormalizer implements NormalizerInterface
{
public function normalize($object, string $format = null, array $context = [])
{
return $object->toString();
}
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof PostId;
}
}This also affects:
- Logging:
$logger->info('Processing request', ['id' => $id->toString()]) - Cache keys:
$cache->get('request:' . $id->toString()) - URL generation:
$router->generate('view', ['id' => $id->toString()]) - Database queries:
$qb->where('id = :id')->setParameter('id', $id->toString())
While not difficult, it's an extra step you need to remember everywhere you cross layer boundaries.
5. Database Migrations
Changing from primitive IDs to Value Objects (or between ID formats) requires careful migration planning, as the database schema might need updates.
Migration scenarios:
// Scenario 1: Already using UUIDs as strings - easy
// No database migration needed, just change PHP code
CREATE TABLE posts (
id VARCHAR(36) PRIMARY KEY -- Already compatible
);
// Scenario 2: Using integers - complex
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY -- Need to migrate to UUIDs
);For integer-to-UUID migrations, you need:
- Add new UUID column
- Generate UUIDs for existing records
- Update all foreign keys
- Switch primary key
- Remove old integer column
This can take days or weeks for large databases and requires careful coordination with zero-downtime deployment strategies.
6. Third-Party Integration Challenges
Some libraries and tools expect primitive types and don't play well with Value Objects:
// Some ORMs or query builders might struggle:
$qb->where(['id' => $postId]); // May not work
$qb->where(['id' => $postId->toString()]); // Need to convert
// API Platform or similar tools need configuration:
#[ApiResource]
class Post
{
#[ApiProperty(identifier: true)]
private PostId $id; // Needs custom identifier handling
}7. Debugging Verbosity
Stack traces and error messages become more verbose:
// With primitives:
TypeError: Argument 1 must be of type string, int given
// With Value Objects:
TypeError: Argument 1 passed to PostRepository::findById()
must be an instance of Domain\ValueObject\PostId,
instance of Domain\ValueObject\UserId given,
called in /path/to/PostController.php on line 42While more informative, it's also more to read through when debugging.
8. Team Resistance
Perhaps the most underestimated challenge: convincing your team that the complexity is worth it.
Common objections:
- "We've never had a bug from mixing up IDs" (until you do)
- "This is overengineering for our simple CRUD app" (is it though?)
- "The boilerplate isn't worth the benefits" (subjective)
- "This will slow down development" (short-term yes, long-term no)
Overcoming this requires:
- Clear documentation of actual bugs prevented
- Demonstrable examples of improved refactoring
- Buy-in from tech leads and architects
- Gradual introduction rather than big-bang refactoring
Summary: Is It Worth It?
The trade-off is clear: higher upfront cost for long-term benefits.
Use Value Object IDs when:
- Your domain has 5+ entity types with relationships
- You're building a long-term, evolving system
- Type safety is critical (financial, legal, healthcare domains)
- Your team embraces DDD principles
- Refactoring safety is important
Stick with primitives when:
- Simple CRUD applications with few entity types
- Prototypes or short-lived projects
- Team is small and unfamiliar with DDD
- Performance is absolutely critical (rare)
- You're working with legacy code that can't change
Best Practices
1. Keep Value Objects Simple
Don't add business logic to IDs; keep them focused on identity and validation.
// Good
final class UserId implements Id
{
use UuidTrait;
}
// Avoid
final class UserId implements Id
{
use UuidTrait;
public function calculateUserRank(): int { /* ... */ }
}2. Use Traits for Common Functionality
The UuidTrait pattern reduces duplication across ID types.
3. Implement Interfaces Thoughtfully
Only implement interfaces that make semantic sense:
// UserId can be an AuthorId in the content context
final class UserId implements AuthorId, MemberId
{
// ...
}4. Provide Factory Methods
Offer multiple construction methods for different contexts:
public static function generate(): static
public static function fromString(string $uuid): static
public static function fromAuthorId(AuthorId $authorId): static5. Make Equals() Explicit
Implement an equals() method for comparisons:
public function equals(self $other): bool
{
return $this->uuid === $other->uuid;
}6. Document Doctrine Type Mapping
Maintain clear documentation of which Doctrine type maps to which Value Object.
Migration Strategy
If you're migrating from primitive IDs to Value Objects:
Step 1: Create the Value Object
final class PostId implements Id
{
use UuidTrait;
}Step 2: Create the Doctrine Type
class PostIdType extends Type { /* ... */ }Step 3: Register the Type
Type::addType('post_id', PostIdType::class);Step 4: Update Entity Mapping
#[ORM\Column(type: 'post_id')]
private PostId $id;Step 5: Update Repository Methods Gradually
Start with new methods, then refactor existing ones:
// New method with Value Object
public function getById(PostId $id): Post
// Old method (deprecated)
/** @deprecated Use getById() instead */
public function findById(string $id): Post
{
return $this->getById(PostId::fromString($id));
}Step 6: Update Application Layer
Gradually update controllers, services, and other layers to use Value Objects.
Conclusion
Using Value Objects for entity IDs in Doctrine is a powerful pattern that significantly improves code quality, type safety, and domain expressiveness. While it requires initial setup and discipline, the benefits far outweigh the costs in medium to large applications.
The pattern prevents entire classes of bugs through compile-time type checking, makes code more maintainable through clear domain language, and provides a solid foundation for complex domain modeling.
For projects embracing Domain-Driven Design, Value Object IDs are not just a nice-to-have—they're an essential tool for building robust, expressive, and maintainable domain models.
Further Reading
- Domain-Driven Design by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Doctrine Custom Mapping Types
- Value Objects in PHP