Working with value objects for ids in Symfony

Installs: 26 452

Dependents: 4

Suggesters: 0

Security: 0

Stars: 5

Watchers: 0

Forks: 6

Open Issues: 0

Type:symfony-bundle

v2.0.0 2024-12-05 12:41 UTC

README

A Symfony bundle to work with id and id list value objects in Symfony. It includes Symfony normalizers for automatic normalization and denormalization and Doctrine types to store the ids and id lists directly in the database.

As it's a central part of an application, it's tested thoroughly (including mutation testing).

Latest Stable Version PHP Version Require codecov Packagist Downloads Packagist License

Installation and configuration

Install package through composer:

composer require digital-craftsman/ids

It's recommended that you install the uuid PHP extension for better performance of id creation and validation. symfony/polyfill-uuid is used as a fallback. You can prevent installing the polyfill when you've installed the PHP extension.

Working with ids

Creating a new id

The bulk of the logic is in the Id class. Creating a new id is as simple as creating a new final readonly class and extending from it like the following:

<?php

declare(strict_types=1);

namespace App\ValueObject;

use DigitalCraftsman\Ids\ValueObject\Id;

final readonly class UserId extends Id
{
}

Now you're already able to use it in your code like this:

$userId = UserId::generateRandom();
if ($userId->isEqualTo($command->userId)) {
    ...
}

Guard against invalid usages:

$requestingUser->userId->mustNotBeEqualTo($command->targetUserId);

Or with a custom exception:

$requestingUser->userId->mustNotBeEqualTo(
    $command->targetUserId,
    static fn () => new Exception\UserCanNotTargetItself(),
);

Symfony serializer

If you're injecting the SerializerInterface directly, there is nothing to do. The normalizer from digital-craftsman/self-aware-normalizers are registered automatically and will handle the serialization and deserialization of the Id class, as it implements the StringNormalizable interface.

namespace App\DTO;

final readonly class UserPayload
{
    public function __construct(
        public UserId $userId,
        public string $firstName,
        public string $lastName,
    ) {
    }
}
public function __construct(
    private SerializerInterface $serializer,
) {
}

public function handle(UserPayload $userPayload): string
{
    return $this->serializer->serialize($userPayload, JsonEncoder::FORMAT);
}
{
  "userId": "15d6208b-7cf2-49e5-a193-301d594d98a7",
  "firstName": "Tomas",
  "lastName": "Bauer"
}

This can be combined with the CQS bundle to have serialized ids there.

Doctrine types

To use an id in your entities, you just need to register a new type for the id. Create a new class for the new id like the following:

<?php

declare(strict_types=1);

namespace App\Doctrine;

use App\ValueObject\UserId;
use DigitalCraftsman\Ids\Doctrine\IdType;

final class UserIdType extends IdType
{
    public static function getTypeName(): string
    {
        return 'user_id';
    }

    public static function getClass(): string
    {
        return UserId::class;
    }
}

Then register the new type in your config/packages/doctrine.yaml file:

doctrine:
  dbal:
    types:
      user_id: App\Doctrine\UserIdType

Alternatively you can also add a compiler pass to register the types automatically.

Then you're already able to add it into your entity like this:

<?php

declare(strict_types=1);

namespace App\Entity;

use App\ValueObject\UserId;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\Column(name: 'id', type: 'user_id')]
    public UserId $id;
    
    ...
}

Working with id lists

Id lists are wrapper for an array of ids. They contain a few utility functions and improved type safety.

The IdList is immutable. Therefore, the mutation methods (like add, remove, ...) always return a new instance of the list.

Creating a new id list

The bulk of the logic is in the IdList class. Creating a new id list is as simple as creating a new final readonly class and extending from it like the following:

<?php

declare(strict_types=1);

namespace App\ValueObject;

use DigitalCraftsman\Ids\ValueObject\IdList;

/** @extends IdList<UserId> */
final readonly class UserIdList extends IdLIst
{
    public static function handlesIdClass(): string
    {
        return UserId::class;
    }
}

Now you're already able to use it in your code like this:

$userIdList = new UserIdList($userIds);
if ($idsOfEnabledUsers->contains($command->userId)) {
    ...
}

Guard against invalid usages:

$idsOfEnabledUsers->mustContainId($command->targetUserId);

Or with custom exception:

$idsOfEnabledUsers->mustContainId(
    $command->targetUserId,
    static fn () => new Exception\UserIsNotEnabled(),
);

Symfony serializer

If you're injecting the SerializerInterface directly, there is nothing to do. The normalizer from digital-craftsman/self-aware-normalizers are registered automatically and will handle the serialization and deserialization of the IdList class, as it implements the ArrayNormalizable interface.

Doctrine types

To use an id list in your entities, you just need to register a new type for the id list. Create a new class for the new id list like the following:

<?php

declare(strict_types=1);

namespace App\Doctrine;

use App\ValueObject\UserIdList;
use DigitalCraftsman\SelfAwareNormalizers\Doctrine\ArrayNormalizableType;

final class UserIdListType extends ArrayNormalizableType
{
    protected function getTypeName(): string
    {
        return 'user_id_list';
    }

    protected function getClass(): string
    {
        return UserIdList::class;
    }
}

Then register the new type in your config/packages/doctrine.yaml file:

doctrine:
  dbal:
    types:
      user_id_list: App\Doctrine\UserIdListType

Then you're already able to add it into your entity like this:

<?php

declare(strict_types=1);

namespace App\Entity;

use App\ValueObject\UserIdList;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: InvestorRepository::class)]
#[ORM\Table(name: 'investor')]
class Investor
{
    #[ORM\Column(name: 'ids_of_users_with_access', type: 'user_id_list')]
    public UserIdList $idsOfUsersWithAccess;
    
    ...
}

Additional documentation