Working with value objects for ids in Symfony
Package info
github.com/digital-craftsman-de/ids
Type:symfony-bundle
pkg:composer/digital-craftsman/ids
Requires
- php: 8.5.*|8.6.*
- digital-craftsman/self-aware-normalizers: ^1.4.0
- doctrine/dbal: ^4.5.0
- symfony/framework-bundle: ^7.4|^8.0
- symfony/polyfill-uuid: ^1.31
- symfony/serializer: ^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.4
- vimeo/psalm: ^6.20
Suggests
- ext-uuid: Improves performance of id creation and validation. symfony/polyfill-uuid is used as a fallback.
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.5.0
- 2.4.0
- 2.3.0
- v2.2.0
- v2.1.0
- v2.0.2
- v2.0.1
- v2.0.0
- v1.4.0
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.0
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.0
- v0.1.0-beta.7
- v0.1.0-beta.6
- v0.1.0-beta.5
- v0.1.0-beta.4
- v0.1.0-beta.3
- v0.1.0-beta.2
- v0.1.0-beta.1
- v0.1.0-alpha.8
- v0.1.0-alpha.7
- v0.1.0-alpha.6
- v0.1.0-alpha.5
- v0.1.0-alpha.4
- v0.1.0-alpha.3
- v0.1.0-alpha.2
- v0.1.0-alpha.1
- dev-php-8.6
- dev-tighter-typing
This package is auto-updated.
Last update: 2026-10-31 14:23:43 UTC
README
Stop passing ids around as plain strings. This bundle lets you create a dedicated, validated value object for every kind of id (UserId, OrderId, …) and lists of them (UserIdList, …), which work out of the box with the Symfony serializer and Doctrine.
As it's a central part of an application, it's tested thoroughly (including mutation testing).
Why?
When ids are plain strings, nothing stops you from mixing them up:
// Which string is which? Neither PHP nor your static analysis can tell. public function grantAccess(string $userId, string $projectId): void $service->grantAccess($projectId, $userId); // Compiles, runs, corrupts data.
With dedicated id classes, the same mistake is caught right away:
public function grantAccess(UserId $userId, ProjectId $projectId): void $service->grantAccess($projectId, $userId); // TypeError
On top of that, you get:
- Validation: An id can only be constructed from a valid UUID. Invalid input never makes it into your domain.
- Expressive code: Comparisons and guards like
$userId->mustNotBeEqualTo($targetUserId)instead of string checks spread through the code. - Id lists: Immutable, typed collections of ids with set operations (
containsId,diff,intersect, …) and guards. AUserIdListcan only containUserIds and never contains duplicates. - Serializer support: Ids are normalized to strings and id lists to arrays of strings – without writing a single normalizer.
- Doctrine support: Ids and id lists can be used directly as column types – without writing a single Doctrine type.
The serializer and Doctrine integration is provided by digital-craftsman/self-aware-normalizers, which is installed as a dependency.
Why not ramsey/uuid or symfony/uid?
Both are great libraries to work with UUIDs, but they aren't built to be the ids of your domain:
- Performance:
ramsey/uuidresolves factories, generators and codecs and builds a whole object graph for every single UUID. For plain v4 UUIDs, which is what most applications need, that's a lot of overhead. This package only creates and validates a string through theuuidextension (or the polyfill) and wraps it in one smallreadonlyobject. That difference adds up quickly when thousands of ids are created or denormalized in a single request. - Custom ids: Both libraries model a UUID, not the id of a user. To get a
UserId, you'd have to wrap or extend their classes, and their static factories, normalizers and Doctrine types still only know about the generic UUID classes. Here, a typed id is a one-liner and everything else works with it automatically. - Id lists: Neither library has a concept of lists of ids. You end up with
array<UuidInterface>, which allows duplicates, mixes different kinds of ids and has no set operations or guards.
You can still use symfony/uid or ramsey/uuid just to generate other UUID versions, for example v7. See Use other UUID versions.
Installation
Requires PHP 8.5 or 8.6, Symfony 7.4 or 8 and Doctrine DBAL 4.
composer require digital-craftsman/ids
It's recommended to install the uuid PHP extension for better performance of id creation and validation. symfony/polyfill-uuid is used as a fallback. When the extension is installed, you can prevent installing the polyfill.
Configuration
The serializer works without any configuration. For the Doctrine types, add the directories containing your ids and id lists to config/packages/self-aware-normalizers.php:
<?php declare(strict_types=1); use Symfony\Component\DependencyInjection\Loader\Configurator\App; return App::config([ 'self_aware_normalizers' => [ 'implementation_directories' => [ '%kernel.project_dir%/src/ValueObject', ], ], ]);
All ids and id lists in these directories are registered as Doctrine types automatically.
Ids
Defining an id
Extend from Id. That's all:
<?php declare(strict_types=1); namespace App\ValueObject; use DigitalCraftsman\Ids\ValueObject\Id; final readonly class UserId extends Id { }
Using an id
// Create $userId = UserId::generateRandom(); $userId = UserId::fromString('15d6208b-7cf2-49e5-a193-301d594d98a7'); // Throws InvalidId for invalid UUIDs // Read $userId->value; // '15d6208b-7cf2-49e5-a193-301d594d98a7' $userId->toString(); // Same, also available through (string) $userId // Compare if ($userId->isEqualTo($command->userId)) { // ... }
Guards
Guards throw an exception when the condition isn't met:
$requestingUser->userId->mustNotBeEqualTo($command->targetUserId);
Every guard accepts an optional callable to throw a custom exception instead:
$requestingUser->userId->mustNotBeEqualTo( $command->targetUserId, static fn () => new Exception\UserCanNotTargetItself(), );
Serializer
Ids are normalized to their string value, no matter how deeply they are nested:
final readonly class UserPayload { public function __construct( public UserId $userId, public string $firstName, public string $lastName, ) { } } $serializer->serialize($userPayload, JsonEncoder::FORMAT);
{
"userId": "15d6208b-7cf2-49e5-a193-301d594d98a7",
"firstName": "Tomas",
"lastName": "Bauer"
}
Denormalization works the same way in reverse. This also makes ids usable in commands and queries of the CQS routing bundle.
Doctrine
Use the class name as column type. Ids are stored as guid (uuid in PostgreSQL):
#[ORM\Entity] #[ORM\Table(name: '`user`')] class User { #[ORM\Id] #[ORM\Column(name: 'id', type: UserId::class)] public UserId $userId; }
Id lists
Defining an id list
Extend from IdList and define which id class it contains:
<?php declare(strict_types=1); namespace App\ValueObject; use DigitalCraftsman\Ids\ValueObject\IdList; /** @extends IdList<UserId> */ final readonly class UserIdList extends IdList { #[\Override] public static function handlesIdClass(): string { return UserId::class; } }
An id list only accepts ids of this class and never contains the same id twice.
Creating an id list
$userIds = new UserIdList([$userId1, $userId2]); $userIds = UserIdList::emptyList(); $userIds = UserIdList::fromIdStrings(['15d6208b-…', 'a4f3c1e2-…']); $userIds = UserIdList::fromMap($users, static fn (User $user) => $user->userId); $userIds = UserIdList::fromIdLists([$admins, $editors]); // Merged, without duplicates
Changing an id list
Id lists are immutable. Every change returns a new list:
$userIds = $userIds->addId($userId); // Throws when already in list $userIds = $userIds->addIdWhenNotInList($userId); $userIds = $userIds->removeId($userId); // Throws when not in list $userIds = $userIds->removeIdWhenInList($userId); $userIds = $userIds->addIds($otherUserIds); // Same variants exist for lists $userIds = $userIds->diff($otherUserIds); $userIds = $userIds->intersect($otherUserIds); $userIds = $userIds->filter(static fn (UserId $userId) => ...);
These methods are marked with #[\NoDiscard], so PHP warns you when you forget to use the returned list.
Reading an id list
$idsOfEnabledUsers->containsId($command->userId); $idsOfEnabledUsers->containsEveryId($userIds); $idsOfEnabledUsers->containsSomeIds($userIds); $idsOfEnabledUsers->containsNoneIds($userIds); $idsOfEnabledUsers->isEqualTo($userIds); // Same ids, independent of order $idsOfEnabledUsers->isEmpty(); count($idsOfEnabledUsers); $idsOfEnabledUsers->idsAsStringList(); // ['15d6208b-…', …] foreach ($idsOfEnabledUsers as $userId) { // ... } $names = $idsOfEnabledUsers->map(static fn (UserId $userId) => $userRepository->getName($userId));
Additionally, there are notContainsId, isNotEqualTo, isNotEmpty, mapWithIdKeys, every, some and reduce.
Guards
As with ids, every check is also available as guard, optionally with a custom exception:
$idsOfEnabledUsers->mustContainId($command->targetUserId); $idsOfEnabledUsers->mustContainId( $command->targetUserId, static fn () => new Exception\UserIsNotEnabled(), );
Available guards: mustContainId, mustNotContainId, mustContainEveryId, mustNotContainEveryId, mustContainSomeIds, mustContainNoneIds, mustBeEmpty, mustNotBeEmpty, mustBeEqualTo and mustNotBeEqualTo.
Serializer
Id lists are normalized to a list of id strings:
{
"idsOfUsersWithAccess": [
"15d6208b-7cf2-49e5-a193-301d594d98a7",
"a4f3c1e2-4b8d-4f6a-9c2e-7d1b3e5f8a90"
]
}
Doctrine
Use the class name as column type. Id lists are stored as jsonb in PostgreSQL and json on other platforms:
#[ORM\Entity] #[ORM\Table(name: 'project')] class Project { #[ORM\Column(name: 'ids_of_users_with_access', type: UserIdList::class)] public UserIdList $idsOfUsersWithAccess; }