---
title: Identity & Value Objects
type: concept
created: 2026-09-06
updated: 2026-09-06
sources: [codebase snapshot 2026-09-06]
tags: [identity, value-objects, uuid, ids]
---

# Identity & Value Objects

Every aggregate in Project-Board is identified by a **value object wrapping a string id**, generated as a random hex string (effectively a UUID without the dashes). Eloquent models use non-incrementing string primary keys to match.

> **Note on provenance:** reflects the code as of 2026-09-06; no written ADR exists.

## Value object base

`Domain\Shared\ValueObject` declares `equals(ValueObject $other): bool` and provides `isEqualTo()`. Each concrete id VO implements value-based equality and `__toString()`.

## The id pattern

All id VOs follow the same shape (`src/Domain/{Context}/ValueObjects/{Id}.php`):

```php
final class BoardId extends ValueObject
{
    public function __construct(private readonly string $value) {}

    public static function generate(): self
    {
        return new self(bin2hex(random_bytes(16)));
    }

    public static function fromString(string $value): self
    {
        if ($value === '') {
            throw new \InvalidArgumentException('A board id cannot be empty.');
        }
        return new self($value);
    }

    public function value(): string
    { return $this->value; }

    public function equals(ValueObject $other): bool
    {
        return $other instanceof self && $this->value === $other->value;
    }

    public function __toString(): string
    { return $this->value; }
}
```

Id VOs: `BoardId`, `TicketId`, `WorkspaceId`, `UserId`. The template's `OrderId` and `Money` VOs were removed with the Order slice.

## Entity / Aggregate base

- `Domain\Shared\Entity` holds a `mixed $id`, exposes `id()`, and implements `equals()` by class + id (delegating to the VO's `equals()` when the id is an object).
- `Domain\Shared\AggregateRoot` extends `Entity` and adds domain-event recording (see [domain-events](domain-events.md)).

## Persistence mapping

The string id is used directly as the Eloquent primary key:

```php
protected $primaryKey = 'board_id';
protected $keyType = 'string';
public $incrementing = false;
```

The migration columns are `uuid` type (e.g. `$table->uuid('board_id')->primary()`), and the mapper converts `(string) $aggregate->id()` to/from the model. See [repository-mapper-pattern](repository-mapper-pattern.md).

## Observations / open questions

- **Random hex, not UUID v4.** `bin2hex(random_bytes(16))` yields a 32-char hex string; the DB columns are `uuid`. These are compatible (a hex string fits a `uuid` column) but not standard-format UUIDs. Worth an ADR if strict UUID formatting matters.
- **Identity generated by the repository** (`nextIdentity()`), not by the aggregate — a deliberate choice to centralize creation.
- **Frontend mismatch:** the frontend `User` type uses `id: number` while the domain `User` uses a string `user_id` (see [user](../entities/user.md)).
- All `*Id` VOs are near-identical; a shared base or trait could DRY them, though the explicit-per-context copies mirror the bounded-context discipline.

## Related pages

- [repository-mapper-pattern](repository-mapper-pattern.md) — how ids are persisted
- [ddd-layering](ddd-layering.md) — where VOs belong
- [custom-generators](custom-generators.md) — scaffolding id VOs