Type-Safe Identifiers with Symfony and Doctrine: Using Dedicated ID Classes
Learn how to enhance type safety in Symfony and Doctrine by using dedicated ID classes like BookId and UserId instead of raw UUIDs. This approach prevents identifier mix-ups, improves code clarity, and ensures better integration with Symfony Messenger and repository methods. Explore practical examples and best practices for implementing type-safe identifiers in your Symfony applications.
When working with Symfony and Doctrine, using UUIDs as entity identifiers is a common approach. Traditionally, IDs are stored as simple integers or as raw Uuid
objects. However, this can lead to type confusion, especially when working with Symfony Messenger or repository methods. A more robust and type-safe approach is to use dedicated ID classes.
Why Use Dedicated ID Classes?
Using a dedicated ID class, such as BookId
or UserId
, ensures that identifiers cannot be accidentally mixed up. This is particularly useful when dealing with Symfony Messenger messages, where multiple UUIDs might be passed. For example:
namespace App\Message;
final readonly class MarkBookAsRead
{
public function __construct(
public BookId $bookId,
public UserId $userId,
) {}
}
With this approach, we ensure that a BookId
is never confused with a UserId
. If we were using raw UUIDs, the parameters could easily be swapped, leading to hard-to-debug issues.
Implementing a Dedicated ID Class
Here’s how you can define a dedicated ID class for a Book
entity:
namespace App\Domain;
use Symfony\Component\Uid\Ulid;
final class BookId extends Ulid
{
}
Now, in your Book
entity, you can use this class as the identifier:
namespace App\Entity;
use App\Domain\BookId;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Book
{
#[ORM\Id]
#[ORM\Column(type: 'ulid', unique: true)]
private BookId $id;
public function __construct()
{
$this->id = new BookId();
}
public function getId(): BookId
{
return $this->id;
}
// ...
}
The needed Doctrine type
The last code example will end up with the following exception:
Cannot assign Symfony\Component\Uid\Ulid to property App\Entity\Book::$id of type App\Entity\BookId
To fix that, a BookIdType
is needed:
namespace App\Doctrine\Type;
use App\Domain\BookId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
final class BookIdType extends AbstractUidType
{
public function getName(): string
{
return self::class;
}
protected function getUidClass(): string
{
return BookId::class;
}
}
which needs to be registered and used:
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
App\Doctrine\Type\BookIdType: App\Doctrine\Type\BookIdType
namespace App\Entity;
+use App\Doctrine\Type\BookIdType;
use App\Domain\BookId;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Book
{
#[ORM\Id]
- #[ORM\Column(type: 'ulid', unique: true)]
+ #[ORM\Column(type: BookIdType::class, unique: true)]
private BookId $id;
public function __construct()
{
$this->id = new BookId();
}
public function getId(): BookId
{
return $this->id;
}
// ...
}
Type-Safe Repository Methods
Another advantage is type-safe repository methods. Instead of fetching entities by a generic string
UUID, you can enforce type safety at the method level:
namespace App\Repository;
use App\Domain\BookId;
use App\Entity\Book;
final class BookRepository
{
public function get(BookId $id): Book
{
$book = $this->find($id->toString());
if (null === $book) {
throw BookNotFoundException::withId($id);
}
return $book;
}
}
And define a meaningful exception:
namespace App\Exception;
use App\Domain\BookId;
final class BookNotFoundException extends \RuntimeException
{
public static function withId(BookId $id): self
{
return new self(sprintf('Book with ID %s not found.', $id->toString()));
}
}
Conclusion
Using dedicated ID classes in Symfony with Doctrine provides type safety, reducing the risk of identifier mix-ups and improving code clarity. This is especially useful when working with Symfony Messenger and repositories, where incorrect ID handling can lead to runtime errors. By implementing dedicated ID classes, you create a more robust and maintainable codebase.